mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-28 06:47:21 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6ad79dd58 | |||
| 82947d3595 | |||
| aef67b6499 | |||
| 8e88419f3a | |||
| 0197a4b538 | |||
| 7a3bf8cdbd | |||
| 3d3957a248 | |||
| b4026b43b2 | |||
| 851c295753 | |||
| f879946ead | |||
| d88f524d9f | |||
| 54ac37ec0a | |||
| 2b56666123 | |||
| a24d305665 | |||
| c47c8f9f74 | |||
| 3a00e9bd42 | |||
| c0c60dd91d | |||
| b5c253f3ed | |||
| 63d97f0877 | |||
| 4f04338ee1 | |||
| f96c94f950 | |||
| 5b5d68a444 | |||
| 0fb650a408 | |||
| b9f01162b2 | |||
| 5ab1d5191e | |||
| acebee4ea3 | |||
| 268a5519f1 | |||
| e452d837dd | |||
| 1451f3189b | |||
| 59bc6d7c94 | |||
| a56104206e | |||
| be485e6cab | |||
| fbd94e129d | |||
| cf5a9e0ba3 | |||
| 254592717a | |||
| aba9e2e9e6 | |||
| 924700a24f | |||
| b8eef694c7 | |||
| 26d55bb04e | |||
| c9f7e48a44 | |||
| 11e1662c90 | |||
| 7ea3aa2a93 | |||
| 4e48c390e7 | |||
| 02030f90a3 | |||
| d4d416ed47 | |||
| fa0a056bc1 | |||
| 1880bdca91 | |||
| 1c2f69eba9 | |||
| e193d34444 | |||
| fca714efe8 | |||
| 65bd2d575b | |||
| 58cab30351 | |||
| 5e863badc2 | |||
| b78e2ad6e7 | |||
| 605176fe75 | |||
| fea5a584c3 | |||
| e28ac2c9d4 | |||
| 4a96f06983 | |||
| 927b3969bf | |||
| fab4bb0586 | |||
| b7b26a6a49 | |||
| 197ab19f0c | |||
| a5e1e9312d | |||
| 62d148b045 | |||
| 8e6dc42772 | |||
| af10369f34 | |||
| c610d11e8f | |||
| c721416e4c | |||
| 7b29e9d65f | |||
| 85231b71e6 |
@@ -27,7 +27,4 @@ Risk and review shape:
|
|||||||
and list verified and unverified target classes in the PR Impact section.
|
and list verified and unverified target classes in the PR Impact section.
|
||||||
|
|
||||||
Available domain lenses are security, concurrency/durability, compatibility,
|
Available domain lenses are security, concurrency/durability, compatibility,
|
||||||
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
and performance; select only those matched by the change.
|
||||||
explicit adversarial request, a high-risk change, or a substantial PR review;
|
|
||||||
then read only its matching role references. A routine standard pass does not
|
|
||||||
load the playbook unless the reviewer needs a RustFS-specific probe.
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
name: adversarial-validation
|
|
||||||
description: Review RustFS diffs or designs for explicit adversarial requests, high-risk changes under the repository review policy, or substantial PR reviews. Skip ordinary questions, diagnosis, planning, status, routine low-risk implementation, and prose with no execution effect.
|
|
||||||
---
|
|
||||||
|
|
||||||
# RustFS Adversarial Validation
|
|
||||||
|
|
||||||
Use the [repository risk tiers and review shape](../../references/adversarial-validation.md). This skill
|
|
||||||
routes a review to RustFS-specific probes without loading unrelated domains.
|
|
||||||
|
|
||||||
## Select Lenses
|
|
||||||
|
|
||||||
Read only the references required by the diff:
|
|
||||||
|
|
||||||
| Lens | When to read |
|
|
||||||
|---|---|
|
|
||||||
| [Correctness](references/correctness.md) | Every non-exempt adversarial review |
|
|
||||||
| [Simplicity](references/simplicity.md) | Mechanical/standard changes and production growth |
|
|
||||||
| [Test coverage](references/test-coverage.md) | Behavior or test changes |
|
|
||||||
| [Security](references/security.md) | Authn/authz, IAM, RPC trust, paths, secrets, parsing, browser, encryption |
|
|
||||||
| [Concurrency/durability](references/concurrency-durability.md) | Async shared state, locks, storage commit, cancellation, persisted queues |
|
|
||||||
| [Compatibility](references/compatibility.md) | S3 surface, MinIO interop, metadata, wire/disk formats, mixed versions |
|
|
||||||
| [Performance](references/performance.md) | Request/object hot paths, allocation, blocking work, fsync, fan-out |
|
|
||||||
|
|
||||||
Do not read all references as a precaution. A path name alone is insufficient;
|
|
||||||
the changed behavior must touch the lens's domain.
|
|
||||||
|
|
||||||
For a dedicated security audit or advisory analysis, use
|
|
||||||
`security-advisory-lessons` instead of loading it automatically during every
|
|
||||||
adversarial review.
|
|
||||||
|
|
||||||
## Review Protocol
|
|
||||||
|
|
||||||
1. Freeze the exact final diff/head (or the design under review) and list the
|
|
||||||
selected lenses.
|
|
||||||
2. Run the review shape required by the repository risk tier.
|
|
||||||
3. For each selected lens, either report a concrete finding or a null verdict
|
|
||||||
naming the attacks performed.
|
|
||||||
4. Apply root `AGENTS.md`'s finding standard. Test each candidate against callers,
|
|
||||||
existing coverage, and invariants before accepting it; an adversarial role
|
|
||||||
does not have to produce a defect.
|
|
||||||
5. Fix or rebut supported findings with code-path, test, or invariant evidence.
|
|
||||||
6. After a non-trivial edit, rerun only lenses affected by that edit against the
|
|
||||||
new exact diff.
|
|
||||||
|
|
||||||
Do not turn a null verdict into a long checklist. Record concise evidence that
|
|
||||||
the relevant failure classes were attacked, then stop under the root completion
|
|
||||||
rule. Keep the required per-lens verdicts for high-risk PRs.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Compatibility Lens
|
|
||||||
|
|
||||||
- Internal metadata uses `metadata_compat` helpers for dual RustFS/MinIO keys,
|
|
||||||
including mixed casing and removal of both twins.
|
|
||||||
- Binary UUID metadata treats absent, empty, and nil as no value. Unversioned
|
|
||||||
remote tiers receive no `versionId`; versioned purge requests retain the real
|
|
||||||
version ID.
|
|
||||||
- `xl.meta` changes preserve supported header/meta versions, recompute
|
|
||||||
signatures, decode legacy fixtures, and remain readable by old RustFS/MinIO.
|
|
||||||
- Foreign/corrupt metadata validates parallel array lengths and missing fields;
|
|
||||||
it returns a decode error rather than indexing, panicking, or fabricating data.
|
|
||||||
- Do not “correct” byte-for-byte MinIO ports without legacy fixture evidence.
|
|
||||||
Bitrot framing, shard math, distribution, and inline prefixes are contracts.
|
|
||||||
- Client-visible metadata/events strip both internal prefixes
|
|
||||||
case-insensitively.
|
|
||||||
- Proto fields are appended, never reused/renumbered; FlatBuffers tables extend
|
|
||||||
compatibly and absent new fields fail closed where authorization/quorum is
|
|
||||||
involved.
|
|
||||||
- Replay real client request shapes and exact pagination boundaries for S3
|
|
||||||
handler changes.
|
|
||||||
- Bucket metadata/IAM/config parsing remains compatible with pinned real MinIO
|
|
||||||
fixtures and encrypted migration data.
|
|
||||||
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
|
|
||||||
condition, and default toward reading old data safely.
|
|
||||||
|
|
||||||
## Outbound targets
|
|
||||||
|
|
||||||
- A change to what the replication or migration client sends by default
|
|
||||||
(checksum policy, payload framing, headers, version-id addressing) is judged
|
|
||||||
against every target class, not the one it fixes. Name each target-side rule
|
|
||||||
the current default satisfies — checksum required with Object Lock
|
|
||||||
parameters, `aws-chunked` decoding, version-id adoption, ETag equals content
|
|
||||||
MD5 — and show which cell of
|
|
||||||
`crates/e2e_test/src/replication_target_matrix_test.rs` covers each.
|
|
||||||
- A test that asserts the fix ("no trailer header") is not evidence; the
|
|
||||||
matrix cell that asserts the target accepted and stored the object is.
|
|
||||||
- Every new environment escape hatch appears in
|
|
||||||
`docs/operations/replication-outbound-transport.md` in the same diff.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Concurrency and Durability Lens
|
|
||||||
|
|
||||||
- For every changed lock, enumerate overlapping lock sets and construct the
|
|
||||||
ABBA interleaving. Multiple-lock order must be documented and consistent.
|
|
||||||
- Mark guard lifetimes and every `.await`, disk,
|
|
||||||
and RPC call; estimate contention/timeouts. Tokio read/write guards across
|
|
||||||
`.await` need bounded hold time; long-lived reads can wedge writers (#4195).
|
|
||||||
Keep `std::sync::Mutex` holds brief and never across `.await`.
|
|
||||||
- Use direct `fetch_*` for unconditional atomic read-modify-write and
|
|
||||||
`compare_exchange` for conditional updates.
|
|
||||||
- Object commits remain fenced if the distributed lock is lost after shard
|
|
||||||
writes and before metadata rename.
|
|
||||||
- For write/rename changes, trace `write tmp -> sync tmp -> rename -> sync parent
|
|
||||||
-> sync required ancestors`; simulate a crash after each step and honor the
|
|
||||||
configured durability gate.
|
|
||||||
- Multi-disk fan-out counts every result. Quorum-minus-one cannot become success;
|
|
||||||
heal remains best-effort per target where that is the established contract.
|
|
||||||
- At every new cancellable await between mutation and cleanup/commit, drop the
|
|
||||||
future and inspect leftover files, counters, permits, and replay state.
|
|
||||||
- Multipart operations on the same upload ID are serialized where required;
|
|
||||||
abort/complete/list races cannot delete parts before durable commit.
|
|
||||||
- Post-commit cleanup is best-effort, retry-safe, and cannot fail an already
|
|
||||||
committed write or delete the last surviving copy.
|
|
||||||
- Persisted read-modify-write uses serialization/CAS. Queue replay is crash-safe
|
|
||||||
and duplicate delivery has an idempotency contract.
|
|
||||||
- Streaming reconstruction failures after partial output surface as errors, not
|
|
||||||
successful EOF.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Correctness Lens
|
|
||||||
|
|
||||||
Attack the changed behavior, not every subsystem in the repository.
|
|
||||||
|
|
||||||
- Trace new error paths to the caller. Inject the ignored/wildcard variants and
|
|
||||||
verify they cannot become success, not-found, or a plausible default.
|
|
||||||
- Exercise zero/empty/missing, maximum, and exact-boundary inputs for every
|
|
||||||
changed count, size, index, page limit, or optional value.
|
|
||||||
- For aggregation/quorum changes, test exactly quorum and quorum-minus-one with
|
|
||||||
mixed disk errors and nil/placeholder entries.
|
|
||||||
- For listing/pagination, test `n == max`, `n == max + 1`, delimiter folding,
|
|
||||||
continuation markers, and object/prefix name collisions.
|
|
||||||
- For EC/read/streaming changes, inject failure after partial output and verify
|
|
||||||
the client receives an error rather than a clean truncated body. Assert exact
|
|
||||||
bytes and length.
|
|
||||||
- For multipart/object commits, fail before/after rename and cleanup; committed
|
|
||||||
data must remain readable and pre-commit cleanup must not destroy parts.
|
|
||||||
- Bound recursion over untrusted or persisted input; handle corrupted/cyclic
|
|
||||||
tree and cache traversals safely.
|
|
||||||
- For version/index ordering, test `len - 1`, `len`, equal timestamps, missing
|
|
||||||
versions, and deterministic tie-breaking.
|
|
||||||
- For directory-object behavior, trace `__XLDIR__` at the store layer; branches
|
|
||||||
below the layer that sees trailing slashes are dead.
|
|
||||||
- For binary UUID metadata, absent, empty, and nil all mean no value. Never send
|
|
||||||
nil/empty `versionId` to an unversioned tier.
|
|
||||||
- For agent rules/skill routers, test a trigger matrix covering ordinary
|
|
||||||
inquiry, low-risk implementation, explicit review, high-risk code, PR
|
|
||||||
creation, release, and post-PR monitoring. Each case must select only the
|
|
||||||
intended workflow and retain required safety/authorization boundaries.
|
|
||||||
|
|
||||||
Null verdicts name only the probes relevant to the diff.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Performance Lens
|
|
||||||
|
|
||||||
- For added clones/allocations on request/object/block paths, quantify copied
|
|
||||||
data and frequency. Recommend borrowing, move, `Bytes`/`Arc`, `Cow`, or
|
|
||||||
capacity reservation only for a concrete repeated cost.
|
|
||||||
- Route every new sync/flush through the durability-mode and bucket override
|
|
||||||
gates; mode `none` must not pay the new fsync.
|
|
||||||
- Keep blocking filesystem/CPU work off async runtime threads, but do not split
|
|
||||||
one small operation into many `spawn_blocking` round trips.
|
|
||||||
- Measure lock hold time across I/O and compare acquisition order for ABBA.
|
|
||||||
- Keep cleanup, extra stat/rename, and diagnostics out of the PUT commit critical
|
|
||||||
section when they need not be there.
|
|
||||||
- Detect per-item serial I/O/RPC in batch APIs and accidental quadratic scans;
|
|
||||||
use a gate or bounded concurrency when the concrete fan-out warrants it.
|
|
||||||
- Count buffer growth and byte copies in EC/bitrot paths; preserve pool gauge
|
|
||||||
balance and avoid repeated metadata decode/fetch per object.
|
|
||||||
- Repetitive success logs stay at `trace`; metrics/instrumentation on hot paths
|
|
||||||
require an existing gate.
|
|
||||||
- Claims of no impact on PUT/GET/commit/erasure paths need relevant benchmark or
|
|
||||||
A/B evidence, especially for 4 KiB objects.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Security Lens
|
|
||||||
|
|
||||||
Use `security-advisory-lessons` only for a dedicated advisory/security audit.
|
|
||||||
For an ordinary matched diff, attack these boundaries:
|
|
||||||
|
|
||||||
- Admin routes: route registration, whitelist, handler authn, and the exact
|
|
||||||
`AdminAction` must agree. Read-only diagnostics still require admin authz.
|
|
||||||
- IAM/service accounts: treat parent, claims, keys, groups, status, and policy
|
|
||||||
names as attacker-controlled; prove ownership/root authority before writes.
|
|
||||||
- Protocol frontends: every changed/sibling command authorizes the matching S3
|
|
||||||
action before reaching storage.
|
|
||||||
- Secrets/signatures: use constant-time comparison, normalize public failures,
|
|
||||||
keep RPC/root/STS keys independent, and fail closed when secrets are absent.
|
|
||||||
- RPC: bind signatures to the exact method/path and timestamp; reject replay,
|
|
||||||
stale, malformed, truncated, and invalid-enum payloads without panic.
|
|
||||||
- Paths/object/archive entries: reject traversal, absolute/platform escapes,
|
|
||||||
and normalization differences between authz and storage.
|
|
||||||
- Copy/multipart/presigned POST: enforce source, destination, version-aware
|
|
||||||
actions, copy-source conditions, and every signed policy condition.
|
|
||||||
- Logging/errors: never expose credentials, tokens, expected signatures, raw
|
|
||||||
secret-bearing input, or merged configs—including via `Debug` and parse errors.
|
|
||||||
- Untrusted serde: reject unknown fields where compatible and validate
|
|
||||||
security-critical defaults/ranges before numeric conversion.
|
|
||||||
- SSE/browser/CORS/trusted proxy: inspect stored ciphertext and wrapper order;
|
|
||||||
isolate user content; never reflect credentialed arbitrary origins or trust
|
|
||||||
forwarded identity from direct clients.
|
|
||||||
- Object Lock: unreadable/fabricated/unparsable metadata fails closed across
|
|
||||||
foreground, lifecycle, scanner, and force-delete paths.
|
|
||||||
|
|
||||||
Security findings distinguish unauthenticated compromise from a
|
|
||||||
low-privileged authenticated bypass.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Simplicity Lens
|
|
||||||
|
|
||||||
- Compare the production diff with the smallest equivalent local edit. Fewer
|
|
||||||
lines alone are not evidence; the replacement must preserve correctness,
|
|
||||||
compatibility, readability, and real boundaries.
|
|
||||||
- Search the touched crate, domain owner, `crates/utils`, `crates/common`, and
|
|
||||||
relevant dependencies for each new helper, constant, wrapper, or fixture.
|
|
||||||
- Reject forced reuse when normalization, error, deadline, or durability
|
|
||||||
semantics differ.
|
|
||||||
- Require a concrete trigger for every new defensive branch. Keep boundary
|
|
||||||
checks for disk/RPC/version data and checks immediately before destructive
|
|
||||||
actions.
|
|
||||||
- Flag one-caller helpers only when they merely forward or split a short linear
|
|
||||||
flow without adding domain naming, invariant isolation, or useful context.
|
|
||||||
- Ensure a replacement removes the superseded in-scope path or keeps one
|
|
||||||
canonical core behind a documented compatibility adapter.
|
|
||||||
- Remove narration/change-history comments; preserve concise safety, lock,
|
|
||||||
durability, and compatibility invariants.
|
|
||||||
- Treat tests, fixtures, generated code, and documentation separately from
|
|
||||||
production growth. Do not optimize away meaningful regression coverage.
|
|
||||||
|
|
||||||
A finding must include a concrete smaller design, not a style preference.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Test-Coverage Lens
|
|
||||||
|
|
||||||
- For every behavior claim, name the focused test/check that fails if the
|
|
||||||
changed hunk is reverted. If none is practical, require the reason and
|
|
||||||
residual risk.
|
|
||||||
- Confirm tests exercise the real production path and distinguish the intended
|
|
||||||
behavior from the named regression. A success, `is_err()`, or no-panic check
|
|
||||||
can be sufficient when that is the actual contract; require exact values,
|
|
||||||
bytes, state, or error variants when those distinctions matter to the change.
|
|
||||||
- For new flags/modes, verify each branch and ask which test fails if the branch
|
|
||||||
is inverted.
|
|
||||||
- For new error propagation, inject the failure and assert the caller observes
|
|
||||||
it; mentally replacing `?`/`return Err` with success must break a test.
|
|
||||||
- Streaming GET tests assert the complete body and length under degraded reads.
|
|
||||||
- Disk/wire-format tests use pinned foreign/legacy fixtures; same-code
|
|
||||||
round-trips are insufficient for compatibility.
|
|
||||||
- Concurrency tests use readiness polling, isolate global state, and avoid fixed
|
|
||||||
sleeps or unrealistically short timeouts. Use nextest groups when process-level
|
|
||||||
serialization is required.
|
|
||||||
- Internal metadata tests assert both RustFS and MinIO keys, not only read-back
|
|
||||||
through a helper that prefers one key.
|
|
||||||
- Avoid duplicate tests of the same production path and poison-value class.
|
|
||||||
Boundary companions are distinct coverage: `n == max` vs `max + 1`, and
|
|
||||||
absent vs empty vs nil UUID.
|
|
||||||
- A focused test proves only the targets/features it builds. Add compilation or
|
|
||||||
Clippy only for uncovered changed targets.
|
|
||||||
@@ -13,7 +13,7 @@ description: Review a GitHub PR using its exact base/head and repository risk po
|
|||||||
```
|
```
|
||||||
|
|
||||||
Record both SHAs; refresh if either moved during fetching. Follow relevant [implementation boundaries](../../references/implementation.md).
|
Record both SHAs; refresh if either moved during fetching. Follow relevant [implementation boundaries](../../references/implementation.md).
|
||||||
2. Apply the [risk policy](../../references/adversarial-validation.md). Ordinary reviews use the root finding standard directly. For explicit adversarial, substantial, or high-risk reviews, use [the domain probes](../adversarial-validation/SKILL.md). File count does not authorize delegation; use exactly two independent reviewers only when authorized, otherwise two fresh sequential passes for that risk tier. Reviewers do not spawn further agents.
|
2. Apply the [risk policy](../../references/adversarial-validation.md). Ordinary reviews use the root finding standard directly. File count does not authorize delegation; use exactly two independent reviewers only when authorized, otherwise two fresh sequential passes for that risk tier. Reviewers do not spawn further agents.
|
||||||
3. Check `gh pr checks <N> --repo <owner/repo>`. Investigate failures when relevant to a finding or a requested CI/readiness diagnosis. Distinguish pending, unavailable, pre-existing, flaky, and PR-caused states using evidence; a code review does not authorize unrelated repairs.
|
3. Check `gh pr checks <N> --repo <owner/repo>`. Investigate failures when relevant to a finding or a requested CI/readiness diagnosis. Distinguish pending, unavailable, pre-existing, flaky, and PR-caused states using evidence; a code review does not authorize unrelated repairs.
|
||||||
4. Report the reviewed SHAs, risk tier, supported findings or `No findings`, material verification limits, check state, and verdict. P0/P1 findings block approval. Severity: P0 = demonstrated data loss, security breach, remote crash, or deadlock; P1 = correctness, compatibility, or material hot-path regression; P2 = a concrete maintainability defect; P3 = optional style, only when requested.
|
4. Report the reviewed SHAs, risk tier, supported findings or `No findings`, material verification limits, check state, and verdict. P0/P1 findings block approval. Severity: P0 = demonstrated data loss, security breach, remote crash, or deadlock; P1 = correctness, compatibility, or material hot-path regression; P2 = a concrete maintainability defect; P3 = optional style, only when requested.
|
||||||
5. When posting is authorized, follow [Git and PR rules](../../references/pull-requests.md), recheck the remote head, and review any delta before submitting. Use `gh pr review` with `--approve`, `--request-changes`, or `--comment` and `--body-file`; use [the API example](references/posting.md) for authorized inline comments. Reuse existing authorization.
|
5. When posting is authorized, follow [Git and PR rules](../../references/pull-requests.md), recheck the remote head, and review any delta before submitting. Use `gh pr review` with `--approve`, `--request-changes`, or `--comment` and `--body-file`; use [the API example](references/posting.md) for authorized inline comments. Reuse existing authorization.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
|
|||||||
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
|
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
|
||||||
| Confirmation | Complete the gate below and end the turn. |
|
| Confirmation | Complete the gate below and end the turn. |
|
||||||
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
|
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
|
||||||
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
|
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website; reconcile main and remove the completed release branch](references/post-release-updates.md) only after complete publication. |
|
||||||
|
|
||||||
## Human Confirmation
|
## Human Confirmation
|
||||||
|
|
||||||
@@ -37,6 +37,6 @@ Only a new affirmative user reply to that exact report authorizes Phase 6. The o
|
|||||||
|
|
||||||
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
|
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
|
||||||
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
|
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
|
||||||
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting candidate branches or preview tags requires an explicit decision.
|
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting unpublished candidate branches or preview tags requires an explicit decision. Completed release branches follow Phase 7 cleanup; keep release tags.
|
||||||
|
|
||||||
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
|
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
|
||||||
|
|||||||
@@ -48,3 +48,10 @@ Use the existing announcement configuration from [website PR #112](https://githu
|
|||||||
3. Use the site's current package manager and required checks. For this config-only change, type-check and lint the changed file, build the site, and inspect the generated homepage for the version, visible link text, and destination. Confirm `/download` actually offers the announced version before using that link.
|
3. Use the site's current package manager and required checks. For this config-only change, type-check and lint the changed file, build the site, and inspect the generated homepage for the version, visible link text, and destination. Confirm `/download` actually offers the announced version before using that link.
|
||||||
4. Commit, push, and create or update the website PR when included in the authorized release scope. Include the published RustFS Release URL and checks run. Keep merge and production-deployment authority separate; reuse permission already given, but this reference grants none by itself.
|
4. Commit, push, and create or update the website PR when included in the authorized release scope. Include the published RustFS Release URL and checks run. Keep merge and production-deployment authority separate; reuse permission already given, but this reference grants none by itself.
|
||||||
5. After an authorized merge/deployment, verify the live `https://rustfs.com/` banner and its destination. If only the PR is ready, report that state and URL; do not report the banner as deployed. A website failure does not invalidate or move the published release tag: retry this phase after resolving the failure.
|
5. After an authorized merge/deployment, verify the live `https://rustfs.com/` banner and its destination. If only the PR is ready, report that state and URL; do not report the banner as deployed. A website failure does not invalidate or move the published release tag: retry this phase after resolving the failure.
|
||||||
|
|
||||||
|
## Reconcile main and remove the completed release branch
|
||||||
|
|
||||||
|
1. Compare the exact published tag with current main. Audit every release-side commit since their merge base, including inherited backports. An ahead count is a history difference, not proof of missing changes. Use ancestry and patch equivalence, then inspect current code and regression evidence for squashed, adapted, superseded, or reverted changes. Record each commit as present, superseded, release-only, or missing; a positive `git cherry` result alone does not prove a missing fix.
|
||||||
|
2. Bring missing shared changes to main through a reviewed PR and the required checks before closing the follow-up. Preserve main's later changes; decide version bumps and pinned Console configuration separately. If a history merge is needed, use a merge commit and verify the release commit is an ancestor of main; squash does not establish that ancestry. Do not merge main into the published release branch or move its tags. Report unresolved changes instead of claiming reconciliation is complete.
|
||||||
|
3. After publication and authorized follow-ups are complete, delete the remote `release/<target>` branch. Immediately before deletion, verify its HEAD still equals the published tag's commit and no open PR targets or originates from it. Preserve a branch with newer work or open PRs and report the blocker. Use a deletion with an expected-old-SHA check to avoid removing a concurrently advanced branch.
|
||||||
|
4. Verify the remote branch is absent and the final and preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
name: security-advisory-lessons
|
|
||||||
description: Perform a dedicated RustFS security/advisory review for authn/authz, IAM, RPC trust, paths, secrets, browser isolation, encryption, Object Lock, or other security boundaries. Use only when the user requests a security/advisory review or an adversarial review explicitly escalates to the full advisory map; do not auto-load solely because code touches a sensitive path.
|
|
||||||
---
|
|
||||||
|
|
||||||
# RustFS Security Advisory Lessons
|
|
||||||
|
|
||||||
Use this skill as the deep security lens. For a normal adversarial review with a
|
|
||||||
matched security surface, the concise security reference under
|
|
||||||
`adversarial-validation` is sufficient.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
1. Freeze the exact diff/head and identify the changed trust boundaries.
|
|
||||||
2. Inspect the headings in [advisory-patterns.md](references/advisory-patterns.md),
|
|
||||||
then read the matching sections. Read the full map only for a broad security
|
|
||||||
audit. Useful headings are
|
|
||||||
auth/admin, IAM/STS/OIDC, policy/plugins, S3/copy/multipart, protocols, paths,
|
|
||||||
secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde.
|
|
||||||
3. Trace unauthenticated, low-privilege, wrong-action/owner/bucket, malformed,
|
|
||||||
and default-config cases. Security decisions must fail closed.
|
|
||||||
4. Require a focused negative regression test for the bypass/exploit form, not
|
|
||||||
only the intended success path. State residual risk when a test is impractical.
|
|
||||||
5. Report proven vulnerabilities separately from defense-in-depth hardening.
|
|
||||||
|
|
||||||
When advisory currentness matters, fetch the live inventory instead of treating
|
|
||||||
the reference as a status mirror:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh api repos/rustfs/rustfs/security-advisories --paginate \
|
|
||||||
--jq '.[] | {ghsa_id,state,severity,summary,updated_at}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Fetch an individual advisory only when the live summary indicates a new or
|
|
||||||
changed lesson.
|
|
||||||
|
|
||||||
## Finding Standard
|
|
||||||
|
|
||||||
Every candidate names the lower-trust principal, the boundary it crosses, the
|
|
||||||
affected resource, and the observable result. A best-practice gap or an impact
|
|
||||||
confined to the caller's own principal is hardening, not a candidate.
|
|
||||||
|
|
||||||
Report each candidate under exactly one verdict:
|
|
||||||
|
|
||||||
- `confirmed`: a complete `file:line` source trace and a bounded observed
|
|
||||||
result. Only confirmed findings carry severity; each also states attacker
|
|
||||||
prerequisites, concrete input/path, impact, smallest safe fix, and a
|
|
||||||
regression check.
|
|
||||||
- `needs_validation`: the trace is blocked on one exact unresolved fact, such
|
|
||||||
as deployment or infrastructure behavior. State that fact; assign no severity
|
|
||||||
and do not substitute an assumption.
|
|
||||||
- `rejected`: a disproved candidate, with the caller, invariant, or test that
|
|
||||||
disproves it.
|
|
||||||
|
|
||||||
Do not exaggerate unauthenticated impact when the actual issue requires
|
|
||||||
authenticated low privilege.
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# RustFS Advisory Pattern Map
|
|
||||||
|
|
||||||
This file is a lesson map, not an advisory inventory mirror. It keeps durable security patterns distilled from RustFS GitHub Security Advisories.
|
|
||||||
|
|
||||||
When current advisory state, severity, URLs, or full text matters, fetch it live:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh api repos/rustfs/rustfs/security-advisories --paginate \
|
|
||||||
--jq '.[] | {ghsa_id,state,severity,summary,updated_at}'
|
|
||||||
gh api repos/rustfs/rustfs/security-advisories/<GHSA_ID>
|
|
||||||
```
|
|
||||||
|
|
||||||
Update this file only when an advisory adds or changes a reusable lesson, affected surface, validation pattern, or regression-test expectation. Do not update it for state-only, URL-only, count-only, or timestamp-only changes.
|
|
||||||
|
|
||||||
## Pattern Index
|
|
||||||
|
|
||||||
### Admin authorization and route exposure
|
|
||||||
|
|
||||||
- `GHSA-pfcq-4gjr-6gjm`: notification target endpoints accepted authenticated users but skipped admin authorization. Lesson: distinguish authn from authz; admin target CRUD must call the operation-specific admin authorization path.
|
|
||||||
- `GHSA-mm2q-qcmx-gw4w`: `ListServiceAccount` used `UpdateServiceAccountAdminAction`, while update lacked target ownership checks. Lesson: exact action constants and ownership checks are both required; information disclosure can chain into secret rotation and takeover.
|
|
||||||
- `GHSA-vcwh-pff9-64cc`: `ImportIam` checked `ExportIAMAction` for an import/write operation. Lesson: every admin handler must authorize the action it actually performs.
|
|
||||||
- `GHSA-jqmc-mg33-v45g` and `GHSA-8784-9m7f-c6p6`: `/profile/cpu` and `/profile/memory` were whitelisted from auth and allowed expensive diagnostics plus path disclosure. Lesson: profiling/debug endpoints need admin auth, opt-in, rate limits, and non-sensitive responses.
|
|
||||||
- `GHSA-f5cv-v44x-2xgf`: `/rustfs/admin/v3/metrics` accepted any authenticated IAM user and skipped admin authorization. Lesson: read-only metrics and diagnostic admin endpoints still require an operation-specific admin action check.
|
|
||||||
- `GHSA-796f-j7xp-hwf4`: `/rustfs/admin/v3/list-remote-targets` checked only that credentials existed and leaked replication target credentials. Lesson: replication target reads are privileged admin operations, and stored remote credentials require strict authz plus response redaction review.
|
|
||||||
- `GHSA-xp32-gxq2-3v52`: console license metadata endpoint was public and exposed subject and expiration fields. Lesson: management metadata endpoints should require admin auth or return only coarse public status.
|
|
||||||
|
|
||||||
### IAM import, service accounts, and privilege boundaries
|
|
||||||
|
|
||||||
- `GHSA-566f-q62r-wcr8`: `ImportIam` accepted attacker-controlled service account `parent`, `claims`, `accessKey`, and `secretKey`, enabling persistent backdoor accounts under root. Lesson: imported IAM payloads are untrusted data and must be validated against privilege boundaries.
|
|
||||||
- `GHSA-3495-h8r9-gfqg`: `ExportIAM` wrote regular-user and service-account secret keys into exported ZIP data. Lesson: IAM export is a credential-disclosure boundary; redact, seal, or strictly justify every exported secret before treating export permission as safe.
|
|
||||||
- `GHSA-5354-r3w2-34m8`: `AddServiceAccount` checked `CreateServiceAccountAdminAction` but trusted caller-supplied `target_user`, allowing service accounts under the root parent. Lesson: service-account create paths must validate parent ownership or root/admin authority, not only the create action.
|
|
||||||
- `GHSA-xgr5-qc6w-vcg9`: `deny_only=true` skipped allow checks and let restricted service accounts mint unrestricted children. Lesson: deny-only logic must never become implicit allow for privilege creation.
|
|
||||||
- `GHSA-mm2q-qcmx-gw4w`: leaked service account access keys plus update-without-ownership formed an escalation chain. Lesson: service-account identifiers are security-sensitive because update APIs consume them.
|
|
||||||
|
|
||||||
### STS, OIDC, and federation flows
|
|
||||||
|
|
||||||
- `GHSA-5qfg-mf7r-jp3w` and `GHSA-3473-5353-xhwh`: `AssumeRoleWithWebIdentity` was reachable through unauthenticated `POST /` routing and could issue temporary credentials from crafted web identity input. Lesson: every STS route needs explicit SigV4 or trusted identity-provider validation before role assumption, and unauthenticated exemptions must be narrowed to the exact action with uniform failure responses.
|
|
||||||
- `GHSA-jxrr-r6pv-h958`: unsigned JWT issuer data was decoded before verification to select an OIDC provider, and distinguishable failures could expose provider configuration. Lesson: web-identity routing may be unauthenticated, but pre-verification claims are untrusted routing hints; bound and rate-limit the request, normalize public errors, and verify signature, issuer, audience, and expiration before issuing credentials.
|
|
||||||
- `GHSA-ccrv-v8v9-ch9q`, `GHSA-48rf-7j3q-3hfv`, and `GHSA-xvfh-7c9g-hpw2`: service-account-controlled material could self-sign JWT session tokens with forged policy claims, and missing `exp` was accepted for service-account tokens. Lesson: session tokens must be signed by a trusted issuer/key path, enforce required claims and expiration, and reject self-signed or principal-controlled tokens.
|
|
||||||
- `GHSA-9pjf-w3c2-m32r`, `GHSA-4x2q-cpx9-9h26`, and `GHSA-xvpm-p3f7-34c3`: public OIDC authorize/callback flows trusted request `Host` or forwarded scheme when building credential-bearing redirects. Lesson: OIDC redirects must use configured allowlisted origins and trusted-proxy handling; never derive the post-login credential destination from direct client headers.
|
|
||||||
- `GHSA-m479-9x88-94w6`, `GHSA-frwq-mfqx-83p8`, `GHSA-q9q8-rf9r-fg9f`, and `GHSA-j5c2-hhf7-6gf5`: OIDC validation accepted attacker-controlled discovery URLs because hostname checks rejected only literal forbidden IPs, allowing DNS rebinding SSRF. Lesson: outbound federation URL validation must resolve and classify hostnames at the connection boundary and reject loopback, private, link-local, and rebound addresses.
|
|
||||||
|
|
||||||
### IAM policy conditions and external policy plugins
|
|
||||||
|
|
||||||
- `GHSA-6r96-hmgc-726c`: request headers collided with lowercase server-derived condition keys such as `userid`, `groups`, `versionid`, and JWT/LDAP claims. Lesson: never let caller-controlled headers append to or replace server-derived policy context; reserve trusted condition keys and keep intentional request-header keys separate.
|
|
||||||
- `GHSA-v9cp-qfw9-9pfp`: quantified negated string conditions applied negation after aggregation, transposing `ForAllValues` and `ForAnyValue` semantics. Lesson: push negation into the per-value predicate for quantified operators and test partially overlapping multi-value sets.
|
|
||||||
- `GHSA-5w8r-p896-6vq2`: OPA policy mode skipped `ExistingObjectTag/*` loading, so tagged objects looked untagged to external policies. Lesson: external authorization plugins need the same object-tag and request context as built-in policy evaluation before they decide.
|
|
||||||
- `GHSA-9wgh-5r68-49p7`: substituted policy-variable values could recreate `${...}` and loop inside a single resolution pass, never reaching the outer iteration limit. Lesson: bound substitutions within each pass and test cycles formed across otherwise literal resolver values; confirm whether attacker-controlled claims can reach those values before assigning remote impact.
|
|
||||||
|
|
||||||
### S3 object actions, copy, multipart, and upload policy validation
|
|
||||||
|
|
||||||
- `GHSA-g8w9-qw9q-fghr` and `GHSA-xm99-m3gq-83g8`: a valid presigned `PutObject` accepted extra `x-amz-*` headers omitted from `SignedHeaders`; `x-amz-copy-source` could turn that upload capability into a cross-bucket read performed as the signer. Lesson: a presigned URL is a bounded capability; inspect all received security-sensitive headers and reject unsigned ones before selecting the S3 operation or reaching storage.
|
|
||||||
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
|
|
||||||
- `GHSA-cg33-pxfp-qcj3` and `GHSA-65wq-gjw8-xr9w`: `HeadObject` and object-tagging paths authorized current-object actions while operating on an explicit historical `versionId`. Lesson: select the corresponding version action for metadata and tag reads and writes too; audit every operation that accepts a version ID.
|
|
||||||
- `GHSA-gjph-xcgw-4p7w`: `PutObject` and browser POST accepted a caller-supplied `versionId` and replaced an unlocked historical version with only `s3:PutObject`. Lesson: only replication-authorized writes may choose an existing version ID; ordinary uploads must not rewrite version history.
|
|
||||||
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
|
|
||||||
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
|
||||||
- `GHSA-wfxj-ph3v-7mjf`: `UploadPartCopy` checked source and destination independently but missed destination copy-source policy constraints. Lesson: source read and destination write checks are not sufficient when policy constrains allowed copy sources.
|
|
||||||
- `GHSA-w5fh-f8xh-5x3p`: presigned POST accepted uploads without enforcing signed policy conditions. Lesson: parse and enforce all POST policy constraints server-side, including size, key prefix, and content type.
|
|
||||||
|
|
||||||
### Protocol frontends and IAM parity
|
|
||||||
|
|
||||||
- `GHSA-3g29-xff2-92vp`: FTP `RETR` and `SIZE`/`MDTM` read paths authenticated the user but skipped IAM before calling storage. Lesson: non-HTTP protocol frontends must enforce the same per-operation authorization as the S3 API before backend access.
|
|
||||||
- `GHSA-g3vq-vv42-f647`: FTPS `MKD` called `create_bucket` without checking `s3:CreateBucket`. Lesson: protocol command handlers need action-specific checks even when sibling handlers already authorize correctly.
|
|
||||||
- `GHSA-3p3x-734c-h5vx`: FTPS and WebDAV compared secret keys with early-return string equality, while FTPS also returned distinguishable invalid-user and invalid-password failures. Lesson: password-style protocol auth needs constant-time secret comparison, indistinguishable failures where practical, and rate limiting.
|
|
||||||
- `GHSA-rhg6-jrf2-29c4`: WebDAV, FTP/FTPS, and SFTP used identity-policy checks with empty conditions and bypassed S3 bucket-policy explicit denies and condition-key denies. Lesson: alternate listeners must evaluate the same effective policy and trustworthy request context before storage access.
|
|
||||||
|
|
||||||
### Filesystem paths and object key traversal
|
|
||||||
|
|
||||||
- `GHSA-pq29-69jg-9mxc`: RPC `read_file_stream` joined untrusted paths under a volume directory without canonical boundary checks. Lesson: `PathBuf::join` plus length checks are not path security.
|
|
||||||
- `GHSA-8r6f-hmq2-28rg`: object keys containing traversal sequences bypassed bucket/object authorization when mapped to filesystem paths. Lesson: reject traversal at object-key parsing and verify final storage paths remain under the expected bucket/key root.
|
|
||||||
- `GHSA-f4vq-9ffr-m8m3`: Snowball auto-extract accepted archive entries such as `../victim-bucket/object`, authorized the raw attacker-bucket path, then storage path cleaning crossed bucket boundaries. Lesson: archive entries become object keys and need traversal rejection plus consistent authz/storage normalization before writes.
|
|
||||||
- `GHSA-g7fq-6xjm-mxf4`: versioned `DeleteObjects` accepted dot-segment keys, authorized the literal prefix, then wrote delete markers under a resolved sibling prefix. Lesson: validate each batch key before authorization and storage normalization, using the same object-name rules as single delete.
|
|
||||||
|
|
||||||
### Secrets, defaults, and cryptographic misuse
|
|
||||||
|
|
||||||
- `GHSA-j59h-h7q5-q348`, `GHSA-3wm5-wpm5-hmfm`, `GHSA-6wc8-xm48-qhmx`, `GHSA-9gf3-jx4p-4xxf`, `GHSA-63xc-c3w3-m2cf`, and `GHSA-ch63-6q4v-hwp5`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, console, and token-signing surfaces. Lesson: root credentials must be operator-provided or generated per install; known defaults and warnings are not acceptable for network-reachable deployments.
|
|
||||||
- `GHSA-h956-rh7x-ppgj`: gRPC used the hard-coded token `rustfs rpc` on both client and server. Lesson: source-visible shared tokens are authentication bypasses.
|
|
||||||
- `GHSA-r5qv-rc46-hv8q`: internode RPC HMAC secret fell back to the public default `rustfsadmin`. Lesson: RPC/internode auth must fail closed instead of silently using public defaults.
|
|
||||||
- `GHSA-75fx-qg6f-8rm7` and `GHSA-68cw-96m3-h2cf`: internode RPC secrets were derivable from known root credentials, making raw storage RPC signatures forgeable when explicit RPC secrets were unset. Lesson: RPC auth keys must be independent random secrets, never derived from S3 root credentials, and raw storage RPC should not share the public S3 listener without an internode-only boundary.
|
|
||||||
- `GHSA-m77q-r63m-pj89`: STS JWTs used the root secret key as the shared token signing key, allowing token forgery when the root secret was known. Lesson: STS signing keys need key separation, rotation, and key IDs; do not reuse root credentials for JWT/HMAC signing.
|
|
||||||
- `GHSA-923g-jp7v-f97f`: license verification embedded a production RSA private key and used private-key decryption as authenticity. Lesson: ship verifying/public keys only and use real signature verification.
|
|
||||||
|
|
||||||
### Sensitive logging and debug output
|
|
||||||
|
|
||||||
- `GHSA-r54g-49rx-98cr`: STS credentials were logged at info level. Lesson: generated credentials must never be logged in plaintext.
|
|
||||||
- `GHSA-8cm2-h255-v749`: debug logs leaked session tokens, secret keys, JWT claims, and raw STS response bodies. Lesson: redaction must cover custom `Debug` implementations and dependency response-body logging.
|
|
||||||
- `GHSA-333v-68xh-8mmq`: invalid RPC signature logging included the shared HMAC secret and expected signature. Lesson: error paths often leak secrets; never log raw secrets or derived authenticators.
|
|
||||||
|
|
||||||
### RPC input validation and panic safety
|
|
||||||
|
|
||||||
- `GHSA-gw2x-q739-qhcr`: malformed gRPC `GetMetrics` payloads reached `unwrap()` on deserialization and caused remote DoS. Lesson: every network/RPC deserialization failure returns an error, not a panic.
|
|
||||||
- `GHSA-h956-rh7x-ppgj` and `GHSA-r5qv-rc46-hv8q`: weak RPC auth increased reachability of otherwise internal handlers. Lesson: panic bugs become more severe when internode auth is weak or defaulted.
|
|
||||||
- `GHSA-c667-rgrv-99vj`: NodeService authentication signed the service prefix instead of the concrete generated method path, so valid metadata for one RPC could be replayed to another method during the timestamp window. Lesson: RPC HMAC payloads must bind exact gRPC method path, HTTP method surrogate, timestamp, and secret.
|
|
||||||
|
|
||||||
### Browser, CORS, and console isolation
|
|
||||||
|
|
||||||
- `GHSA-v9fg-3cr2-277j`: object preview rendered attacker-controlled HTML in a same-origin iframe, exposing console credentials stored in `localStorage`. Lesson: user content must be origin-isolated from the console and protected with `nosniff`, CSP, and strict content-type handling.
|
|
||||||
- `GHSA-7gcx-wg4x-q9x6`: an incomplete preview fix reintroduced extension-based PDF detection and bypassed the sandboxed fallback for attacker-controlled content. Lesson: browser-surface fixes need regression tests for alternate viewers and file-type branches, and preview trust must come from validated content type plus sandboxing rather than object names.
|
|
||||||
- `GHSA-x5xv-223c-8vm7`: default CORS reflected arbitrary origins with credentials. Lesson: never combine reflected origins with `Access-Control-Allow-Credentials: true`; default should be fail-closed.
|
|
||||||
|
|
||||||
### Trusted proxy and source IP conditions
|
|
||||||
|
|
||||||
- `GHSA-fc6g-2gcp-2qrq`: `aws:SourceIp` trusted client-supplied `X-Forwarded-For` or `X-Real-IP`. Lesson: forwarded IP headers are valid only behind configured trusted proxies; direct clients use socket peer IP.
|
|
||||||
|
|
||||||
### SSE and on-disk storage invariants
|
|
||||||
|
|
||||||
- `GHSA-xrrf-67jm-3c2r`: SSE metadata reported encryption while reader composition bypassed `EncryptReader` and stored plaintext. Lesson: test actual bytes on disk and wrapper order, not only API metadata.
|
|
||||||
- `GHSA-wqmc-vjgv-jrpw`: SSE-C persisted and listed the plaintext MD5 as the ETag, allowing list-only users to confirm or brute-force low-entropy content and correlate equal plaintext across keys. Lesson: encrypted-object ETags must not expose deterministic plaintext fingerprints; protect them with per-object key material and keep listing, conditional, copy, and multipart semantics consistent.
|
|
||||||
|
|
||||||
### Object Lock and retention invariants
|
|
||||||
|
|
||||||
- `GHSA-j548-9grx-fh4f`: Object Lock enforcement treated unreadable, fabricated, or unparsable bucket metadata as absent configuration and allowed retained objects to be deleted or expired. Lesson: retention must fail closed unless Object Lock absence is authoritative, and every delete, lifecycle, scanner, force-delete, and default-retention path needs the same state distinction.
|
|
||||||
- `GHSA-28w4-rp2p-636w` and `GHSA-c7hx-vr97-45rg`: an authorized `DeleteBucket` with `x-rustfs-force-delete: true` removed active COMPLIANCE-retained versions even though ordinary version deletion was denied. Lesson: bucket force-delete must enforce retention across every version, including against root/owner callers.
|
|
||||||
- `GHSA-q23w-fp7p-472v`: `PutObjectRetention` policy conditions read `x-amz-object-lock-*` headers while the handler applied mode and date from the XML body. Lesson: authorize the parsed retention that will be stored, or reject mismatched header/body values; compare retention dates as instants so offsets cannot change the applied bound.
|
|
||||||
|
|
||||||
### Serde deserialization and input validation
|
|
||||||
|
|
||||||
- Reject unknown fields in untrusted S3 API XML/JSON, lifecycle, policy, and replication input where compatibility permits. Check the current type and supported payload fixtures; do not infer the repository's current coverage from an older audit. Where extra fields are part of the compatibility contract, validate security-critical values explicitly.
|
|
||||||
- `#[serde(default)]` on security-critical fields silently accepts missing values as zero/empty. Lesson: when a field has security implications (retention days, permissions, limits), validate the deserialized value explicitly rather than relying on defaults.
|
|
||||||
- Integer fields deserialized from user input and cast with `as` (e.g., `i32 as u32`) can wrap negative values to large positives. Lesson: validate ranges before casting; use `try_into()` with a typed error, or clamp only when the domain explicitly requires saturation.
|
|
||||||
- XML config typos (e.g., `"NoncurentDays"` instead of `"NoncurrentDays"`) are silently accepted when `deny_unknown_fields` is absent. Lesson: strict deserialization prevents silent misconfiguration that could cause data loss or unexpected retention behavior.
|
|
||||||
|
|
||||||
## Useful Search Seeds
|
|
||||||
|
|
||||||
Use these targeted searches when a diff touches security-sensitive code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
|
|
||||||
rg -n "authorize_operation|FtpsDriver|SftpDriver|RETR|MKD|SIZE|MDTM|CreateBucket|GetObject|HeadObject" crates/protocols rustfs
|
|
||||||
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|presign|SignedHeaders|x-amz-copy-source|collect_signed_headers|content-length-range|starts-with" rustfs crates
|
|
||||||
rg -n "ListBucketVersions|GetObjectVersion|versionId|VersionId|ExistingObjectTag|ForAllValues|ForAnyValue|POLICY_PLUGIN|opa" rustfs crates
|
|
||||||
rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
|
|
||||||
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
|
|
||||||
rg -n "TONIC_RPC_PREFIX|verify_rpc_signature|check_auth|NodeServiceServer|x-rustfs-signature" rustfs crates
|
|
||||||
rg -n "debug!|trace!|info!|error!|\\?resp|\\?merged_config|session_token|secret_key" rustfs crates
|
|
||||||
rg -n "HashReader|EncryptReader|try_resolve_etag|ETag|SSE|server-side encryption|Access-Control-Allow-Credentials|Origin" rustfs crates
|
|
||||||
rg -n "ObjectLock|object_lock|retention|COMPLIANCE|GOVERNANCE|delete_prefix|lifecycle|scanner" rustfs crates
|
|
||||||
rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
|
|
||||||
```
|
|
||||||
|
|
||||||
## Minimum Regression Test Expectations
|
|
||||||
|
|
||||||
- Authz fixes: include unauthenticated, valid low-privilege, wrong-action, correct-action, owner, non-owner, and root/admin cases as applicable.
|
|
||||||
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases; test bucket-policy explicit denies and condition-key denies on each enabled listener against an S3 control, and assert denied paths do not reach storage.
|
|
||||||
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
|
||||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
|
||||||
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, storage-class, and cross-bucket copy-source headers; require rejection before source or destination storage access, and verify explicitly signed equivalents still work.
|
|
||||||
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, multipart-copy source, `HeadObject`, and tag read/write cases.
|
|
||||||
- Version-write fixes: a `PutObject`-only principal must not replace an existing version through `PutObject` or browser POST `?versionId=`; verify replication-authorized writes and versioned, suspended, and unversioned buckets separately.
|
|
||||||
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, cyclic variable substitutions, plugin mode, and built-in policy mode.
|
|
||||||
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks; test per-key `DeleteObjects` errors and prefix-scoped denials on versioned buckets.
|
|
||||||
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
|
||||||
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
|
|
||||||
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
|
|
||||||
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
|
||||||
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together; for ETags, upload equal plaintext under different keys and assert list-only callers cannot derive or correlate the plaintext digest, including multipart objects.
|
|
||||||
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions. For `PutObjectRetention`, test conflicting header/body values and offset dates against policy bounds and read-back retention.
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-linux=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
|
sha256-linux=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
|
||||||
sha256-darwin=74a1d6975e2d6dfe01363585cb9bfa3232d79556a154d4f599653fe2ed1ce696
|
sha256-darwin=eb33067d90ee66c0a97e8f3658e46c152e47f897d2fbfdc6cb5ad93cfa55414b
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=b53fe6d46cdffedfad6a83da0dfffa9aec10cd7c8ace53469f129587bc9f4d50
|
sha256-darwin=a67a347f26be186c598b5beaf68e6bcbe1855350a35e1423dd5b8a81bcba06a5
|
||||||
sha256-linux=6be90f5c3886e9852ac27d51f2d47e6d2fa8bb424e0d03ee931268dcd0020db8
|
sha256-linux=ad4fabe811c6cca1aba89fad34ccd404b5832a6a955875c2826f6e0fa1ea4f1f
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
|
sha256-darwin=3814cdde8cacf4187e18188d342ed70931cc32e268e42e5bb438d565bb4d0f81
|
||||||
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
|
sha256-linux=663025a80273579f95ae3f49af02c0afcbfce94df09701c360a13e607b7637db
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=002ec5d973f82288e50a096a2c8b3b38749f62a7a3e169775566874d8ee98b4a
|
sha256=f065e7071aac9d48a7c1b301f328fa7cc14f11c4e7dc46da3e55057c4ea29bd5
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
{
|
{
|
||||||
"invariant": "list-uncommitted-version",
|
"invariant": "list-uncommitted-version",
|
||||||
"suite": "rustfs-filemeta",
|
"suite": "rustfs-filemeta",
|
||||||
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
|
"name": "metacache::tests::resolve_merge_keeps_the_newest_version_at_listing_quorum"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"invariant": "minio-object-fixture",
|
"invariant": "minio-object-fixture",
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
{
|
{
|
||||||
"invariant": "list-objects-shared-corpus-orphan-cleanup-requires-authoritative-evidence",
|
"invariant": "list-objects-shared-corpus-orphan-cleanup-requires-authoritative-evidence",
|
||||||
"suite": "rustfs-ecstore",
|
"suite": "rustfs-ecstore",
|
||||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence"
|
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_listing_preserves_inputs"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"invariant": "list-objects-shared-corpus-consumer-drop-joins-real-disk-producer",
|
"invariant": "list-objects-shared-corpus-consumer-drop-joins-real-disk-producer",
|
||||||
@@ -241,11 +241,6 @@
|
|||||||
"suite": "rustfs-ecstore",
|
"suite": "rustfs-ecstore",
|
||||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_cancelled_quorum_walk_releases_senders_and_disk_lock"
|
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_cancelled_quorum_walk_releases_senders_and_disk_lock"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"invariant": "list-objects-shared-corpus-enabled-index-contracts",
|
|
||||||
"suite": "rustfs-ecstore",
|
|
||||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_enabled_index_contracts"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"invariant": "borrowed-context-boundary",
|
"invariant": "borrowed-context-boundary",
|
||||||
"suite": "rustfs-ecstore",
|
"suite": "rustfs-ecstore",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_glibc.py
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_upgrade_workflow.py
|
||||||
|
|||||||
+24
-2
@@ -68,6 +68,17 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
|||||||
# boundaries. Reserve the run's capacity so unrelated fixtures cannot starve
|
# boundaries. Reserve the run's capacity so unrelated fixtures cannot starve
|
||||||
# their existing deadlines and typed-failure assertions (rustfs/backlog#2706).
|
# their existing deadlines and typed-failure assertions (rustfs/backlog#2706).
|
||||||
# Keep each test's internal concurrency and zero-retry policy unchanged.
|
# Keep each test's internal concurrency and zero-retry policy unchanged.
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = """
|
||||||
|
package(rustfs-ecstore) & (
|
||||||
|
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
|
||||||
|
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
|
||||||
|
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
|
||||||
|
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
threads-required = "num-test-threads"
|
||||||
|
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
||||||
threads-required = "num-test-threads"
|
threads-required = "num-test-threads"
|
||||||
@@ -117,7 +128,7 @@ setup = 'ecstore-large-stack'
|
|||||||
# path on a real store; measured with RUST_MIN_STACK on 2026-10-15 they need
|
# path on a real store; measured with RUST_MIN_STACK on 2026-10-15 they need
|
||||||
# 2016-2040 KiB of the 2 MiB libtest default and overflowed on Linux CI.
|
# 2016-2040 KiB of the 2 MiB libtest default and overflowed on Linux CI.
|
||||||
[[profile.default.scripts]]
|
[[profile.default.scripts]]
|
||||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::plugin_attempt::v2_tests::delegation_v2_persists_and_vends_with_real_parent_intersection|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||||
setup = 'ecstore-base-stack'
|
setup = 'ecstore-base-stack'
|
||||||
|
|
||||||
[[profile.default.scripts]]
|
[[profile.default.scripts]]
|
||||||
@@ -351,6 +362,17 @@ path = "junit.xml"
|
|||||||
|
|
||||||
# Match the local durable-progress reservations. All members, deadlines,
|
# Match the local durable-progress reservations. All members, deadlines,
|
||||||
# assertions, and internal concurrency remain enabled without retries.
|
# assertions, and internal concurrency remain enabled without retries.
|
||||||
|
[[profile.ci.overrides]]
|
||||||
|
filter = """
|
||||||
|
package(rustfs-ecstore) & (
|
||||||
|
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
|
||||||
|
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
|
||||||
|
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
|
||||||
|
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
threads-required = "num-test-threads"
|
||||||
|
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
||||||
threads-required = "num-test-threads"
|
threads-required = "num-test-threads"
|
||||||
@@ -397,7 +419,7 @@ setup = 'ecstore-large-stack'
|
|||||||
|
|
||||||
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
|
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
|
||||||
[[profile.ci.scripts]]
|
[[profile.ci.scripts]]
|
||||||
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::plugin_attempt::v2_tests::delegation_v2_persists_and_vends_with_real_parent_intersection|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
|
||||||
setup = 'ecstore-base-stack'
|
setup = 'ecstore-base-stack'
|
||||||
|
|
||||||
[[profile.ci.scripts]]
|
[[profile.ci.scripts]]
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ Notes:
|
|||||||
- `.github/workflows/e2e-s3tests.yml` (weekly full s3-tests sweep)
|
- `.github/workflows/e2e-s3tests.yml` (weekly full s3-tests sweep)
|
||||||
- `.github/workflows/mint.yml` (weekly multi-SDK mint run)
|
- `.github/workflows/mint.yml` (weekly multi-SDK mint run)
|
||||||
- `.github/workflows/fuzz.yml` (nightly fuzz corpus jobs)
|
- `.github/workflows/fuzz.yml` (nightly fuzz corpus jobs)
|
||||||
- `.github/workflows/performance-ab.yml` (nightly warp A/B gate)
|
|
||||||
- **Pending:** the ci-7 e2e nightly-full workflow does not exist yet
|
- **Pending:** the ci-7 e2e nightly-full workflow does not exist yet
|
||||||
(backlog#1149 ci-7). When it lands, wire it up with the snippet above.
|
(backlog#1149 ci-7). When it lands, wire it up with the snippet above.
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"never_ran_grace_until": "2026-09-08T00:00:00Z"
|
"never_ran_grace_until": "2026-09-08T00:00:00Z"
|
||||||
},
|
},
|
||||||
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
||||||
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
|
|
||||||
{
|
{
|
||||||
"workflow": ".github/workflows/runner-hygiene.yml",
|
"workflow": ".github/workflows/runner-hygiene.yml",
|
||||||
"max_age_hours": 792,
|
"max_age_hours": 792,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
|
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
|
||||||
|
|
||||||
Every other label in this repository is capacity RustFS pays for by the hour whether or not a job is running well:
|
Every other label in this repository is capacity RustFS pays for. ARC pods and lab machines accrue cost while the machine exists. A Ubicloud label is metered for the minutes a job holds its VM:
|
||||||
|
|
||||||
| Label | Paid by | What it is | Use it for |
|
| Label | Paid by | What it is | Use it for |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -15,8 +15,8 @@ Every other label in this repository is capacity RustFS pays for by the hour whe
|
|||||||
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
|
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
|
||||||
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
|
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
|
||||||
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
|
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
|
||||||
| `sm-standard-4-arm` | RustFS | ARC pod, aarch64; reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds |
|
| `ubicloud-standard-16-arm` | RustFS | Ubicloud VM, alias of `ubicloud-standard-16-arm-ubuntu-2404`: Ubuntu 24.04 arm64, 16 vCPU, 48 GB, 300 GB disk. Reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds (`linux-aarch64-musl`, `linux-aarch64-gnu`) |
|
||||||
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
|
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | Docker workloads with a measured resource or environment requirement beyond the hosted runner |
|
||||||
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
|
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
|
||||||
|
|
||||||
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
|
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
|
||||||
@@ -27,11 +27,15 @@ A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason
|
|||||||
|
|
||||||
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
|
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
|
||||||
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
|
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
|
||||||
3. **Hardware.** Native aarch64, Docker-in-Docker, or the lab machines.
|
3. **Hardware or environment.** Native aarch64 or access to the lab machines. Docker alone is not a reason: hosted Ubuntu ships Docker and supports the QEMU and Buildx setup actions.
|
||||||
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
|
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
|
||||||
|
|
||||||
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
|
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
|
||||||
|
|
||||||
|
The functional chain's `prepare` and `complete-chain` jobs, the performance runner availability check, and runner hygiene use `ubuntu-latest`: they inspect APIs or artifacts, not the lab hosts. The actual functional and performance suites remain on their lab runners. Functional-chain health collection stays on the operator-managed `smoke-testing` runner as part of the automation environment. Docker image packaging also uses `ubuntu-latest` for both musl and glibc variants; it downloads release binaries rather than compiling the workspace, and still sets up QEMU and Buildx explicitly.
|
||||||
|
|
||||||
|
Moving these jobs off AKS reduces capacity demand, not necessarily the Azure bill immediately. VM costs fall only when nodes scale down or smaller capacity replaces them.
|
||||||
|
|
||||||
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
|
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
|
||||||
|
|
||||||
## Cache rule
|
## Cache rule
|
||||||
@@ -40,7 +44,7 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
|
|||||||
|
|
||||||
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
|
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
|
||||||
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
|
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
|
||||||
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build.
|
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build. The `build.yml` aarch64 release legs append `-ubicloud-arm` for this reason. `cache-warm.yml` does not write those keys.
|
||||||
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
|
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
|
||||||
|
|
||||||
## Where self-hosted minutes leak
|
## Where self-hosted minutes leak
|
||||||
@@ -62,7 +66,7 @@ gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
|
|||||||
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
|
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
|
||||||
```
|
```
|
||||||
|
|
||||||
Only rows whose label starts with `sm-`, `dind-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
|
Only rows whose label starts with `sm-`, `dind-`, `ubicloud-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
|
||||||
|
|
||||||
## Checklist for a new or moved job
|
## Checklist for a new or moved job
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ on:
|
|||||||
- 'scripts/test_package_service_scripts.sh'
|
- 'scripts/test_package_service_scripts.sh'
|
||||||
- 'scripts/test_package_versions.sh'
|
- 'scripts/test_package_versions.sh'
|
||||||
- 'scripts/test_docker_workflow.py'
|
- 'scripts/test_docker_workflow.py'
|
||||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
|
||||||
- 'scripts/security/check_preview_release_workflow.sh'
|
- 'scripts/security/check_preview_release_workflow.sh'
|
||||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||||
- 'scripts/security/check_workflow_pins.sh'
|
- 'scripts/security/check_workflow_pins.sh'
|
||||||
@@ -48,7 +47,6 @@ on:
|
|||||||
- 'scripts/test_package_service_scripts.sh'
|
- 'scripts/test_package_service_scripts.sh'
|
||||||
- 'scripts/test_package_versions.sh'
|
- 'scripts/test_package_versions.sh'
|
||||||
- 'scripts/test_docker_workflow.py'
|
- 'scripts/test_docker_workflow.py'
|
||||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
|
||||||
- 'scripts/security/check_preview_release_workflow.sh'
|
- 'scripts/security/check_preview_release_workflow.sh'
|
||||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||||
- 'scripts/security/check_workflow_pins.sh'
|
- 'scripts/security/check_workflow_pins.sh'
|
||||||
@@ -155,9 +153,6 @@ jobs:
|
|||||||
- name: Check preview release workflow policy
|
- name: Check preview release workflow policy
|
||||||
run: ./scripts/security/check_preview_release_workflow.sh
|
run: ./scripts/security/check_preview_release_workflow.sh
|
||||||
|
|
||||||
- name: Check performance A/B workflow trust boundary
|
|
||||||
run: ./scripts/security/check_performance_ab_workflow.sh
|
|
||||||
|
|
||||||
- name: Check tier evidence workflow isolation
|
- name: Check tier evidence workflow isolation
|
||||||
run: ./scripts/security/check_tier_artifact_workflow.sh
|
run: ./scripts/security/check_tier_artifact_workflow.sh
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
name: BTF prototype reference
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "scripts/inspect_btf_prototypes.py"
|
||||||
|
- "scripts/test_inspect_btf_prototypes.py"
|
||||||
|
- "scripts/fixtures/btf_prototypes.c"
|
||||||
|
- ".github/workflows/btf-prototype-reference.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: btf-prototype-reference-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
reference:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Check prototype and CLI contracts
|
||||||
|
run: python3 -B scripts/test_inspect_btf_prototypes.py
|
||||||
|
- name: Install only missing native reference tools
|
||||||
|
run: |
|
||||||
|
select_bpftool() {
|
||||||
|
local candidate
|
||||||
|
local candidates=()
|
||||||
|
if command -v bpftool >/dev/null; then
|
||||||
|
candidates+=("$(command -v bpftool)")
|
||||||
|
fi
|
||||||
|
for candidate in /usr/lib/linux-tools-*/bpftool /usr/lib/linux-tools/*/bpftool; do
|
||||||
|
if [[ -x "$candidate" ]]; then
|
||||||
|
candidates+=("$candidate")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
while IFS= read -r candidate; do
|
||||||
|
if [[ -x "$candidate" ]] && file -Lb "$candidate" | grep -q 'ELF' \
|
||||||
|
&& "$candidate" version >/dev/null 2>&1; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < <(printf '%s\n' "${candidates[@]}" | sort -Vr)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
packages=()
|
||||||
|
command -v clang >/dev/null || packages+=(clang)
|
||||||
|
command -v llvm-objcopy >/dev/null || packages+=(llvm)
|
||||||
|
command -v gcc >/dev/null || packages+=(gcc)
|
||||||
|
select_bpftool >/dev/null || packages+=(linux-tools-generic)
|
||||||
|
if (( ${#packages[@]} )); then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --no-install-recommends -y "${packages[@]}"
|
||||||
|
fi
|
||||||
|
# File parsing needs a working binary, not a host-kernel wrapper.
|
||||||
|
selected_bpftool="$(select_bpftool)"
|
||||||
|
"$selected_bpftool" version
|
||||||
|
printf 'BPFTOOL=%s\n' "$selected_bpftool" >> "$GITHUB_ENV"
|
||||||
|
- name: Compare declarations and layouts with independent references
|
||||||
|
run: |
|
||||||
|
clang -target bpf -O2 -g -c scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/prototypes.o"
|
||||||
|
llvm-objcopy --dump-section ".BTF=$RUNNER_TEMP/prototypes.btf" "$RUNNER_TEMP/prototypes.o"
|
||||||
|
gcc -std=c11 -Wall -Wextra -Werror -DRUSTFS_BTF_LAYOUT_REFERENCE \
|
||||||
|
scripts/fixtures/btf_prototypes.c -o "$RUNNER_TEMP/layout-reference"
|
||||||
|
"$RUNNER_TEMP/layout-reference" > "$RUNNER_TEMP/layout-reference.json"
|
||||||
|
python3 -B scripts/test_inspect_btf_prototypes.py --native-reference "$RUNNER_TEMP/prototypes.btf" \
|
||||||
|
--layout-reference "$RUNNER_TEMP/layout-reference.json"
|
||||||
@@ -181,12 +181,18 @@ jobs:
|
|||||||
selected="all"
|
selected="all"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# linux-aarch64-* need a real aarch64 host (placement rule: hardware)
|
||||||
|
# so the packaged binary and console smoke execute the artifact.
|
||||||
|
# ubicloud-standard-16-arm is that host: Ubuntu 24.04 arm64, alias of
|
||||||
|
# ubicloud-standard-16-arm-ubuntu-2404 (16 vCPU, 48 GB). linux-x86_64-* stay on the
|
||||||
|
# ARC sm-standard-4 pool. GNU rows keep cross=true so cargo-zigbuild
|
||||||
|
# pins the glibc 2.31 floor on the same architecture; musl stays a
|
||||||
|
# native cargo build.
|
||||||
all='{"include":[
|
all='{"include":[
|
||||||
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||||
{"target_id":"linux-aarch64-musl","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
{"target_id":"linux-aarch64-musl","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
|
||||||
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
{"target_id":"linux-x86_64-gnu","os":"sm-standard-4","target":"x86_64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||||
{"target_id":"linux-aarch64-gnu","os":"sm-standard-4-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
{"target_id":"linux-aarch64-gnu","os":"ubicloud-standard-16-arm","target":"aarch64-unknown-linux-gnu","cross":true,"platform":"linux","rustflags":"","features":"pyroscope","glibc":"2.31"},
|
||||||
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
|
{"target_id":"macos-aarch64","os":"macos-latest","target":"aarch64-apple-darwin","cross":false,"platform":"macos","rustflags":"","features":"pyroscope","glibc":""},
|
||||||
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
|
{"target_id":"windows-x86_64","os":"windows-latest","target":"x86_64-pc-windows-msvc","cross":false,"platform":"windows","rustflags":"","features":"","glibc":""}
|
||||||
]}'
|
]}'
|
||||||
@@ -244,7 +250,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
target: ${{ matrix.target }}
|
target: ${{ matrix.target }}
|
||||||
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}
|
# rust-cache keys include OS and architecture but not the runner image.
|
||||||
|
# The Ubicloud aarch64 legs therefore use their own suffix so they
|
||||||
|
# cannot restore a cache written on a different arm64 image.
|
||||||
|
# x86_64, macOS, and Windows keys stay unsuffixed.
|
||||||
|
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}${{ matrix.os == 'ubicloud-standard-16-arm' && '-ubicloud-arm' || '' }}
|
||||||
# main only. A cache saved on refs/tags/X is scoped to that tag: no
|
# main only. A cache saved on refs/tags/X is scoped to that tag: no
|
||||||
# other tag, no main run and no PR can restore it, so every release
|
# other tag, no main run and no PR can restore it, so every release
|
||||||
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
|
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
|
||||||
@@ -764,9 +774,9 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The self-hosted Linux runners do not ship the aws CLI (GitHub-hosted
|
# ARC pods (the x86_64 legs) do not ship the aws CLI. Ubicloud arm64
|
||||||
# images did). Install the official CLI bundle on demand so R2 uploads
|
# images follow the GitHub runner image and often do. Install the
|
||||||
# survive a fresh runner without relying on a distro package.
|
# official CLI bundle when it is missing so R2 uploads work on either.
|
||||||
if ! command -v aws >/dev/null 2>&1; then
|
if ! command -v aws >/dev/null 2>&1; then
|
||||||
echo "aws CLI not found on runner; installing..."
|
echo "aws CLI not found on runner; installing..."
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ on:
|
|||||||
# 07:00 UTC Sunday — staggered clear of the other Sunday crons: ci (00:00),
|
# 07:00 UTC Sunday — staggered clear of the other Sunday crons: ci (00:00),
|
||||||
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
|
# build (01:00), e2e-s3tests (02:00), audit (03:00), nix-flake-update
|
||||||
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
|
# (05:00), mint (06:00), and the daily fuzz (02:00), minio-interop (03:17),
|
||||||
# e2e-replication-nightly (04:00) and performance-ab (06:00) lanes.
|
# and e2e-replication-nightly (04:00) lanes.
|
||||||
- cron: "43 7 * * 0"
|
- cron: "43 7 * * 0"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ jobs:
|
|||||||
name: Build Docker Images
|
name: Build Docker Images
|
||||||
needs: build-check
|
needs: build-check
|
||||||
if: needs.build-check.outputs.should_build == 'true'
|
if: needs.build-check.outputs.should_build == 'true'
|
||||||
runs-on: dind-sm-standard-2
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ name: e2e-nightly
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00),
|
# 04:00 UTC nightly — staggered clear of fuzz/e2e-s3tests (02:00)
|
||||||
# stale (01:30) and performance-ab (06:00).
|
# and stale (01:30).
|
||||||
- cron: "29 4 * * *"
|
- cron: "29 4 * * *"
|
||||||
|
|
||||||
# Only alert-on-failure needs more than read access; it declares its own
|
# Only alert-on-failure needs more than read access; it declares its own
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- ".github/workflows/e2e-upgrade.yml"
|
- ".github/workflows/e2e-upgrade.yml"
|
||||||
- "crates/e2e_test/src/common.rs"
|
- "crates/e2e_test/src/common.rs"
|
||||||
|
- "crates/e2e_test/src/distributed/**"
|
||||||
- "crates/e2e_test/src/fake_s3_target/**"
|
- "crates/e2e_test/src/fake_s3_target/**"
|
||||||
- "crates/e2e_test/src/lib.rs"
|
- "crates/e2e_test/src/lib.rs"
|
||||||
- "crates/e2e_test/src/replication_extension_test.rs"
|
- "crates/e2e_test/src/replication_extension_test.rs"
|
||||||
|
- "crates/e2e_test/src/rpc_fault_proxy.rs"
|
||||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||||
- "crates/ecstore/**"
|
- "crates/ecstore/**"
|
||||||
- "crates/filemeta/**"
|
- "crates/filemeta/**"
|
||||||
@@ -128,6 +130,7 @@ jobs:
|
|||||||
run: chmod +x target/debug/rustfs
|
run: chmod +x target/debug/rustfs
|
||||||
|
|
||||||
- name: Verify and unpack pinned previous release
|
- name: Verify and unpack pinned previous release
|
||||||
|
id: verify_source
|
||||||
env:
|
env:
|
||||||
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||||
run: |
|
run: |
|
||||||
@@ -185,6 +188,28 @@ jobs:
|
|||||||
done
|
done
|
||||||
exit "$status"
|
exit "$status"
|
||||||
|
|
||||||
|
- name: Run mixed-version MPU recovery
|
||||||
|
if: ${{ !cancelled() && steps.verify_source.outcome == 'success' }}
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
NO_PROXY: 127.0.0.1,localhost,::1
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
ALL_PROXY: ""
|
||||||
|
RUSTFS_E2E_LOG_FILTER: "warn,rustfs_ecstore::cluster::rpc::client=debug"
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export no_proxy="$NO_PROXY"
|
||||||
|
export RUSTFS_E2E_LOG_DIR="$RUNNER_TEMP/rustfs-upgrade-logs/mpu-mixed-recovery"
|
||||||
|
mkdir -p "$RUSTFS_E2E_LOG_DIR"
|
||||||
|
mpu_test='distributed::multipart_fence_acceptance_test::mixed_version_failed_complete_requires_current_coordinator_for_abort'
|
||||||
|
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
|
||||||
|
"$mpu_test" -- --list --exact | tee "$RUSTFS_E2E_LOG_DIR/list.log"
|
||||||
|
test "$(grep -Fxc "${mpu_test}: test" "$RUSTFS_E2E_LOG_DIR/list.log")" -eq 1
|
||||||
|
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
|
||||||
|
"$mpu_test" -- --exact --nocapture --test-threads=1 \
|
||||||
|
2>&1 | tee "$RUSTFS_E2E_LOG_DIR/test.log"
|
||||||
|
|
||||||
- name: Upload scanner/heal G09 evidence
|
- name: Upload scanner/heal G09 evidence
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
|||||||
@@ -1,384 +0,0 @@
|
|||||||
# Copyright 2024 RustFS Team
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
# Warp A/B budget gate for the hotpath series (rustfs/backlog#935 HP-14, item 4).
|
|
||||||
#
|
|
||||||
# Two entry points, honestly scoped:
|
|
||||||
# * schedule (nightly, on main): post-merge detection — catches a regression
|
|
||||||
# within 24h of landing, not before merge.
|
|
||||||
# * workflow_dispatch: an explicitly selected trusted ref.
|
|
||||||
# The dispatch input can run the gate with --allow-regression so a deliberate
|
|
||||||
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
|
|
||||||
# blocked (rustfs/backlog#935 correction 1).
|
|
||||||
|
|
||||||
name: Performance A/B
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "31 6 * * *" # 06:31 UTC nightly, against main
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
duration:
|
|
||||||
description: "warp duration per round"
|
|
||||||
required: false
|
|
||||||
default: "12s"
|
|
||||||
type: string
|
|
||||||
allow_regression:
|
|
||||||
description: "Pass the gate despite a FAIL (deliberate tradeoff)"
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUST_BACKTRACE: 1
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
warp-ab:
|
|
||||||
if: github.repository == 'rustfs/rustfs'
|
|
||||||
name: Warp A/B budget gate
|
|
||||||
runs-on: sm-standard-2
|
|
||||||
# A normal nightly restores the last successful binary and builds only the
|
|
||||||
# candidate; daily access keeps that cache warm. A cache miss may build both
|
|
||||||
# and needs room for the A/B run plus artifact and cache publication.
|
|
||||||
timeout-minutes: 180
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
fetch-depth: 0 # baseline may be an earlier successful scheduled head
|
|
||||||
|
|
||||||
- name: Setup Rust environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
rust-version: stable
|
|
||||||
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
- name: Install warp
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
WARP_VERSION="1.0.0"
|
|
||||||
curl -fsSL "https://github.com/minio/warp/releases/download/v${WARP_VERSION}/warp_Linux_x86_64.tar.gz" \
|
|
||||||
| sudo tar -xz -C /usr/local/bin warp
|
|
||||||
warp --version
|
|
||||||
|
|
||||||
- name: Decide exemption
|
|
||||||
id: exempt
|
|
||||||
env:
|
|
||||||
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
|
|
||||||
run: |
|
|
||||||
allow="false"
|
|
||||||
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
|
|
||||||
allow="true"
|
|
||||||
fi
|
|
||||||
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# A failed regression run must keep comparing against the last known-good
|
|
||||||
# scheduled head. Otherwise the next nightly would absorb the regression
|
|
||||||
# into its baseline and turn green without a fix.
|
|
||||||
- name: Find last successful scheduled baseline
|
|
||||||
id: scheduled_baseline
|
|
||||||
if: github.event_name == 'schedule'
|
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
||||||
with:
|
|
||||||
result-encoding: string
|
|
||||||
script: |
|
|
||||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
workflow_id: "performance-ab.yml",
|
|
||||||
event: "schedule",
|
|
||||||
status: "success",
|
|
||||||
per_page: 1,
|
|
||||||
});
|
|
||||||
return data.workflow_runs[0]?.head_sha ?? "";
|
|
||||||
|
|
||||||
# Manual runs compare a selected ref with current main. Scheduled runs
|
|
||||||
# compare current main with the last successful scheduled head. With no
|
|
||||||
# history, the first run measures the candidate against itself and seeds
|
|
||||||
# that head only if the complete rig succeeds.
|
|
||||||
- name: Resolve baseline / candidate commits
|
|
||||||
id: commits
|
|
||||||
env:
|
|
||||||
SCHEDULED_BASELINE_SHA: ${{ steps.scheduled_baseline.outputs.result }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
candidate_sha="$(git rev-parse HEAD)"
|
|
||||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
|
||||||
baseline_sha="${SCHEDULED_BASELINE_SHA:-$candidate_sha}"
|
|
||||||
else
|
|
||||||
baseline_sha="$(git rev-parse origin/main)"
|
|
||||||
fi
|
|
||||||
git cat-file -e "${baseline_sha}^{commit}"
|
|
||||||
if ! git merge-base --is-ancestor "$baseline_sha" "$candidate_sha"; then
|
|
||||||
echo "::error::baseline $baseline_sha is not an ancestor of candidate $candidate_sha; update the selected ref before comparing" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "baseline_sha=$baseline_sha" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "baseline commit: $baseline_sha"
|
|
||||||
echo "candidate commit: $candidate_sha"
|
|
||||||
|
|
||||||
# Exact-key restore of the candidate binary saved by its successful
|
|
||||||
# scheduled run. A miss leaves cache-hit unset and falls back to a source
|
|
||||||
# build of that known-good head.
|
|
||||||
- name: Restore cached baseline binary
|
|
||||||
id: baseline_cache
|
|
||||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
|
||||||
with:
|
|
||||||
path: baseline-bin/rustfs
|
|
||||||
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
|
|
||||||
|
|
||||||
# Self-heal: on a nightly/dispatch run where the candidate commit IS the
|
|
||||||
# baseline commit, a cache miss would make the rig build the same commit
|
|
||||||
# twice (~65min per side with the post-#4806 LTO profile — no job budget
|
|
||||||
# fits that). Build it once here, reuse it for both phases, and save it
|
|
||||||
# back to the cache so the next run hits.
|
|
||||||
- name: Build baseline on cache miss (same-commit self-heal)
|
|
||||||
id: selfheal
|
|
||||||
if: >-
|
|
||||||
steps.baseline_cache.outputs.cache-hit != 'true' &&
|
|
||||||
steps.commits.outputs.baseline_sha == steps.commits.outputs.candidate_sha
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cargo build --release --bin rustfs
|
|
||||||
mkdir -p baseline-bin
|
|
||||||
cp target/release/rustfs baseline-bin/rustfs
|
|
||||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Build baseline on cache miss (different candidate)
|
|
||||||
id: baseline_build
|
|
||||||
if: >-
|
|
||||||
steps.baseline_cache.outputs.cache-hit != 'true' &&
|
|
||||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
baseline_root="$RUNNER_TEMP/rustfs-baseline-${{ github.run_id }}"
|
|
||||||
baseline_target="$RUNNER_TEMP/rustfs-baseline-target-${{ github.run_id }}"
|
|
||||||
git worktree add --detach "$baseline_root" "${{ steps.commits.outputs.baseline_sha }}"
|
|
||||||
cargo build --release --manifest-path "$baseline_root/Cargo.toml" --bin rustfs --target-dir "$baseline_target"
|
|
||||||
mkdir -p baseline-bin
|
|
||||||
cp "$baseline_target/release/rustfs" baseline-bin/rustfs
|
|
||||||
git worktree remove --force "$baseline_root"
|
|
||||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Build candidate binary
|
|
||||||
id: candidate_build
|
|
||||||
if: steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cargo build --release --bin rustfs
|
|
||||||
mkdir -p candidate-bin
|
|
||||||
cp target/release/rustfs candidate-bin/rustfs
|
|
||||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Save self-healed baseline to cache
|
|
||||||
if: steps.selfheal.outputs.built == 'true' || steps.baseline_build.outputs.built == 'true'
|
|
||||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
|
||||||
with:
|
|
||||||
path: baseline-bin/rustfs
|
|
||||||
key: rustfs-baseline-${{ steps.commits.outputs.baseline_sha }}
|
|
||||||
|
|
||||||
- name: Run warp A/B and gate
|
|
||||||
id: ab
|
|
||||||
env:
|
|
||||||
INPUT_DURATION: ${{ github.event.inputs.duration }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# The formal runner executes A1 baseline -> B1 candidate -> B2 candidate
|
|
||||||
# -> A2 baseline for each workload and drive-sync cell. It requires three
|
|
||||||
# rounds per leg to emit tail latency and error-rate evidence.
|
|
||||||
# --health-timeout 180 outlasts the server's own 120s startup-readiness
|
|
||||||
# budget, which the rig's previous 60s health poll undershot (the first
|
|
||||||
# two nightly failures). perf-6 recalibrates these once the noise study
|
|
||||||
# lands.
|
|
||||||
duration="${INPUT_DURATION:-12s}"
|
|
||||||
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
|
|
||||||
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
|
|
||||||
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
|
|
||||||
selfheal_built="${{ steps.selfheal.outputs.built }}"
|
|
||||||
baseline_built="${{ steps.baseline_build.outputs.built }}"
|
|
||||||
candidate_built="${{ steps.candidate_build.outputs.built }}"
|
|
||||||
|
|
||||||
args=(--duration "$duration" --rounds 3 --cooldown 5 --health-timeout 180 --baseline-revision "$baseline_sha" --candidate-revision "$candidate_sha")
|
|
||||||
|
|
||||||
if [[ "$baseline_hit" == "true" || "$selfheal_built" == "true" || "$baseline_built" == "true" ]]; then
|
|
||||||
chmod +x baseline-bin/rustfs
|
|
||||||
base_bin="$PWD/baseline-bin/rustfs"
|
|
||||||
args+=(--baseline-bin "$base_bin")
|
|
||||||
if [[ "$baseline_hit" == "true" ]]; then
|
|
||||||
base_src="actions-cache (rustfs-baseline-$baseline_sha)"
|
|
||||||
elif [[ "$selfheal_built" == "true" ]]; then
|
|
||||||
base_src="source build (cache self-heal, saved as rustfs-baseline-$baseline_sha)"
|
|
||||||
else
|
|
||||||
base_src="isolated baseline source build (saved as rustfs-baseline-$baseline_sha)"
|
|
||||||
fi
|
|
||||||
if [[ "$candidate_sha" == "$baseline_sha" ]]; then
|
|
||||||
# No commits landed since the last successful baseline, so reuse
|
|
||||||
# the one binary for both phases and measure only rig drift.
|
|
||||||
args+=(--candidate-bin "$base_bin")
|
|
||||||
cand_src="same binary as baseline (same commit)"
|
|
||||||
elif [[ "$candidate_built" == "true" ]]; then
|
|
||||||
chmod +x candidate-bin/rustfs
|
|
||||||
args+=(--candidate-bin "$PWD/candidate-bin/rustfs")
|
|
||||||
cand_src="source build of the checked-out ref"
|
|
||||||
else
|
|
||||||
echo "::error::candidate binary was not built" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "::error::baseline binary was not restored or built" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "baseline binary: $base_src"
|
|
||||||
echo "candidate binary: $cand_src"
|
|
||||||
|
|
||||||
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
|
|
||||||
args+=(--allow-regression --exemption-reason "workflow dispatch override")
|
|
||||||
fi
|
|
||||||
# Do not let a gate FAIL abort the job here; capture status and surface
|
|
||||||
# it after the step summary is written.
|
|
||||||
set +e
|
|
||||||
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
|
|
||||||
echo "status=$?" >> "$GITHUB_OUTPUT"
|
|
||||||
set -e
|
|
||||||
# Locate the newest run dir + gate.md for the summary/comment/artifact
|
|
||||||
# steps. On a startup failure there is no gate.md, but the run dir still
|
|
||||||
# holds server-logs/ for diagnosis.
|
|
||||||
# Run dirs are UTC-timestamp names (no special chars); ls is safe here.
|
|
||||||
# shellcheck disable=SC2012
|
|
||||||
run_dir="$(ls -td target/hotpath-abba/*/ 2>/dev/null | head -n1 || true)"
|
|
||||||
echo "run_dir=${run_dir%/}" >> "$GITHUB_OUTPUT"
|
|
||||||
# shellcheck disable=SC2012
|
|
||||||
gate_md="$(ls -t target/hotpath-abba/*/candidate_gate.md 2>/dev/null | head -n1 || true)"
|
|
||||||
echo "gate_md=$gate_md" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Upload A/B results
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: hotpath-warp-ab-${{ github.run_number }}
|
|
||||||
# Includes per-cell median_summary.csv / baseline_compare.csv, both gates,
|
|
||||||
# and server-logs/ (rustfs.log + startup env per phase) so a failed run
|
|
||||||
# is diagnosable. Short retention: this is churny nightly debug data.
|
|
||||||
path: target/hotpath-abba/
|
|
||||||
if-no-files-found: warn
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
- name: Write gate summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
status="${{ steps.ab.outputs.status }}"
|
|
||||||
gate_md="${{ steps.ab.outputs.gate_md }}"
|
|
||||||
run_dir="${{ steps.ab.outputs.run_dir }}"
|
|
||||||
{
|
|
||||||
echo "## Hotpath warp A/B — run ${{ github.run_number }}"
|
|
||||||
echo
|
|
||||||
if [[ "$status" == "0" ]]; then
|
|
||||||
echo "Rig/gate exit: \`0\` (pass or warn)."
|
|
||||||
else
|
|
||||||
echo "Rig/gate exit: \`${status:-unknown}\` — **FAILED**."
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
if [[ -n "$gate_md" && -f "$gate_md" ]]; then
|
|
||||||
cat "$gate_md"
|
|
||||||
else
|
|
||||||
echo "No \`gate.md\` produced — the rig failed **before** the gate"
|
|
||||||
echo "(most likely server startup / health). Failing phase(s) below;"
|
|
||||||
echo "full logs in the \`hotpath-warp-ab-${{ github.run_number }}\` artifact."
|
|
||||||
if [[ -n "$run_dir" && -d "$run_dir/server-logs" ]]; then
|
|
||||||
for f in "$run_dir"/server-logs/*.log; do
|
|
||||||
[[ -f "$f" ]] || continue
|
|
||||||
echo
|
|
||||||
echo "<details><summary>$(basename "$f")</summary>"
|
|
||||||
echo
|
|
||||||
echo '```'
|
|
||||||
tail -n 30 "$f"
|
|
||||||
echo '```'
|
|
||||||
echo "</details>"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
- name: Stage successful candidate baseline
|
|
||||||
if: >-
|
|
||||||
steps.ab.outputs.status == '0' &&
|
|
||||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cp candidate-bin/rustfs baseline-bin/rustfs
|
|
||||||
|
|
||||||
- name: Cache successful candidate baseline
|
|
||||||
if: >-
|
|
||||||
steps.ab.outputs.status == '0' &&
|
|
||||||
steps.commits.outputs.baseline_sha != steps.commits.outputs.candidate_sha
|
|
||||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
|
||||||
with:
|
|
||||||
path: baseline-bin/rustfs
|
|
||||||
key: rustfs-baseline-${{ steps.commits.outputs.candidate_sha }}
|
|
||||||
|
|
||||||
# Scheduled failure alerting is handled by the alert-on-failure job below
|
|
||||||
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
|
|
||||||
|
|
||||||
- name: Enforce gate
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
status="${{ steps.ab.outputs.status }}"
|
|
||||||
if [[ -z "$status" ]]; then
|
|
||||||
echo "::error::warp A/B setup failed before the rig ran. Check the first failed workflow step." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "$status" != "0" ]]; then
|
|
||||||
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / gate.md artifact." >&2
|
|
||||||
exit "$status"
|
|
||||||
fi
|
|
||||||
echo "warp A/B budget gate passed."
|
|
||||||
|
|
||||||
alert-on-failure:
|
|
||||||
name: Alert on scheduled failure
|
|
||||||
needs: [warp-ab]
|
|
||||||
# `always()` is required: without it this job is skipped when a needed
|
|
||||||
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
|
|
||||||
# ci-8); manual dispatch failures are already watched by a human.
|
|
||||||
# `cancelled` is included alongside `failure` on purpose: a job that hits
|
|
||||||
# timeout-minutes ends as `cancelled`, and the 2026-07-11..07-14 nightly
|
|
||||||
# timeouts went silent precisely because the guard was failure-only. The
|
|
||||||
# composite action already reports cancelled/timed-out jobs in the issue
|
|
||||||
# body.
|
|
||||||
if: >-
|
|
||||||
github.repository == 'rustfs/rustfs' && (
|
|
||||||
always() && github.event_name == 'schedule' &&
|
|
||||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
|
||||||
)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Open or update failure-tracking issue
|
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}
|
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
check-ephemerality:
|
check-ephemerality:
|
||||||
if: github.repository == 'rustfs/rustfs'
|
if: github.repository == 'rustfs/rustfs'
|
||||||
name: Check runner ephemerality
|
name: Check runner ephemerality
|
||||||
runs-on: sm-standard-2
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
|
||||||
runs-on: smoke-testing
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
outputs:
|
outputs:
|
||||||
manifest: ${{ steps.candidate.outputs.manifest }}
|
manifest: ${{ steps.candidate.outputs.manifest }}
|
||||||
@@ -188,7 +188,7 @@ jobs:
|
|||||||
complete-chain:
|
complete-chain:
|
||||||
needs: [prepare, upgrade, s3, kms, tier, storage, heal, pool, security, replication, fault-tolerance, table, performance]
|
needs: [prepare, upgrade, s3, kms, tier, storage, heal, pool, security, replication, fault-tolerance, table, performance]
|
||||||
if: ${{ always() && needs.prepare.result != 'skipped' }}
|
if: ${{ always() && needs.prepare.result != 'skipped' }}
|
||||||
runs-on: smoke-testing
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
runner-check:
|
runner-check:
|
||||||
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ inputs.chain_manifest != '' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
runs-on: sm-standard-2
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
#
|
#
|
||||||
# Forces a job failure and then runs .github/actions/schedule-failure-issue
|
# Forces a job failure and then runs .github/actions/schedule-failure-issue
|
||||||
# through the exact `needs` + `always() && contains(needs.*.result,
|
# through the exact `needs` + `always() && contains(needs.*.result,
|
||||||
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz,
|
# 'failure')` wiring used by the real consumers (e2e-s3tests, mint, fuzz).
|
||||||
# performance-ab). Dispatch it twice to verify both the issue-creation and
|
# Dispatch it twice to verify both the issue-creation and
|
||||||
# the dedupe-comment paths, then close the resulting
|
# the dedupe-comment paths, then close the resulting
|
||||||
# "[scheduled-failure] Schedule Failure Alert Drill" issue in rustfs/backlog
|
# "[scheduled-failure] Schedule Failure Alert Drill" issue in rustfs/backlog
|
||||||
# (the default target repository). Requires the BACKLOG_ISSUE_TOKEN secret
|
# (the default target repository). Requires the BACKLOG_ISSUE_TOKEN secret
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ on:
|
|||||||
- "mint"
|
- "mint"
|
||||||
- "minio-interop"
|
- "minio-interop"
|
||||||
- "Nightly GNU Build"
|
- "Nightly GNU Build"
|
||||||
- "Performance A/B"
|
|
||||||
- "Runner Hygiene"
|
- "Runner Hygiene"
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ on:
|
|||||||
- "crates/ecstore/src/object_api/write_commit_context.rs"
|
- "crates/ecstore/src/object_api/write_commit_context.rs"
|
||||||
- "crates/ecstore/src/bucket/utils.rs"
|
- "crates/ecstore/src/bucket/utils.rs"
|
||||||
- "crates/ecstore/Cargo.toml"
|
- "crates/ecstore/Cargo.toml"
|
||||||
- "crates/storage-api/src/bucket.rs"
|
|
||||||
- "crates/storage-api/Cargo.toml"
|
|
||||||
- "crates/utils/src/dunce.rs"
|
- "crates/utils/src/dunce.rs"
|
||||||
- "crates/utils/src/os/**"
|
- "crates/utils/src/os/**"
|
||||||
- "crates/utils/Cargo.toml"
|
- "crates/utils/Cargo.toml"
|
||||||
@@ -50,8 +48,6 @@ on:
|
|||||||
- "crates/ecstore/src/object_api/write_commit_context.rs"
|
- "crates/ecstore/src/object_api/write_commit_context.rs"
|
||||||
- "crates/ecstore/src/bucket/utils.rs"
|
- "crates/ecstore/src/bucket/utils.rs"
|
||||||
- "crates/ecstore/Cargo.toml"
|
- "crates/ecstore/Cargo.toml"
|
||||||
- "crates/storage-api/src/bucket.rs"
|
|
||||||
- "crates/storage-api/Cargo.toml"
|
|
||||||
- "crates/utils/src/dunce.rs"
|
- "crates/utils/src/dunce.rs"
|
||||||
- "crates/utils/src/os/**"
|
- "crates/utils/src/os/**"
|
||||||
- "crates/utils/Cargo.toml"
|
- "crates/utils/Cargo.toml"
|
||||||
@@ -107,6 +103,10 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
|
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
|
||||||
|
|
||||||
|
- name: Test multipart parent durability barriers
|
||||||
|
shell: pwsh
|
||||||
|
run: cargo test -p rustfs-ecstore --lib multipart_owned_record_parent_barriers_ -- --nocapture
|
||||||
|
|
||||||
- name: Test Windows path conversion
|
- name: Test Windows path conversion
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
|
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
|
||||||
@@ -144,17 +144,6 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: "cargo test -p rustfs-ecstore --lib bucket::utils:: -- --nocapture"
|
run: "cargo test -p rustfs-ecstore --lib bucket::utils:: -- --nocapture"
|
||||||
|
|
||||||
- name: Test artifact prefix Windows file-name rules
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$testName = 'bucket::tests::artifact_prefix_preserves_host_file_name_rules'
|
|
||||||
cargo test --locked -p rustfs-storage-api --lib $testName -- --exact --nocapture --color never 2>&1 |
|
|
||||||
Tee-Object -Variable testOutput
|
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
|
||||||
if (($testOutput -join "`n") -notmatch 'test result: ok\. 1 passed; 0 failed; 0 ignored;') {
|
|
||||||
throw 'Expected exactly one passing artifact prefix test on Windows'
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Test Windows file-name rules
|
- name: Test Windows file-name rules
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
|
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
|
||||||
|
|||||||
@@ -158,9 +158,7 @@ requested adversarial/design reviews, and agent-instruction changes that alter
|
|||||||
execution. Ordinary questions, diagnoses, status reports, non-adversarial code
|
execution. Ordinary questions, diagnoses, status reports, non-adversarial code
|
||||||
reviews, and low-risk planning do not trigger it.
|
reviews, and low-risk planning do not trigger it.
|
||||||
|
|
||||||
For applicable work and substantial PR reviews, read the [risk tiers and review shape](.agents/references/adversarial-validation.md).
|
For applicable work and substantial PR reviews, follow the [risk tiers and review shape](.agents/references/adversarial-validation.md) and check changed trust boundaries and relevant correctness, security, concurrency, durability, compatibility, performance, and negative regression coverage.
|
||||||
Load only the matching domain probes; ordinary reviews do not become adversarial
|
|
||||||
merely because this reference exists.
|
|
||||||
|
|
||||||
A review has no finding quota; `No findings` is a complete outcome. A request to
|
A review has no finding quota; `No findings` is a complete outcome. A request to
|
||||||
find problems is not evidence that a defect exists. Before reporting a candidate,
|
find problems is not evidence that a defect exists. Before reporting a candidate,
|
||||||
|
|||||||
Generated
+169
-88
@@ -365,9 +365,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow"
|
name = "arrow"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c25317ebddb8f2e6f006759336c95e5ded847a446f3524c27853bfe7d77a69b"
|
checksum = "3f04e0a89cc175eaf3740282537851e6fd9d7276f97730b9d0ea2859c11a85df"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-arith",
|
"arrow-arith",
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
@@ -386,9 +386,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-arith"
|
name = "arrow-arith"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ab555c0ffe9cb817041c0cee80baa9663a22225d0a035693579771f2693cadc"
|
checksum = "157943c720919e8f5b6731407ce5cd4c9a5ef47a75bf10edaf24b7081b5763ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
"arrow-buffer",
|
"arrow-buffer",
|
||||||
@@ -400,9 +400,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-array"
|
name = "arrow-array"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2dae1d91737421e92c6dd7c7f9d0f2d25198fd6fc539738b628f4dad10adf533"
|
checksum = "72b5b1682924f11ecf6929689c16cbc69efbb6dd141b06ad98ee1cb10d858c43"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arrow-buffer",
|
"arrow-buffer",
|
||||||
@@ -419,9 +419,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-buffer"
|
name = "arrow-buffer"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fe9cd0064f2d7cf8ecfaf77cd70e67a980bc71aea92b63102bd37a7d3379a09"
|
checksum = "b5d687afc6c927965a8a759f3b68de91933d9dfa2e1bb6e156bac84b85665e92"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"half",
|
"half",
|
||||||
@@ -431,9 +431,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-cast"
|
name = "arrow-cast"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee510bc252781d700400979a96bd1eadebeae23901132bbabd92118da1b376c5"
|
checksum = "3f23c3379feede42135ef27ca60e67f6e643cd4343ae74e4c8b16c83f55afd85"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
"arrow-buffer",
|
"arrow-buffer",
|
||||||
@@ -453,9 +453,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-csv"
|
name = "arrow-csv"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ace83d5935421b950fea3a0a3f9972c0837c297ef98ade57127a5661b681112c"
|
checksum = "85b1984628b091b8319b3c305fce21d04f9e9b2578bb733b0d0108462ae1d698"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
"arrow-cast",
|
"arrow-cast",
|
||||||
@@ -468,9 +468,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-data"
|
name = "arrow-data"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "54f128c73bb7fe9f7137218f910edd5b436f390e0f040b1956f5028390787897"
|
checksum = "bb0a031c7ff5e2bdb443117054fb466af1d4df5b7f0ecbe197713dc8b113b1ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-buffer",
|
"arrow-buffer",
|
||||||
"arrow-schema",
|
"arrow-schema",
|
||||||
@@ -481,9 +481,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-ipc"
|
name = "arrow-ipc"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e244f39b82fa7a823e8b5984a0bd988234f9c92c1df79378fb85090e6e4c821"
|
checksum = "f5dd1afadda2eb821aaf851bb4c0adef60c30cc0f1a29bd02fb99dd07fb9f58d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
"arrow-buffer",
|
"arrow-buffer",
|
||||||
@@ -497,9 +497,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-json"
|
name = "arrow-json"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "727b21ebf8894dcd9504d6ab66bffdac338391e1f85cde594526a0210299ea40"
|
checksum = "e2bc4d56a0dad84cc51c5812ccff48b642047a1f3be80349cd84318983e79edb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
"arrow-buffer",
|
"arrow-buffer",
|
||||||
@@ -522,9 +522,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-ord"
|
name = "arrow-ord"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "70d376b9b883239ae0f9963cdeeb59126ccaac08d60adf208a6ad3e2dd4048c3"
|
checksum = "561b8209faa2cd489764b7fd07d7cb9e8d274d78048c38de59f77c41140cb834"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
"arrow-buffer",
|
"arrow-buffer",
|
||||||
@@ -535,9 +535,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-row"
|
name = "arrow-row"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48096811f92b5c3a0c59d33cfecd7d7d2ed037d81e54a27a42d232e7700e6de2"
|
checksum = "0d8c68b87a569f92b90c46cb0a09d86bd9609ddc9b1bf1fc5a503c8c6907c14e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
"arrow-buffer",
|
"arrow-buffer",
|
||||||
@@ -548,9 +548,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-schema"
|
name = "arrow-schema"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37b6c8a6b12cf3869d299ce36ed8e1cd6fe9836264b68fb6c6538473a36124a1"
|
checksum = "e87acb0c6bf6237fca7a50d7b51e801c98a18a5fe6f3bb34f7d77b85ca165388"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -558,9 +558,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-select"
|
name = "arrow-select"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e523e85fa9fb3181a391bf0edfa6a1ae0dc46df8cbf7f6b96034dcb1e2a7a04a"
|
checksum = "c844141958d805b47d1208e0d12aac53fcf388ce8011639fe82aa492adde0bb4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
@@ -572,9 +572,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrow-string"
|
name = "arrow-string"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b931a905a0e4b8832d9f54fb369cf6e545f9dc03cb3d9611216a7bd2653df60a"
|
checksum = "bc130892b1a9552da77971924b390e06cf375d1f7e4344a7b7b40ca4426fa470"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
"arrow-buffer",
|
"arrow-buffer",
|
||||||
@@ -1514,6 +1514,23 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bindgen"
|
||||||
|
version = "0.73.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"cexpr",
|
||||||
|
"clang-sys",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"regex",
|
||||||
|
"rustc-hash",
|
||||||
|
"shlex",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "binrw"
|
name = "binrw"
|
||||||
version = "0.15.2"
|
version = "0.15.2"
|
||||||
@@ -1726,13 +1743,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "9.0.0"
|
version = "9.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
|
checksum = "6737ce8b81460bf9407909f73ff50bf13f7cb8577e1e76715f57ed0eba58ba44"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib 3.0.0",
|
"alloc-no-stdlib 3.0.0",
|
||||||
"alloc-stdlib 0.3.0",
|
"alloc-stdlib 0.3.0",
|
||||||
"brotli-decompressor 6.0.1",
|
"brotli-decompressor 6.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1747,9 +1764,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli-decompressor"
|
name = "brotli-decompressor"
|
||||||
version = "6.0.1"
|
version = "6.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
|
checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib 3.0.0",
|
"alloc-no-stdlib 3.0.0",
|
||||||
"alloc-stdlib 0.3.0",
|
"alloc-stdlib 0.3.0",
|
||||||
@@ -1967,6 +1984,15 @@ dependencies = [
|
|||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cexpr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||||
|
dependencies = [
|
||||||
|
"nom 7.1.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -2083,6 +2109,17 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clang-sys"
|
||||||
|
version = "1.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
|
||||||
|
dependencies = [
|
||||||
|
"glob",
|
||||||
|
"libc",
|
||||||
|
"libloading",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -2199,7 +2236,7 @@ version = "0.4.46"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
|
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"brotli 9.0.0",
|
"brotli 9.1.0",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -2207,7 +2244,7 @@ dependencies = [
|
|||||||
"lz4",
|
"lz4",
|
||||||
"memchr",
|
"memchr",
|
||||||
"zstd 0.14.3",
|
"zstd 0.14.3",
|
||||||
"zstd-safe 8.2.0",
|
"zstd-safe",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2542,9 +2579,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-bigint"
|
name = "crypto-bigint"
|
||||||
version = "0.7.7"
|
version = "0.7.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cc907c45bb7e9ef8fc3778defd0f3cd8c81288f1b4e748eba064948f20c47122"
|
checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cpubits",
|
"cpubits",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
@@ -2620,9 +2657,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctutils"
|
name = "ctutils"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cmov",
|
"cmov",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -3632,9 +3669,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
|
checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid 0.10.2",
|
"const-oid 0.10.2",
|
||||||
"pem-rfc7468 1.0.0",
|
"pem-rfc7468 1.0.0",
|
||||||
@@ -4040,6 +4077,7 @@ dependencies = [
|
|||||||
"rustfs-utils",
|
"rustfs-utils",
|
||||||
"rustls",
|
"rustls",
|
||||||
"s3s",
|
"s3s",
|
||||||
|
"same-file",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
@@ -4066,7 +4104,7 @@ version = "0.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
|
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der 0.8.2",
|
"der 0.8.3",
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
"elliptic-curve",
|
"elliptic-curve",
|
||||||
"rfc6979",
|
"rfc6979",
|
||||||
@@ -4131,9 +4169,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "elliptic-curve"
|
name = "elliptic-curve"
|
||||||
version = "0.14.2"
|
version = "0.14.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ad4496221fcfc21015fd8c0e26fcfab00504832dc0604f52acdd7bad2a8dc81"
|
checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct",
|
"base16ct",
|
||||||
"crypto-bigint",
|
"crypto-bigint",
|
||||||
@@ -5235,9 +5273,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath"
|
name = "hotpath"
|
||||||
version = "0.29.1"
|
version = "0.29.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "62f2351902fa259b758c7303505273393f6b08172ab5bdd5739a2d2bc6d554e8"
|
checksum = "5b4af2b5ecf2650278fe33b0e49b68c8c484d2306475c127628316dea740cf5c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -5270,15 +5308,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath-drain"
|
name = "hotpath-drain"
|
||||||
version = "0.29.1"
|
version = "0.29.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "83dab2340134d9976d62f9f5172f9c6584c7f521b092f258e0e58e412e160b53"
|
checksum = "a5cceb6c15fbaaeb00b51acc7be7fad533d0b56cb422b1cfc16cf9ca1624d33a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath-macros"
|
name = "hotpath-macros"
|
||||||
version = "0.29.1"
|
version = "0.29.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a04bbe5701b097f224a08187a78b75bb9d9b4bbaff6a279c2a50ba73edbb7aac"
|
checksum = "08c0a42eb299ebb7ec6b9161489e5bf132603a34ca12cecadb8490937aa0492a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -6398,7 +6436,7 @@ dependencies = [
|
|||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"indexmap 2.14.2",
|
"indexmap 2.14.2",
|
||||||
"metrics",
|
"metrics",
|
||||||
"ordered-float 5.5.0",
|
"ordered-float 5.5.1",
|
||||||
"quanta",
|
"quanta",
|
||||||
"radix_trie",
|
"radix_trie",
|
||||||
"rand 0.9.5",
|
"rand 0.9.5",
|
||||||
@@ -7332,9 +7370,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "5.5.0"
|
version = "5.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
|
checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
@@ -7451,9 +7489,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pageant"
|
name = "pageant"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866"
|
checksum = "e0bc922d9aa8cfcf598de27b3c6122cb2eeda9d6fe71527a22ebcb7f633540ff"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct",
|
"base16ct",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
@@ -7522,9 +7560,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parquet"
|
name = "parquet"
|
||||||
version = "59.3.1"
|
version = "59.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b3d2ef0fb136e9341570f219ecb168902e76079c6d81ad555f0a4840b1e08a3"
|
checksum = "8eb260d566f2fbdabe356d26aaa5d13403aac29b5b4e44463b8f9bd7b2b12680"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arrow-array",
|
"arrow-array",
|
||||||
@@ -7809,7 +7847,7 @@ version = "0.8.0-rc.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
|
checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der 0.8.2",
|
"der 0.8.3",
|
||||||
"spki 0.8.1",
|
"spki 0.8.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7852,7 +7890,7 @@ dependencies = [
|
|||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"cbc 0.2.1",
|
"cbc 0.2.1",
|
||||||
"der 0.8.2",
|
"der 0.8.3",
|
||||||
"pbkdf2 0.13.0",
|
"pbkdf2 0.13.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"scrypt 0.12.0",
|
"scrypt 0.12.0",
|
||||||
@@ -7876,7 +7914,7 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der 0.8.2",
|
"der 0.8.3",
|
||||||
"pkcs5 0.8.1",
|
"pkcs5 0.8.1",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"spki 0.8.1",
|
"spki 0.8.1",
|
||||||
@@ -9138,9 +9176,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "russh"
|
name = "russh"
|
||||||
version = "0.64.1"
|
version = "0.65.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58"
|
checksum = "3bb77a4b259312b2abbc541bca1c13cb4e9b61c560a7fd894068c3b92e0c9dc3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
@@ -9155,7 +9193,7 @@ dependencies = [
|
|||||||
"curve25519-dalek 5.0.0",
|
"curve25519-dalek 5.0.0",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"delegate",
|
"delegate",
|
||||||
"der 0.8.2",
|
"der 0.8.3",
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
"ecdsa",
|
"ecdsa",
|
||||||
"ed25519-dalek 3.0.0",
|
"ed25519-dalek 3.0.0",
|
||||||
@@ -10298,7 +10336,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10688,7 +10725,6 @@ dependencies = [
|
|||||||
"hotpath",
|
"hotpath",
|
||||||
"insta",
|
"insta",
|
||||||
"rustfs-filemeta",
|
"rustfs-filemeta",
|
||||||
"rustfs-utils",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"time",
|
"time",
|
||||||
@@ -10848,7 +10884,7 @@ version = "1.0.2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64-simd",
|
"base64-simd",
|
||||||
"blake2",
|
"blake2",
|
||||||
"brotli 9.0.0",
|
"brotli 9.1.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"convert_case 0.12.0",
|
"convert_case 0.12.0",
|
||||||
"crc-fast",
|
"crc-fast",
|
||||||
@@ -11309,7 +11345,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct",
|
"base16ct",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
"der 0.8.2",
|
"der 0.8.3",
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -11475,6 +11511,15 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -11754,9 +11799,9 @@ checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sketches-ddsketch"
|
name = "sketches-ddsketch"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
|
checksum = "45f82fa313d625dd72aaa65ec0c9f8c95407add0510c1bef3c18ea7257ec34ce"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
@@ -11885,7 +11930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
|
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"der 0.8.2",
|
"der 0.8.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -12634,6 +12679,30 @@ dependencies = [
|
|||||||
"webpki-roots 0.26.11",
|
"webpki-roots 0.26.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.9.12+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime 0.7.5+spec-1.1.0",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.7.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.2+spec-1.1.0"
|
version = "1.1.2+spec-1.1.0"
|
||||||
@@ -12650,9 +12719,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap 2.14.2",
|
"indexmap 2.14.2",
|
||||||
"toml_datetime",
|
"toml_datetime 1.1.2+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -12661,9 +12730,15 @@ version = "1.1.5+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.3+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tonic"
|
name = "tonic"
|
||||||
version = "0.14.6"
|
version = "0.14.6"
|
||||||
@@ -13690,6 +13765,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -13956,9 +14037,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "9.1.0"
|
version = "9.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
|
checksum = "ec2e3161de27091f18a831d9494e9c53378a34833af5c1dda35ad85849da18d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
@@ -14010,10 +14091,18 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "zstd"
|
name = "zstd"
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zstd-safe 7.3.0",
|
"apache-avro",
|
||||||
|
"arrow-array",
|
||||||
|
"arrow-ipc",
|
||||||
|
"arrow-schema",
|
||||||
|
"bytes",
|
||||||
|
"kafka-protocol",
|
||||||
|
"parquet",
|
||||||
|
"semver",
|
||||||
|
"toml",
|
||||||
|
"zstd 0.14.3",
|
||||||
|
"zstd-safe",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -14022,16 +14111,7 @@ version = "0.14.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
|
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zstd-safe 8.2.0",
|
"zstd-safe",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zstd-safe"
|
|
||||||
version = "7.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
|
||||||
dependencies = [
|
|
||||||
"zstd-sys",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -14049,6 +14129,7 @@ version = "2.1.2+zstd.1.5.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
|
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
"cc",
|
"cc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|||||||
+7
-4
@@ -66,6 +66,7 @@ members = [
|
|||||||
"crates/io-metrics", # I/O metrics collection for performance analysis
|
"crates/io-metrics", # I/O metrics collection for performance analysis
|
||||||
"crates/io-core", # Buffered Bytes, mmap-then-copy, and aligned pread I/O helpers
|
"crates/io-core", # Buffered Bytes, mmap-then-copy, and aligned pread I/O helpers
|
||||||
"crates/zip", # ZIP file handling and compression
|
"crates/zip", # ZIP file handling and compression
|
||||||
|
"crates/zstd-compat", # Fixed codec for dependencies requiring zstd 0.13
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
@@ -260,7 +261,7 @@ aws-smithy-http-client = { default-features = false, version = "1.5.1" }
|
|||||||
aws-smithy-runtime-api = { version = "1.19.1" }
|
aws-smithy-runtime-api = { version = "1.19.1" }
|
||||||
aws-smithy-types = { version = "1.9.0" }
|
aws-smithy-types = { version = "1.9.0" }
|
||||||
base64-simd = "0.8.0"
|
base64-simd = "0.8.0"
|
||||||
brotli = "9.0.0"
|
brotli = "9.1.0"
|
||||||
clap = { version = "4.6.7" }
|
clap = { version = "4.6.7" }
|
||||||
const-str = { version = "1.1.0" }
|
const-str = { version = "1.1.0" }
|
||||||
convert_case = "0.12.0"
|
convert_case = "0.12.0"
|
||||||
@@ -302,6 +303,7 @@ num_cpus = { version = "1.17.0" }
|
|||||||
nvml-wrapper = "0.13.0"
|
nvml-wrapper = "0.13.0"
|
||||||
object = { version = "=0.40.0", default-features = false }
|
object = { version = "=0.40.0", default-features = false }
|
||||||
parking_lot = "0.13.0"
|
parking_lot = "0.13.0"
|
||||||
|
same-file = "1.0.6"
|
||||||
path-absolutize = "4.0.1"
|
path-absolutize = "4.0.1"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||||
@@ -354,7 +356,7 @@ winapi-util = "0.1.11"
|
|||||||
windows = { version = "0.62.2" }
|
windows = { version = "0.62.2" }
|
||||||
windows-sys = "0.61.2"
|
windows-sys = "0.61.2"
|
||||||
xxhash-rust = { version = "0.8.19" }
|
xxhash-rust = { version = "0.8.19" }
|
||||||
zip = "9.1.0"
|
zip = "9.1.2"
|
||||||
zstd = "0.14.3"
|
zstd = "0.14.3"
|
||||||
|
|
||||||
# Observability and Metrics
|
# Observability and Metrics
|
||||||
@@ -375,7 +377,7 @@ libunftp = { version = "0.23.1" }
|
|||||||
unftp-core = "0.1.0"
|
unftp-core = "0.1.0"
|
||||||
suppaftp = { version = "12.2.1" }
|
suppaftp = { version = "12.2.1" }
|
||||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||||
russh = { version = "0.64.1" }
|
russh = { version = "0.65.0" }
|
||||||
russh-sftp = "3.0.1"
|
russh-sftp = "3.0.1"
|
||||||
|
|
||||||
# WebDAV
|
# WebDAV
|
||||||
@@ -384,7 +386,7 @@ dav-server = "0.11.0"
|
|||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
rustfs-mimalloc = { version = "0.7.2" }
|
rustfs-mimalloc = { version = "0.7.2" }
|
||||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||||
hotpath = { version = "0.29.1", default-features = false }
|
hotpath = { version = "0.29.2", default-features = false }
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.49" }
|
insta = { version = "1.49" }
|
||||||
|
|
||||||
@@ -424,6 +426,7 @@ strip = "none"
|
|||||||
|
|
||||||
# Pin upstream fixes until compatible crates.io releases are available.
|
# Pin upstream fixes until compatible crates.io releases are available.
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
|
zstd = { path = "crates/zstd-compat" }
|
||||||
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
|
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
|
||||||
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
|
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
|
||||||
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
|
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ chown -R 10001:10001 data logs
|
|||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||||
|
|
||||||
# Using specific version
|
# Using specific version
|
||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||||
```
|
```
|
||||||
|
|
||||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||||
@@ -220,7 +220,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
|
|||||||
./docker-buildx.sh --push
|
./docker-buildx.sh --push
|
||||||
|
|
||||||
# Build specific version
|
# Build specific version
|
||||||
./docker-buildx.sh --release 1.0.3 --push
|
./docker-buildx.sh --release 1.0.4 --push
|
||||||
|
|
||||||
# Build for custom registry
|
# Build for custom registry
|
||||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||||
@@ -238,7 +238,7 @@ You can also use Make targets for convenience:
|
|||||||
```bash
|
```bash
|
||||||
make docker-buildx # Build locally
|
make docker-buildx # Build locally
|
||||||
make docker-buildx-push # Build and push
|
make docker-buildx-push # Build and push
|
||||||
make docker-buildx-version VERSION=1.0.3 # Build specific version
|
make docker-buildx-version VERSION=1.0.4 # Build specific version
|
||||||
make help-docker # Show all Docker-related commands
|
make help-docker # Show all Docker-related commands
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
|
|||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||||
|
|
||||||
# 使用指定版本运行
|
# 使用指定版本运行
|
||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||||
```
|
```
|
||||||
|
|
||||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||||
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
|
|||||||
./docker-buildx.sh --push
|
./docker-buildx.sh --push
|
||||||
|
|
||||||
# 构建指定版本
|
# 构建指定版本
|
||||||
./docker-buildx.sh --release 1.0.3 --push
|
./docker-buildx.sh --release 1.0.4 --push
|
||||||
|
|
||||||
# 构建并推送到自定义仓库
|
# 构建并推送到自定义仓库
|
||||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||||
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
|
|||||||
```bash
|
```bash
|
||||||
make docker-buildx # 本地构建
|
make docker-buildx # 本地构建
|
||||||
make docker-buildx-push # 构建并推送
|
make docker-buildx-push # 构建并推送
|
||||||
make docker-buildx-version VERSION=1.0.3 # 构建指定版本
|
make docker-buildx-version VERSION=1.0.4 # 构建指定版本
|
||||||
make help-docker # 显示所有 Docker 相关命令
|
make help-docker # 显示所有 Docker 相关命令
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ rustfs-io-core = { workspace = true }
|
|||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time"] }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ pub const ENV_HEAL_MRF_REPLAY_BATCH: &str = "RUSTFS_HEAL_MRF_REPLAY_BATCH";
|
|||||||
/// Default behavior keeps the MRF intent pipeline enabled.
|
/// Default behavior keeps the MRF intent pipeline enabled.
|
||||||
pub const DEFAULT_HEAL_MRF_ENABLE: bool = true;
|
pub const DEFAULT_HEAL_MRF_ENABLE: bool = true;
|
||||||
|
|
||||||
|
/// Opt in to lifecycle V2 transitions after qualifying every reader and the rollback binary.
|
||||||
|
pub const ENV_MRF_LIFECYCLE_V2_ENABLE: &str = "RUSTFS_MRF_LIFECYCLE_V2_ENABLE";
|
||||||
|
pub const DEFAULT_MRF_LIFECYCLE_V2_ENABLE: bool = false;
|
||||||
|
|
||||||
/// Default MRF queue capacity (matches MinIO's 100k MRF list ceiling).
|
/// Default MRF queue capacity (matches MinIO's 100k MRF list ceiling).
|
||||||
pub const DEFAULT_HEAL_MRF_QUEUE_SIZE: usize = 100_000;
|
pub const DEFAULT_HEAL_MRF_QUEUE_SIZE: usize = 100_000;
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ zip.workspace = true
|
|||||||
clap = { workspace = true, features = ["derive", "env"] }
|
clap = { workspace = true, features = ["derive", "env"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
same-file.workspace = true
|
||||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
|
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
|
||||||
rustfs-heal.workspace = true
|
rustfs-heal.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -200,8 +200,8 @@ python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run
|
|||||||
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||||
|
|
||||||
# Distributed 4-node 4-disk lane includes physical-worker fault injection and
|
# Distributed 4-node 4-disk lane uses the fault-test hooks server; the
|
||||||
# scanner_heal_topology_ cases; both require the fault-test hooks server.
|
# scanner_heal_topology_ fault cases fail closed without it.
|
||||||
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
|
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
|
||||||
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
//! 3. Starting with the default `rustfsadmin` credentials emits the
|
//! 3. Starting with the default `rustfsadmin` credentials emits the
|
||||||
//! default-credentials startup warning (observable operator signal).
|
//! default-credentials startup warning (observable operator signal).
|
||||||
|
|
||||||
|
// Keep inventory acceptance in the admin-auth PR smoke selection.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod target_inventory_test;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Copyright 2026 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.
|
||||||
|
|
||||||
|
//! Exercise the target inventory through HTTP authentication and authorization.
|
||||||
|
//! Handler-level tests separately control reconciliation locks and config faults.
|
||||||
|
|
||||||
|
use crate::common::{
|
||||||
|
RustFSTestEnvironment, admin_create_user, admin_request, admin_request_with_session_token, build_test_sts_client,
|
||||||
|
init_logging, local_http_client,
|
||||||
|
};
|
||||||
|
use http::header::{AUTHORIZATION, HOST};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use rustfs_signer::{constants::UNSIGNED_PAYLOAD, sign_v4};
|
||||||
|
use s3s::Body;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{error::Error, time::Duration};
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||||
|
|
||||||
|
const SECRET: &str = "inventory-acceptance-token";
|
||||||
|
const LIMITED_KEY: &str = "inventory-limited";
|
||||||
|
const LIMITED_SECRET: &str = "inventory-limited-secret";
|
||||||
|
const PATHS: [&str; 5] = [
|
||||||
|
"/rustfs/admin/v3/target/list",
|
||||||
|
"/rustfs/admin/v3/audit/target/list",
|
||||||
|
"/rustfs/admin/v4/plugins/instances",
|
||||||
|
"/rustfs/admin/v4/plugins/instances/builtin:webhook:notify:acceptance",
|
||||||
|
"/rustfs/admin/v4/extensions/instances",
|
||||||
|
];
|
||||||
|
|
||||||
|
async fn duplicate_authorization(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||||
|
let url = format!("{}{path}", env.url);
|
||||||
|
let uri: http::Uri = url.parse()?;
|
||||||
|
let authority = uri.authority().ok_or("inventory URL missing authority")?.to_string();
|
||||||
|
let request = http::Request::builder()
|
||||||
|
.method(http::Method::GET)
|
||||||
|
.uri(uri)
|
||||||
|
.header(HOST, authority)
|
||||||
|
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
|
||||||
|
let mut headers = signed.headers().clone();
|
||||||
|
let authorization = headers.get(AUTHORIZATION).ok_or("signer omitted authorization")?.clone();
|
||||||
|
headers.append(AUTHORIZATION, authorization);
|
||||||
|
assert_eq!(headers.get_all(AUTHORIZATION).iter().count(), 2);
|
||||||
|
Ok(local_http_client().get(url).headers(headers).send().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_unloaded_target(entry: &Value, path: &str) {
|
||||||
|
assert_eq!(entry["service"], "webhook", "{path}: unexpected target service");
|
||||||
|
assert!(
|
||||||
|
entry["account_id"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance")),
|
||||||
|
"{path}: fixture target is missing"
|
||||||
|
);
|
||||||
|
assert_eq!(entry["status"], "offline", "{path}: unloaded targets must remain visible as offline");
|
||||||
|
if !path.contains("/extensions/") {
|
||||||
|
assert_eq!(entry["health_reason"], "not_loaded_in_runtime", "{path}: unexpected health reason");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn target_inventory_preserves_unloaded_targets_and_http_auth_boundaries() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
// Enabled destinations remain configured while both delivery modules are
|
||||||
|
// disabled, so inventory reads must not require a loaded target runtime.
|
||||||
|
env.start_rustfs_server_with_env(
|
||||||
|
vec![],
|
||||||
|
&[
|
||||||
|
("RUSTFS_NOTIFY_ENABLE", "false"),
|
||||||
|
("RUSTFS_AUDIT_ENABLE", "false"),
|
||||||
|
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
|
||||||
|
("RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
|
||||||
|
("RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
|
||||||
|
("RUSTFS_AUDIT_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
|
||||||
|
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
|
||||||
|
("RUSTFS_AUDIT_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
admin_create_user(&env, LIMITED_KEY, LIMITED_SECRET).await?;
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(60), async {
|
||||||
|
let assumed = build_test_sts_client(&env.url, &env.access_key, &env.secret_key, None, "e2e-target-inventory")
|
||||||
|
.assume_role()
|
||||||
|
.role_arn("arn:aws:iam::123456789012:role/target-inventory")
|
||||||
|
.role_session_name("target-inventory-console-session")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let temporary = assumed
|
||||||
|
.credentials()
|
||||||
|
.ok_or("AssumeRole should return temporary inventory credentials")?;
|
||||||
|
assert_ne!(
|
||||||
|
temporary.access_key_id(),
|
||||||
|
env.access_key,
|
||||||
|
"inventory must exercise a distinct session credential"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!temporary.session_token().is_empty(),
|
||||||
|
"temporary inventory credentials must include a token"
|
||||||
|
);
|
||||||
|
|
||||||
|
for path in PATHS {
|
||||||
|
for (principal, access_key, secret_key, session_token) in [
|
||||||
|
("root", env.access_key.as_str(), env.secret_key.as_str(), None),
|
||||||
|
(
|
||||||
|
"temporary admin",
|
||||||
|
temporary.access_key_id(),
|
||||||
|
temporary.secret_access_key(),
|
||||||
|
Some(temporary.session_token()),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let (status, body) = admin_request_with_session_token(
|
||||||
|
&env.url,
|
||||||
|
http::Method::GET,
|
||||||
|
path,
|
||||||
|
None,
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
session_token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{path}: signed {principal} inventory failed");
|
||||||
|
assert!(!body.contains(SECRET), "{path}: inventory exposed a target secret");
|
||||||
|
let value: Value = serde_json::from_str(&body)?;
|
||||||
|
let entries: Vec<&Value> = if path.ends_with("/target/list") {
|
||||||
|
let field = if path.contains("/audit/") {
|
||||||
|
"audit_endpoints"
|
||||||
|
} else {
|
||||||
|
"notification_endpoints"
|
||||||
|
};
|
||||||
|
value[field].as_array().ok_or("missing endpoint inventory")?.iter().collect()
|
||||||
|
} else if path.ends_with("/instances") {
|
||||||
|
value["instances"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or("missing instance inventory")?
|
||||||
|
.iter()
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
vec![&value]
|
||||||
|
};
|
||||||
|
let targets: Vec<_> = entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry["account_id"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let expected = if path.ends_with("/instances") { 2 } else { 1 };
|
||||||
|
assert_eq!(targets.len(), expected, "{path}: configured targets disappeared");
|
||||||
|
for entry in &targets {
|
||||||
|
assert_unloaded_target(entry, path);
|
||||||
|
if path.contains("/v4/") {
|
||||||
|
assert_eq!(entry["config"]["auth_token"], "***redacted***", "{path}: missing secret redaction");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if expected == 2 {
|
||||||
|
for domain in ["notify", "audit"] {
|
||||||
|
assert!(targets.iter().any(|entry| entry["domain"] == domain), "{path}: missing {domain} domain");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?;
|
||||||
|
assert_eq!(unsigned.status(), StatusCode::FORBIDDEN, "{path}: unsigned request was not denied");
|
||||||
|
let body = unsigned.text().await?;
|
||||||
|
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
|
||||||
|
|
||||||
|
let ambiguous = duplicate_authorization(&env, path).await?;
|
||||||
|
assert_eq!(
|
||||||
|
ambiguous.status(),
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"{path}: duplicate authorization was not rejected"
|
||||||
|
);
|
||||||
|
let body = ambiguous.text().await?;
|
||||||
|
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
|
||||||
|
|
||||||
|
let (status, body) = admin_request(&env.url, http::Method::GET, path, None, LIMITED_KEY, LIMITED_SECRET).await?;
|
||||||
|
assert_eq!(status, StatusCode::FORBIDDEN, "{path}: principal without admin policy was not denied");
|
||||||
|
assert!(body.contains("<Code>AccessDenied</Code>"), "{path}: unexpected authorization error");
|
||||||
|
assert!(!body.contains("Signature is required") && !body.contains("SignatureDoesNotMatch"));
|
||||||
|
}
|
||||||
|
Ok::<(), Box<dyn Error + Send + Sync>>(())
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
env.stop_server();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -20,15 +20,14 @@
|
|||||||
//! offline before heal converges, a strict latest-listing quorum of 3 can only
|
//! offline before heal converges, a strict latest-listing quorum of 3 can only
|
||||||
//! ever observe 2 copies, so ListObjectsV2 silently dropped the object even
|
//! ever observe 2 copies, so ListObjectsV2 silently dropped the object even
|
||||||
//! though GetObject (read quorum 2) still succeeded. Exposed by the flaky
|
//! though GetObject (read quorum 2) still succeeded. Exposed by the flaky
|
||||||
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853); the
|
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853). The
|
||||||
//! product fix relaxes the listing's required object quorum by the number of
|
//! listing now follows MinIO's rule: an entry needs `ceil(asked/2)` copies
|
||||||
//! set drives the listing could not consult (see
|
//! among the online drives it asked, so three online drives need two copies
|
||||||
//! `latest_listing_required_object_quorum` in
|
//! (see `SetDisks::list_path` in `crates/ecstore/src/store/list_objects.rs`).
|
||||||
//! `crates/ecstore/src/store/list_objects.rs`).
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::common::{RustFSTestClusterEnvironment, init_logging};
|
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment, init_logging};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -51,13 +50,13 @@ mod tests {
|
|||||||
format!("degraded-object-{idx:02}")
|
format!("degraded-object-{idx:02}")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_all_keys(client: &Client) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
|
async fn list_all_keys(client: &Client, bucket: &str) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
|
||||||
let mut keys = HashSet::new();
|
let mut keys = HashSet::new();
|
||||||
let mut continuation_token: Option<String> = None;
|
let mut continuation_token: Option<String> = None;
|
||||||
loop {
|
loop {
|
||||||
let response = client
|
let response = client
|
||||||
.list_objects_v2()
|
.list_objects_v2()
|
||||||
.bucket(BUCKET)
|
.bucket(bucket)
|
||||||
.set_continuation_token(continuation_token.clone())
|
.set_continuation_token(continuation_token.clone())
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -147,7 +146,7 @@ mod tests {
|
|||||||
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
|
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
|
||||||
let deadline = Instant::now() + LISTING_DEADLINE;
|
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||||
let listed = loop {
|
let listed = loop {
|
||||||
let listed = match list_all_keys(&client).await {
|
let listed = match list_all_keys(&client, BUCKET).await {
|
||||||
Ok(keys) => keys,
|
Ok(keys) => keys,
|
||||||
Err(error) if Instant::now() < deadline => {
|
Err(error) if Instant::now() < deadline => {
|
||||||
info!("retrying degraded listing: {error}");
|
info!("retrying degraded listing: {error}");
|
||||||
@@ -171,4 +170,97 @@ mod tests {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 4-node x 4-drive cluster (one 16-drive set), #8828 shape:
|
||||||
|
/// 1. PUT objects while the storage class is EC:8, so each is 8+8.
|
||||||
|
/// 2. Restart with the EC:4 default, as after a storage-class change.
|
||||||
|
/// 3. Stop two nodes (8 drives). Each object keeps eight data shards, so
|
||||||
|
/// GET succeeds and ListObjectsV2 must list every key: the listing
|
||||||
|
/// quorum is half of the eight drives it can ask.
|
||||||
|
/// 4. Stop a third node. Four online drives are below the set's read
|
||||||
|
/// quorum, so LIST must report 503 instead of a partial page.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ec8_objects_stay_listable_with_half_of_an_ec4_set_offline() -> TestResult {
|
||||||
|
const EC8_BUCKET: &str = "degraded-listing-ec8";
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 4)).await?;
|
||||||
|
cluster.set_env("RUSTFS_HEAL_ENABLED", "false");
|
||||||
|
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:8");
|
||||||
|
cluster.start().await?;
|
||||||
|
cluster.create_test_bucket(EC8_BUCKET).await?;
|
||||||
|
let client = cluster.create_s3_client(0)?;
|
||||||
|
for idx in 0..OBJECT_COUNT {
|
||||||
|
let key = object_key(idx);
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(EC8_BUCKET)
|
||||||
|
.key(&key)
|
||||||
|
.body(Bytes::from(format!("ec8 listing payload {idx}")).into())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("EC:8 PUT for {key} failed: {error}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("restarting with the EC:4 default storage class");
|
||||||
|
cluster.stop();
|
||||||
|
// A later entry for the same variable wins when the node process starts.
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4");
|
||||||
|
cluster.start().await?;
|
||||||
|
let client = cluster.create_s3_client(0)?;
|
||||||
|
|
||||||
|
info!("stopping nodes 2 and 3 (eight of sixteen drives)");
|
||||||
|
cluster.stop_node(2)?;
|
||||||
|
cluster.stop_node(3)?;
|
||||||
|
for idx in 0..OBJECT_COUNT {
|
||||||
|
let key = object_key(idx);
|
||||||
|
let deadline = Instant::now() + GET_RETRY_DEADLINE;
|
||||||
|
loop {
|
||||||
|
match client.get_object().bucket(EC8_BUCKET).key(&key).send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
let body = response.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(body.as_ref(), format!("ec8 listing payload {idx}").as_bytes());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) if Instant::now() < deadline => {
|
||||||
|
info!("retrying EC:8 GET for {key}: {error}");
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(format!("EC:8 object {key} failed GET with eight drives: {error}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
|
||||||
|
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||||
|
loop {
|
||||||
|
match list_all_keys(&client, EC8_BUCKET).await {
|
||||||
|
Ok(listed) if listed == expected => break,
|
||||||
|
Ok(listed) => assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"EC:8 objects readable with eight drives stayed missing from ListObjectsV2: listed={listed:?}"
|
||||||
|
),
|
||||||
|
Err(error) => assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"ListObjectsV2 kept failing with eight of sixteen drives online: {error}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("stopping node 1 (four of sixteen drives remain)");
|
||||||
|
cluster.stop_node(1)?;
|
||||||
|
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||||
|
loop {
|
||||||
|
match client.list_objects_v2().bucket(EC8_BUCKET).send().await {
|
||||||
|
Err(error) if error.raw_response().is_some_and(|response| response.status().as_u16() == 503) => break,
|
||||||
|
Err(error) => assert!(Instant::now() < deadline, "below the set read quorum LIST must report 503, got {error}"),
|
||||||
|
Ok(response) => panic!("below the set read quorum LIST must fail, but listed {} keys", response.contents().len()),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
#![cfg(unix)]
|
|
||||||
|
|
||||||
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
|
|
||||||
use super::harness::{
|
|
||||||
DistCluster, DistLayout, TestResult, enable_versioning, payload_for, s3_probe_client, unique_bucket, wait_for_ready,
|
|
||||||
};
|
|
||||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk};
|
|
||||||
use crate::common::init_logging;
|
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
|
||||||
use aws_sdk_s3::types::{Delete, ObjectIdentifier};
|
|
||||||
use serde_json::json;
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::time::timeout;
|
|
||||||
|
|
||||||
const PUBLIC_INTERNAL_ERROR: &str = "We encountered an internal error, please try again.";
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub(super) struct PhysicalEntry {
|
|
||||||
identity: (u64, u64),
|
|
||||||
changed: (i64, i64),
|
|
||||||
modified: (i64, i64),
|
|
||||||
bytes: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include directory identity/ctime as well as file bytes. Creating and removing
|
|
||||||
// a temporary marker/rollback file cannot hide behind a restored final xl.meta.
|
|
||||||
// Reads change atime, which is deliberately excluded.
|
|
||||||
pub(super) fn physical_snapshot(dist: &DistCluster, bucket: &str) -> TestResult<BTreeMap<(usize, PathBuf), PhysicalEntry>> {
|
|
||||||
let mut snapshot = BTreeMap::new();
|
|
||||||
for (node, instance) in dist.cluster.nodes.iter().enumerate() {
|
|
||||||
let root = Path::new(&instance.data_dirs[0]).join(bucket);
|
|
||||||
for entry in walkdir::WalkDir::new(&root) {
|
|
||||||
let entry = entry?;
|
|
||||||
let metadata = std::fs::symlink_metadata(entry.path())?;
|
|
||||||
assert!(!metadata.file_type().is_symlink(), "unexpected fixture symlink");
|
|
||||||
snapshot.insert(
|
|
||||||
(node, entry.path().strip_prefix(&root)?.to_path_buf()),
|
|
||||||
PhysicalEntry {
|
|
||||||
identity: (metadata.dev(), metadata.ino()),
|
|
||||||
changed: (metadata.ctime(), metadata.ctime_nsec()),
|
|
||||||
modified: (metadata.mtime(), metadata.mtime_nsec()),
|
|
||||||
bytes: if metadata.is_dir() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(std::fs::read(entry.path())?)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Version {
|
|
||||||
key: String,
|
|
||||||
id: String,
|
|
||||||
body: Vec<u8>,
|
|
||||||
manifests: Vec<VersionShardCensus>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Version {
|
|
||||||
async fn put(dist: &DistCluster, bucket: &str, key: &str, seed: &str) -> TestResult<Self> {
|
|
||||||
let body = payload_for(seed, 2 * 1024 * 1024);
|
|
||||||
let output = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
dist.client(1)?
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.body(body.clone().into())
|
|
||||||
.send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
let id = output.version_id().ok_or("fixture PUT omitted version ID")?.to_owned();
|
|
||||||
let manifests = dist
|
|
||||||
.cluster
|
|
||||||
.nodes
|
|
||||||
.iter()
|
|
||||||
.map(|node| census_object_version_on_disk(Path::new(&node.data_dirs[0]), bucket, key, Some(&id)))
|
|
||||||
.collect::<TestResult<Vec<_>>>()?;
|
|
||||||
assert!(
|
|
||||||
manifests
|
|
||||||
.iter()
|
|
||||||
.all(|manifest| manifest.is_complete() && !manifest.expected_part_numbers.is_empty())
|
|
||||||
);
|
|
||||||
Ok(Self {
|
|
||||||
key: key.to_owned(),
|
|
||||||
id,
|
|
||||||
body,
|
|
||||||
manifests,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn target(key: &str, version: Option<&str>) -> TestResult<ObjectIdentifier> {
|
|
||||||
Ok(ObjectIdentifier::builder()
|
|
||||||
.key(key)
|
|
||||||
.set_version_id(version.map(str::to_owned))
|
|
||||||
.build()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_fixture(
|
|
||||||
dist: &DistCluster,
|
|
||||||
bucket: &str,
|
|
||||||
prefix: &str,
|
|
||||||
versions: &[Version],
|
|
||||||
marker: (&str, &str),
|
|
||||||
) -> TestResult {
|
|
||||||
let expected: BTreeSet<_> = versions
|
|
||||||
.iter()
|
|
||||||
.map(|version| (version.key.clone(), version.id.clone()))
|
|
||||||
.collect();
|
|
||||||
for node in 0..dist.cluster.nodes.len() {
|
|
||||||
let client = dist.client(node)?;
|
|
||||||
let listed = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
client.list_object_versions().bucket(bucket).prefix(prefix).send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
let actual: BTreeSet<_> = listed
|
|
||||||
.versions()
|
|
||||||
.iter()
|
|
||||||
.map(|version| {
|
|
||||||
(
|
|
||||||
version.key().unwrap_or_default().to_owned(),
|
|
||||||
version.version_id().unwrap_or_default().to_owned(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
assert_eq!(listed.versions().len(), versions.len());
|
|
||||||
assert_eq!(actual, expected, "delete changed exact data versions on node {node}");
|
|
||||||
assert_eq!(listed.delete_markers().len(), 1);
|
|
||||||
assert_eq!(listed.delete_markers()[0].key(), Some(marker.0));
|
|
||||||
assert_eq!(listed.delete_markers()[0].version_id(), Some(marker.1));
|
|
||||||
assert_eq!(listed.delete_markers()[0].is_latest(), Some(true));
|
|
||||||
for version in versions {
|
|
||||||
assert_exact_version(&client, bucket, &version.key, &version.id, &version.body).await?;
|
|
||||||
let disk = Path::new(&dist.cluster.nodes[node].data_dirs[0]);
|
|
||||||
assert!(
|
|
||||||
census_object_version_on_disk(disk, bucket, &version.key, Some(&version.id))?
|
|
||||||
.matches_manifest(&version.manifests[node])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_single_denied(dist: &DistCluster, node: usize, bucket: &str, key: &str, version: Option<&str>) -> TestResult {
|
|
||||||
let before = physical_snapshot(dist, bucket)?;
|
|
||||||
// One attempt: a later retry must not conceal a first local mutation.
|
|
||||||
let client = s3_probe_client(&dist.client(node)?);
|
|
||||||
let error = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
client
|
|
||||||
.delete_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.set_version_id(version.map(str::to_owned))
|
|
||||||
.send(),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.expect_err("ordinary deletion succeeded in a managed bucket");
|
|
||||||
let service = error
|
|
||||||
.as_service_error()
|
|
||||||
.ok_or("delete failed without an S3 service rejection")?;
|
|
||||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
|
||||||
assert_eq!(service.code(), Some("InternalError"));
|
|
||||||
assert_eq!(service.message(), Some(PUBLIC_INTERNAL_ERROR));
|
|
||||||
assert!(
|
|
||||||
physical_snapshot(dist, bucket)? == before,
|
|
||||||
"first delete through node {node} wrote or rolled back managed data"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_batch_denied(dist: &DistCluster, node: usize, bucket: &str, objects: &[ObjectIdentifier]) -> TestResult {
|
|
||||||
let before = physical_snapshot(dist, bucket)?;
|
|
||||||
let client = s3_probe_client(&dist.client(node)?);
|
|
||||||
let response = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
client
|
|
||||||
.delete_objects()
|
|
||||||
.bucket(bucket)
|
|
||||||
.delete(Delete::builder().set_objects(Some(objects.to_vec())).quiet(false).build()?)
|
|
||||||
.send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
assert!(response.deleted().is_empty(), "managed batch reported a successful delete");
|
|
||||||
assert_eq!(response.errors().len(), objects.len(), "batch must reject every requested occurrence");
|
|
||||||
for (error, requested) in response.errors().iter().zip(objects) {
|
|
||||||
assert_eq!(error.key(), Some(requested.key()));
|
|
||||||
assert_eq!(error.version_id(), requested.version_id());
|
|
||||||
assert_eq!(error.code(), Some("InternalError"));
|
|
||||||
assert_eq!(error.message(), Some(PUBLIC_INTERNAL_ERROR));
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
physical_snapshot(dist, bucket)? == before,
|
|
||||||
"first batch through node {node} wrote or rolled back managed data"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_existing_object(source: &Path, destination: &Path) -> TestResult {
|
|
||||||
assert!(!destination.exists());
|
|
||||||
for entry in walkdir::WalkDir::new(source) {
|
|
||||||
let entry = entry?;
|
|
||||||
let target = destination.join(entry.path().strip_prefix(source)?);
|
|
||||||
if entry.file_type().is_dir() {
|
|
||||||
std::fs::create_dir_all(&target)?;
|
|
||||||
} else {
|
|
||||||
assert!(entry.file_type().is_file());
|
|
||||||
std::fs::copy(entry.path(), target)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ordinary_delete_control(dist: &DistCluster, bucket: &str) -> TestResult {
|
|
||||||
let client = s3_probe_client(&dist.client(0)?);
|
|
||||||
let key = "ordinary-observer-control.bin";
|
|
||||||
let version = Version::put(dist, bucket, key, key).await?;
|
|
||||||
let before = physical_snapshot(dist, bucket)?;
|
|
||||||
let deleted = client.delete_object().bucket(bucket).key(key).send().await?;
|
|
||||||
assert_eq!(deleted.delete_marker(), Some(true));
|
|
||||||
let marker = deleted.version_id().ok_or("ordinary delete omitted marker ID")?;
|
|
||||||
assert!(
|
|
||||||
physical_snapshot(dist, bucket)? != before,
|
|
||||||
"physical observer missed real marker creation"
|
|
||||||
);
|
|
||||||
client
|
|
||||||
.delete_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.version_id(marker)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
// The logical version inventory is restored, but ctime/identity still
|
|
||||||
// expose the two writes. This checks the observer used by managed negatives.
|
|
||||||
let listed = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
|
||||||
assert_eq!(listed.versions().len(), 1);
|
|
||||||
assert_eq!(listed.versions()[0].version_id(), Some(version.id.as_str()));
|
|
||||||
assert!(listed.delete_markers().is_empty());
|
|
||||||
assert!(
|
|
||||||
physical_snapshot(dist, bucket)? != before,
|
|
||||||
"marker round-trip hid transient physical writes"
|
|
||||||
);
|
|
||||||
assert_exact_version(&client, bucket, key, &version.id, &version.body).await?;
|
|
||||||
client
|
|
||||||
.delete_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.version_id(&version.id)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert!(
|
|
||||||
client
|
|
||||||
.list_object_versions()
|
|
||||||
.bucket(bucket)
|
|
||||||
.prefix(key)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.versions()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
|
|
||||||
let current = Version::put(dist, bucket, "ordinary-batch-current.bin", "current").await?;
|
|
||||||
let exact = Version::put(dist, bucket, "ordinary-batch-exact.bin", "exact").await?;
|
|
||||||
let response = client
|
|
||||||
.delete_objects()
|
|
||||||
.bucket(bucket)
|
|
||||||
.delete(
|
|
||||||
Delete::builder()
|
|
||||||
.objects(target(¤t.key, None)?)
|
|
||||||
.objects(target(&exact.key, Some(&exact.id))?)
|
|
||||||
.objects(target(&exact.key, Some(&exact.id))?)
|
|
||||||
.build()?,
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert!(response.errors().is_empty());
|
|
||||||
assert_eq!(response.deleted().len(), 3);
|
|
||||||
let listed = client
|
|
||||||
.list_object_versions()
|
|
||||||
.bucket(bucket)
|
|
||||||
.prefix(¤t.key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(listed.versions().len(), 1);
|
|
||||||
assert_eq!(listed.delete_markers().len(), 1);
|
|
||||||
assert!(
|
|
||||||
client
|
|
||||||
.list_object_versions()
|
|
||||||
.bucket(bucket)
|
|
||||||
.prefix(&exact.key)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.versions()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_delete_entrypoints_preserve_versions_and_physical_state() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
|
|
||||||
let (identity, admin) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = identity["bucket"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or("managed receipt omitted bucket")?
|
|
||||||
.to_owned();
|
|
||||||
let prefix = identity["attempt_prefix"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or("managed receipt omitted prefix")?
|
|
||||||
.to_owned();
|
|
||||||
let key = format!("{prefix}native.bin");
|
|
||||||
let mut versions = vec![
|
|
||||||
Version::put(&dist, &bucket, &key, "native-old").await?,
|
|
||||||
Version::put(&dist, &bucket, &key, "native-new").await?,
|
|
||||||
];
|
|
||||||
let ordinary = unique_bucket("delete-control");
|
|
||||||
dist.create_bucket(&ordinary).await?;
|
|
||||||
enable_versioning(&dist.client(0)?, &ordinary).await?;
|
|
||||||
|
|
||||||
// A new managed bucket cannot legitimately create a marker. Model existing
|
|
||||||
// persisted state from before adoption/upgrade using a real S3-generated
|
|
||||||
// ordinary object+marker, copied disk-for-disk in the same erasure set.
|
|
||||||
// Only this object's files move; bucket authority/configuration is untouched.
|
|
||||||
let marker_key = format!("{prefix}existing-marker.bin");
|
|
||||||
versions.push(Version::put(&dist, &ordinary, &marker_key, "existing-data").await?);
|
|
||||||
let output = dist
|
|
||||||
.client(2)?
|
|
||||||
.delete_object()
|
|
||||||
.bucket(&ordinary)
|
|
||||||
.key(&marker_key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(output.delete_marker(), Some(true));
|
|
||||||
let marker = output.version_id().ok_or("ordinary fixture omitted marker ID")?.to_owned();
|
|
||||||
dist.cluster.stop();
|
|
||||||
for node in &dist.cluster.nodes {
|
|
||||||
let disk = Path::new(&node.data_dirs[0]);
|
|
||||||
copy_existing_object(&disk.join(&ordinary).join(&marker_key), &disk.join(&bucket).join(&marker_key))?;
|
|
||||||
}
|
|
||||||
dist.cluster.start().await?;
|
|
||||||
wait_for_ready(&dist.cluster).await?;
|
|
||||||
assert_fixture(&dist, &bucket, &prefix, &versions, (&marker_key, &marker)).await?;
|
|
||||||
ordinary_delete_control(&dist, &ordinary).await?;
|
|
||||||
let batch = vec![
|
|
||||||
target(&key, None)?,
|
|
||||||
target(&key, Some(&versions[0].id))?,
|
|
||||||
target(&key, Some(&versions[1].id))?,
|
|
||||||
target(&marker_key, Some(&marker))?,
|
|
||||||
target(&key, Some(&versions[0].id))?,
|
|
||||||
target(&marker_key, Some(&versions[2].id))?,
|
|
||||||
];
|
|
||||||
for phase in ["open", "sealed", "restarted"] {
|
|
||||||
if phase == "sealed" {
|
|
||||||
confirm_seal(&dist, &admin, &json!({"version": 1, "target": identity}).to_string(), &identity).await?;
|
|
||||||
} else if phase == "restarted" {
|
|
||||||
dist.restart_with_current_binary().await?;
|
|
||||||
}
|
|
||||||
for node in 0..dist.cluster.nodes.len() {
|
|
||||||
assert_single_denied(&dist, node, &bucket, &key, None).await?;
|
|
||||||
assert_single_denied(&dist, node, &bucket, &key, Some(&versions[0].id)).await?;
|
|
||||||
assert_single_denied(&dist, node, &bucket, &marker_key, Some(&marker)).await?;
|
|
||||||
assert_batch_denied(&dist, node, &bucket, &batch).await?;
|
|
||||||
}
|
|
||||||
assert_fixture(&dist, &bucket, &prefix, &versions, (&marker_key, &marker)).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
#![cfg(unix)]
|
|
||||||
|
|
||||||
use super::artifact_delete_test::physical_snapshot;
|
|
||||||
use super::artifact_seal_test::{ArtifactAdmin, create_artifact_attempt};
|
|
||||||
use super::harness::{DistCluster, DistLayout, TestResult, wait_for_ready};
|
|
||||||
use crate::common::{
|
|
||||||
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
|
|
||||||
signed_s3_request,
|
|
||||||
};
|
|
||||||
use futures::{StreamExt as _, TryStreamExt as _};
|
|
||||||
use http::{Method, StatusCode};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::{collections::BTreeSet, time::Duration};
|
|
||||||
use tokio::time::timeout;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/inventory";
|
|
||||||
const BUDGET: Duration = Duration::from_secs(45);
|
|
||||||
|
|
||||||
struct Reader {
|
|
||||||
access: String,
|
|
||||||
secret: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Reader {
|
|
||||||
async fn create(dist: &DistCluster, target: &Value, action: &str) -> TestResult<Self> {
|
|
||||||
let reader = Self {
|
|
||||||
access: format!("inventory{}", &Uuid::new_v4().simple().to_string()[..12]),
|
|
||||||
secret: Uuid::new_v4().simple().to_string(),
|
|
||||||
};
|
|
||||||
let policy_name = format!("inventory{}", &Uuid::new_v4().simple().to_string()[..12]);
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("target bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or("target prefix")?
|
|
||||||
.trim_end_matches('/');
|
|
||||||
let resource = if action == "s3:ListBucket" {
|
|
||||||
format!("arn:aws:s3:::{bucket}")
|
|
||||||
} else {
|
|
||||||
format!("arn:aws:s3:::{bucket}/{prefix}")
|
|
||||||
};
|
|
||||||
let policy =
|
|
||||||
json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":action,"Resource":resource}]}).to_string();
|
|
||||||
let url = &dist.cluster.nodes[0].url;
|
|
||||||
let access = &dist.cluster.access_key;
|
|
||||||
let secret = &dist.cluster.secret_key;
|
|
||||||
admin_create_user_via(AdminTransport::Signed, url, access, secret, &reader.access, &reader.secret).await?;
|
|
||||||
admin_add_canned_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &policy).await?;
|
|
||||||
admin_attach_user_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &reader.access).await?;
|
|
||||||
Ok(reader)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn request(&self, dist: &DistCluster, target: &Value, invocation: Uuid) -> TestResult<reqwest::Response> {
|
|
||||||
timeout(
|
|
||||||
BUDGET,
|
|
||||||
signed_s3_request(
|
|
||||||
Method::POST,
|
|
||||||
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
|
|
||||||
Some(json!({"version":1,"invocation_id":invocation,"target":target}).to_string()),
|
|
||||||
Some("application/json"),
|
|
||||||
&self.access,
|
|
||||||
&self.secret,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct Frame {
|
|
||||||
version: u32,
|
|
||||||
invocation_id: Uuid,
|
|
||||||
target: Value,
|
|
||||||
sequence: u64,
|
|
||||||
payload: Payload,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
|
||||||
enum Payload {
|
|
||||||
Observation {
|
|
||||||
disk_id: Uuid,
|
|
||||||
key: String,
|
|
||||||
version_id: Uuid,
|
|
||||||
delete_marker: bool,
|
|
||||||
},
|
|
||||||
Complete {
|
|
||||||
members: usize,
|
|
||||||
observations: u64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_frames(mut response: reqwest::Response) -> TestResult<(Vec<Frame>, bool)> {
|
|
||||||
let mut pending = Vec::new();
|
|
||||||
let mut frames = Vec::new();
|
|
||||||
let mut clean = true;
|
|
||||||
loop {
|
|
||||||
match timeout(BUDGET, response.chunk()).await? {
|
|
||||||
Ok(Some(chunk)) => {
|
|
||||||
pending.extend_from_slice(&chunk);
|
|
||||||
while let Some(end) = pending.iter().position(|byte| *byte == b'\n') {
|
|
||||||
assert!(end < 1024 * 1024, "HTTP inventory line exceeds encoded bound");
|
|
||||||
let line: Vec<_> = pending.drain(..=end).collect();
|
|
||||||
frames.push(serde_json::from_slice(&line[..end])?);
|
|
||||||
}
|
|
||||||
assert!(pending.len() < 1024 * 1024, "unbounded incomplete HTTP line");
|
|
||||||
}
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(_) => {
|
|
||||||
clean = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok((frames, clean && pending.is_empty()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seal(dist: &DistCluster, user: &ArtifactAdmin, target: &Value) -> TestResult {
|
|
||||||
let (status, body) = user
|
|
||||||
.request(
|
|
||||||
dist,
|
|
||||||
0,
|
|
||||||
"/rustfs/admin/v3/plugin-artifacts/seal-writes",
|
|
||||||
json!({"version":1,"target":target}).to_string(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(status, StatusCode::OK, "actual all-member closure: {body}");
|
|
||||||
assert_eq!(serde_json::from_str::<Value>(&body)?["all_writes_sealed"], true);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exact_inventory(frames: &[Frame], target: &Value, invocation: Uuid, versions: &BTreeSet<(String, Uuid, bool)>) {
|
|
||||||
let mut observed = BTreeSet::new();
|
|
||||||
let mut disks = BTreeSet::new();
|
|
||||||
let mut count = 0u64;
|
|
||||||
for (sequence, frame) in frames.iter().enumerate() {
|
|
||||||
assert_eq!(frame.version, 1);
|
|
||||||
assert_eq!(frame.invocation_id, invocation);
|
|
||||||
assert_eq!(&frame.target, target);
|
|
||||||
assert_eq!(frame.sequence, u64::try_from(sequence).expect("bounded fixture sequence"));
|
|
||||||
match &frame.payload {
|
|
||||||
Payload::Observation {
|
|
||||||
disk_id,
|
|
||||||
key,
|
|
||||||
version_id,
|
|
||||||
delete_marker,
|
|
||||||
} => {
|
|
||||||
assert!(!disk_id.is_nil() && !version_id.is_nil());
|
|
||||||
assert!(
|
|
||||||
observed.insert((*disk_id, key.clone(), *version_id, *delete_marker)),
|
|
||||||
"duplicate physical observation"
|
|
||||||
);
|
|
||||||
disks.insert(*disk_id);
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
Payload::Complete { members, observations } => {
|
|
||||||
assert_eq!(sequence + 1, frames.len(), "Complete must be the last HTTP frame");
|
|
||||||
assert_eq!(*members, 4);
|
|
||||||
assert_eq!(*observations, count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
matches!(frames.last().map(|frame| &frame.payload), Some(Payload::Complete { .. })),
|
|
||||||
"A03I3A_HTTP_COMPLETE"
|
|
||||||
);
|
|
||||||
if !versions.is_empty() {
|
|
||||||
assert_eq!(disks.len(), 4);
|
|
||||||
}
|
|
||||||
for disk in disks {
|
|
||||||
let actual: BTreeSet<_> = observed
|
|
||||||
.iter()
|
|
||||||
.filter(|(id, ..)| *id == disk)
|
|
||||||
.map(|(_, key, version, marker)| (key.clone(), *version, *marker))
|
|
||||||
.collect();
|
|
||||||
assert_eq!(&actual, versions, "A03I3A_EXACT_VERSIONS");
|
|
||||||
}
|
|
||||||
assert_eq!(observed.len(), versions.len() * 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn artifact_inventory_http_exact_versions() -> TestResult {
|
|
||||||
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
|
|
||||||
init_logging();
|
|
||||||
let dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
|
|
||||||
let (target, admin) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"].as_str().ok_or("prefix")?;
|
|
||||||
let mut versions = BTreeSet::new();
|
|
||||||
for (name, body) in [
|
|
||||||
("same", b"first".to_vec()),
|
|
||||||
("same", b"second".to_vec()),
|
|
||||||
("zero", Vec::new()),
|
|
||||||
] {
|
|
||||||
let key = format!("{prefix}{name}");
|
|
||||||
let output = timeout(BUDGET, dist.client(1)?.put_object().bucket(bucket).key(&key).body(body.into()).send()).await??;
|
|
||||||
let version = Uuid::parse_str(output.version_id().ok_or("actual PUT version")?)?;
|
|
||||||
versions.insert((key, version, false));
|
|
||||||
}
|
|
||||||
seal(&dist, &admin, &target).await?;
|
|
||||||
// A wildcard action is still restricted to this exact attempt resource.
|
|
||||||
// The separate authority case exercises the dedicated action alone.
|
|
||||||
let reader = Reader::create(&dist, &target, "admin:*").await?;
|
|
||||||
let before = physical_snapshot(&dist, bucket)?;
|
|
||||||
let invocation = Uuid::new_v4();
|
|
||||||
let response = reader.request(&dist, &target, invocation).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK, "A03I3A_HTTP_ROUTE");
|
|
||||||
assert_eq!(
|
|
||||||
response.headers().get("content-type").and_then(|value| value.to_str().ok()),
|
|
||||||
Some("application/x-ndjson")
|
|
||||||
);
|
|
||||||
let (frames, clean) = read_frames(response).await?;
|
|
||||||
assert!(clean, "Complete requires clean HTTP EOF");
|
|
||||||
assert!(frames.len() > 2, "real multi-frame HTTP body");
|
|
||||||
exact_inventory(&frames, &target, invocation, &versions);
|
|
||||||
assert!(
|
|
||||||
before == physical_snapshot(&dist, bucket)?,
|
|
||||||
"inventory mutated physical artifact namespace"
|
|
||||||
);
|
|
||||||
let (empty, empty_admin) = create_artifact_attempt(&dist).await?;
|
|
||||||
seal(&dist, &empty_admin, &empty).await?;
|
|
||||||
let empty_reader = Reader::create(&dist, &empty, "admin:*").await?;
|
|
||||||
let invocation = Uuid::new_v4();
|
|
||||||
let response = empty_reader.request(&dist, &empty, invocation).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let (frames, clean) = read_frames(response).await?;
|
|
||||||
assert!(clean);
|
|
||||||
exact_inventory(&frames, &empty, invocation, &BTreeSet::new());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn artifact_inventory_http_scoped_authority() -> TestResult {
|
|
||||||
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
|
|
||||||
init_logging();
|
|
||||||
let dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
|
|
||||||
let (target, admin) = create_artifact_attempt(&dist).await?;
|
|
||||||
seal(&dist, &admin, &target).await?;
|
|
||||||
let reader = Reader::create(&dist, &target, "admin:InventoryPluginArtifactAttempt").await?;
|
|
||||||
let invocation = Uuid::new_v4();
|
|
||||||
let response = reader.request(&dist, &target, invocation).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK, "dedicated inventory authority");
|
|
||||||
let (frames, clean) = read_frames(response).await?;
|
|
||||||
assert!(clean);
|
|
||||||
exact_inventory(&frames, &target, invocation, &BTreeSet::new());
|
|
||||||
let mut other_prefix = target.clone();
|
|
||||||
other_prefix["attempt_prefix"] = json!(format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4()));
|
|
||||||
let mut other_bucket = target.clone();
|
|
||||||
other_bucket["bucket"] = json!("another-artifact-bucket");
|
|
||||||
for denied in [&other_prefix, &other_bucket] {
|
|
||||||
assert_eq!(
|
|
||||||
reader.request(&dist, denied, Uuid::new_v4()).await?.status(),
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"A03I3A_SCOPED_AUTHORITY"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let (status, _) = admin
|
|
||||||
.request(
|
|
||||||
&dist,
|
|
||||||
0,
|
|
||||||
ROUTE,
|
|
||||||
json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target}).to_string(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(status, StatusCode::FORBIDDEN, "A03I3A_SCOPED_AUTHORITY seal-only user");
|
|
||||||
let listed = Reader::create(&dist, &target, "s3:ListBucket").await?;
|
|
||||||
dist.client_with_credentials(0, &listed.access, &listed.secret)?
|
|
||||||
.list_objects_v2()
|
|
||||||
.bucket(target["bucket"].as_str().ok_or("bucket")?)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
listed.request(&dist, &target, Uuid::new_v4()).await?.status(),
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"A03I3A_SCOPED_AUTHORITY S3 list-only user"
|
|
||||||
);
|
|
||||||
let root_response = signed_s3_request(
|
|
||||||
Method::POST,
|
|
||||||
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
|
|
||||||
Some(json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target}).to_string()),
|
|
||||||
Some("application/json"),
|
|
||||||
&dist.cluster.access_key,
|
|
||||||
&dist.cluster.secret_key,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(root_response.status(), StatusCode::FORBIDDEN, "original scoped user required");
|
|
||||||
for body in [
|
|
||||||
json!({"version":1,"invocation_id":Uuid::nil(),"target":target}),
|
|
||||||
json!({"version":1,"invocation_id":Uuid::new_v4(),"target":target,"extra":true}),
|
|
||||||
] {
|
|
||||||
let response = signed_s3_request(
|
|
||||||
Method::POST,
|
|
||||||
&format!("{}{ROUTE}", dist.cluster.nodes[0].url),
|
|
||||||
Some(body.to_string()),
|
|
||||||
Some("application/json"),
|
|
||||||
&reader.access,
|
|
||||||
&reader.secret,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "strict inventory request");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_incomplete(response: reqwest::Response) -> TestResult<Vec<Frame>> {
|
|
||||||
assert_eq!(
|
|
||||||
response.status(),
|
|
||||||
StatusCode::OK,
|
|
||||||
"valid request must exercise the streaming failure boundary"
|
|
||||||
);
|
|
||||||
let (frames, _) = read_frames(response).await?;
|
|
||||||
assert!(
|
|
||||||
!frames.iter().any(|frame| matches!(frame.payload, Payload::Complete { .. })),
|
|
||||||
"A03I3A_NO_FALSE_COMPLETE"
|
|
||||||
);
|
|
||||||
Ok(frames)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn artifact_inventory_http_incomplete_has_no_terminal() -> TestResult {
|
|
||||||
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
|
|
||||||
init_logging();
|
|
||||||
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
|
|
||||||
let (target, admin) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"].as_str().ok_or("prefix")?;
|
|
||||||
// Real PUTs produce more data than a stalled HTTP/2 flow-control window.
|
|
||||||
futures::stream::iter(0..256)
|
|
||||||
.map(|index| {
|
|
||||||
let client = dist.client(1);
|
|
||||||
let key = format!("{prefix}slow/{index:04}/{}/{}", "a".repeat(190), "b".repeat(190));
|
|
||||||
async move {
|
|
||||||
let output = client?
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.body(vec![1].into())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert!(!Uuid::parse_str(output.version_id().ok_or("actual PUT version")?)?.is_nil());
|
|
||||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.buffer_unordered(8)
|
|
||||||
.try_collect::<Vec<_>>()
|
|
||||||
.await?;
|
|
||||||
seal(&dist, &admin, &target).await?;
|
|
||||||
let reader = Reader::create(&dist, &target, "admin:InventoryPluginArtifactAttempt").await?;
|
|
||||||
dist.cluster.stop_node(3)?;
|
|
||||||
assert_incomplete(reader.request(&dist, &target, Uuid::new_v4()).await?).await?;
|
|
||||||
dist.cluster.start_node(3).await?;
|
|
||||||
wait_for_ready(&dist.cluster).await?;
|
|
||||||
let mut wrong = target.clone();
|
|
||||||
wrong["bucket_incarnation"] = json!(Uuid::new_v4());
|
|
||||||
assert_incomplete(reader.request(&dist, &wrong, Uuid::new_v4()).await?).await?;
|
|
||||||
// The configured endpoint map is ordered. Damage its final receiver, so
|
|
||||||
// earlier real observations are emitted before the physical scan fails.
|
|
||||||
let node = dist
|
|
||||||
.cluster
|
|
||||||
.nodes
|
|
||||||
.iter()
|
|
||||||
.max_by_key(|node| format!("{}{}", node.url, node.data_dirs[0]))
|
|
||||||
.ok_or("configured members")?;
|
|
||||||
let metadata = std::path::Path::new(&node.data_dirs[0])
|
|
||||||
.join(bucket)
|
|
||||||
.join(format!("{prefix}slow/0000/{}/{}", "a".repeat(190), "b".repeat(190)))
|
|
||||||
.join("xl.meta");
|
|
||||||
let original = std::fs::read(&metadata)?;
|
|
||||||
std::fs::write(&metadata, b"corrupt inventory metadata")?;
|
|
||||||
let before = physical_snapshot(&dist, bucket)?;
|
|
||||||
let outcome = assert_incomplete(reader.request(&dist, &target, Uuid::new_v4()).await?).await;
|
|
||||||
let after = physical_snapshot(&dist, bucket)?;
|
|
||||||
std::fs::write(&metadata, original)?;
|
|
||||||
let frames = outcome?;
|
|
||||||
assert!(before == after, "failed inventory repaired or mutated a physical object");
|
|
||||||
assert!(
|
|
||||||
frames
|
|
||||||
.iter()
|
|
||||||
.any(|frame| matches!(frame.payload, Payload::Observation { .. })),
|
|
||||||
"late physical failure must follow real HTTP observations"
|
|
||||||
);
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.no_proxy()
|
|
||||||
.http2_prior_knowledge()
|
|
||||||
.http2_initial_stream_window_size(1)
|
|
||||||
.http2_initial_connection_window_size(1)
|
|
||||||
.http2_adaptive_window(false)
|
|
||||||
.build()?;
|
|
||||||
let url = format!("{}{ROUTE}", dist.cluster.nodes[0].url);
|
|
||||||
let request = |invocation| {
|
|
||||||
json!({"version":1,"invocation_id":invocation,"target":target})
|
|
||||||
.to_string()
|
|
||||||
.into_bytes()
|
|
||||||
};
|
|
||||||
let response = crate::common::signed_request_with_client(
|
|
||||||
&client,
|
|
||||||
Method::POST,
|
|
||||||
&url,
|
|
||||||
&reader.access,
|
|
||||||
&reader.secret,
|
|
||||||
Some(request(Uuid::new_v4())),
|
|
||||||
Some("application/json"),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
// Do not poll the body: its one-byte HTTP/2 receive window stalls transport.
|
|
||||||
// This waits beyond the fixed30s service budget, not for readiness.
|
|
||||||
tokio::time::sleep(Duration::from_secs(35)).await;
|
|
||||||
assert_incomplete(response).await?;
|
|
||||||
let response = crate::common::signed_request_with_client(
|
|
||||||
&client,
|
|
||||||
Method::POST,
|
|
||||||
&url,
|
|
||||||
&reader.access,
|
|
||||||
&reader.secret,
|
|
||||||
Some(request(Uuid::new_v4())),
|
|
||||||
Some("application/json"),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
drop(response);
|
|
||||||
let response = timeout(Duration::from_secs(10), reader.request(&dist, &target, Uuid::new_v4())).await??;
|
|
||||||
let (frames, clean) = timeout(Duration::from_secs(10), read_frames(response)).await??;
|
|
||||||
assert!(
|
|
||||||
clean && matches!(frames.last().map(|frame| &frame.payload), Some(Payload::Complete { .. })),
|
|
||||||
"fresh scan must succeed promptly after an actual disconnected HTTP body"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
use super::artifact_repair_tail_test::PhysicalBarrier;
|
|
||||||
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
|
|
||||||
use super::harness::{DistCluster, DistLayout, TestResult, payload_for, wait_until};
|
|
||||||
use crate::chaos::census_object_version_on_disk;
|
|
||||||
use crate::common::init_logging;
|
|
||||||
use crate::storage_api::artifact_rpc::{
|
|
||||||
DiskError, TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest,
|
|
||||||
};
|
|
||||||
use http::StatusCode;
|
|
||||||
use rustfs_filemeta::FileMeta;
|
|
||||||
use rustfs_protos::proto_gen::node_service::{RenameDataRequest, node_service_client::NodeServiceClient};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::time::timeout;
|
|
||||||
use tonic::Request;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const PUT_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-attempt";
|
|
||||||
|
|
||||||
fn signed_put_request(body: RenameDataRequest, audience: &str) -> TestResult<Request<RenameDataRequest>> {
|
|
||||||
let canonical = rustfs_protos::canonical_artifact_rename_data_request_body(&body)?;
|
|
||||||
let mut request = Request::new(body);
|
|
||||||
request.set_timeout(Duration::from_secs(15));
|
|
||||||
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
|
||||||
let digest = request
|
|
||||||
.metadata()
|
|
||||||
.get("x-rustfs-content-sha256")
|
|
||||||
.ok_or("missing canonical digest")?
|
|
||||||
.to_str()?
|
|
||||||
.to_owned();
|
|
||||||
request.metadata_mut().as_mut().extend(gen_tonic_signature_headers(
|
|
||||||
audience,
|
|
||||||
TONIC_RPC_PREFIX.trim_start_matches('/'),
|
|
||||||
"ArtifactRenameData",
|
|
||||||
Some(&digest),
|
|
||||||
)?);
|
|
||||||
Ok(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn publication_snapshot(paths: &[&Path]) -> TestResult<BTreeMap<PathBuf, Option<Vec<u8>>>> {
|
|
||||||
let mut snapshot = BTreeMap::new();
|
|
||||||
for root in paths {
|
|
||||||
for entry in walkdir::WalkDir::new(root) {
|
|
||||||
let entry = entry?;
|
|
||||||
let contents = if entry.file_type().is_dir() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(std::fs::read(entry.path())?)
|
|
||||||
};
|
|
||||||
snapshot.insert(entry.path().to_path_buf(), contents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_raw_put_closed(body: &RenameDataRequest, url: &str, audience: &str, paths: &[&Path]) -> TestResult {
|
|
||||||
let before = publication_snapshot(paths)?;
|
|
||||||
// Re-sign the exact original request; replay protection is not the rejection.
|
|
||||||
let request = signed_put_request(body.clone(), audience)?;
|
|
||||||
let response = timeout(Duration::from_secs(20), async {
|
|
||||||
let mut client = NodeServiceClient::connect(url.to_owned()).await?;
|
|
||||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(client.artifact_rename_data(request).await?.into_inner())
|
|
||||||
})
|
|
||||||
.await??;
|
|
||||||
assert!(!response.success, "sealed attempt admitted another ordinary PUT");
|
|
||||||
let error = response.error.ok_or("rejected raw PUT omitted error")?;
|
|
||||||
assert_eq!(error.code, DiskError::Io(std::io::ErrorKind::PermissionDenied.into()).to_u32());
|
|
||||||
assert_eq!(error.error_info, "io error artifact attempt mutation domain is closed");
|
|
||||||
assert_eq!(publication_snapshot(paths)?, before, "rejected raw PUT mutated staged or published files");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_put_seal_waits_for_disconnected_physical_tail() -> TestResult {
|
|
||||||
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
|
|
||||||
init_logging();
|
|
||||||
let _ = rustfs_credentials::set_global_rpc_secret("rustfs-internode-signature-e2e-secret".to_owned());
|
|
||||||
let rpc_secret = rustfs_credentials::try_get_rpc_token()?;
|
|
||||||
let dist = DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_RPC_SECRET", &rpc_secret)]).await?;
|
|
||||||
let (target, user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
|
|
||||||
let key = format!("{prefix}detached-put.bin");
|
|
||||||
let payload = payload_for(&key, 2 * 1024 * 1024);
|
|
||||||
let output = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
dist.client(1)?
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(&key)
|
|
||||||
.body(payload.clone().into())
|
|
||||||
.send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
let version = output.version_id().ok_or("managed PUT omitted version ID")?;
|
|
||||||
let request_body = json!({"version": 1, "target": target}).to_string();
|
|
||||||
let baselines = dist
|
|
||||||
.cluster
|
|
||||||
.nodes
|
|
||||||
.iter()
|
|
||||||
.map(|node| census_object_version_on_disk(Path::new(&node.data_dirs[0]), bucket, &key, Some(version)))
|
|
||||||
.collect::<TestResult<Vec<_>>>()?;
|
|
||||||
assert!(
|
|
||||||
baselines
|
|
||||||
.iter()
|
|
||||||
.all(|baseline| baseline.is_complete() && !baseline.expected_part_numbers.is_empty())
|
|
||||||
);
|
|
||||||
|
|
||||||
let node = &dist.cluster.nodes[3];
|
|
||||||
let disk = PathBuf::from(&node.data_dirs[0]).canonicalize()?;
|
|
||||||
let object = disk.join(bucket).join(&key);
|
|
||||||
let metadata_bytes = std::fs::read(object.join("xl.meta"))?;
|
|
||||||
let fi = FileMeta::load(&metadata_bytes)?.into_fileinfo(bucket, &key, version, true, false, true)?;
|
|
||||||
let admitted_file_info = serde_json::to_value(&fi)?;
|
|
||||||
assert!(!fi.is_healing(), "ordinary publication must not carry the repair marker");
|
|
||||||
let data_dir = fi
|
|
||||||
.data_dir
|
|
||||||
.ok_or("physical-tail fixture requires external shards")?
|
|
||||||
.to_string();
|
|
||||||
let source = Uuid::new_v4().to_string();
|
|
||||||
let staging = disk.join(".rustfs.sys/tmp").join(&source);
|
|
||||||
std::fs::create_dir_all(&staging)?;
|
|
||||||
let destination = object.join(&data_dir);
|
|
||||||
// Replay the receiver publication of a real ordinary PUT while its attempt
|
|
||||||
// remains open. Preserve its referenced version metadata. The admitted
|
|
||||||
// owned operation may still finish metadata publication after RPC detach.
|
|
||||||
std::fs::rename(&destination, staging.join(&data_dir))?;
|
|
||||||
let barrier = PhysicalBarrier::arm(&disk, &destination)?;
|
|
||||||
let format: Value = serde_json::from_slice(&std::fs::read(disk.join(".rustfs.sys/format.json"))?)?;
|
|
||||||
let disk_id = Uuid::parse_str(format["xl"]["this"].as_str().ok_or("disk format omitted xl.this")?)?;
|
|
||||||
let incarnation = Uuid::parse_str(target["bucket_incarnation"].as_str().ok_or("target omitted incarnation")?)?;
|
|
||||||
let put = RenameDataRequest {
|
|
||||||
disk: disk_id.to_string(),
|
|
||||||
src_volume: ".rustfs.sys/tmp".to_owned(),
|
|
||||||
src_path: source,
|
|
||||||
dst_volume: bucket.to_owned(),
|
|
||||||
dst_path: key.clone(),
|
|
||||||
file_info: serde_json::to_string(&fi)?,
|
|
||||||
file_info_bin: Default::default(),
|
|
||||||
scanner_publication_lease_token: Default::default(),
|
|
||||||
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
|
|
||||||
};
|
|
||||||
let request = signed_put_request(put.clone(), &node.address)?;
|
|
||||||
// Direct RPC takes no coordinator attempt lock and owns its own connection.
|
|
||||||
let url = node.url.clone();
|
|
||||||
let mut rpc = tokio::spawn(async move {
|
|
||||||
timeout(Duration::from_secs(30), async move {
|
|
||||||
let mut client = NodeServiceClient::connect(url).await?;
|
|
||||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(client.artifact_rename_data(request).await?)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
tokio::select! {
|
|
||||||
result = &mut rpc => {
|
|
||||||
let response = result???.into_inner();
|
|
||||||
panic!(
|
|
||||||
"ordinary PUT receiver completed before the physical barrier: success={}, error={:?}",
|
|
||||||
response.success, response.error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
arrived = barrier.observed("arrived") => arrived?,
|
|
||||||
}
|
|
||||||
assert!(!destination.exists(), "physical PUT escaped its barrier");
|
|
||||||
rpc.abort();
|
|
||||||
assert!(rpc.await.expect_err("disconnect the ordinary PUT waiter").is_cancelled());
|
|
||||||
// The ordinary PUT wrapper retains an owned task. Observe its RPC-facing
|
|
||||||
// waiter dropping, rather than cancellation of the continuing disk task.
|
|
||||||
barrier.observed("rpc-waiter-dropped").await?;
|
|
||||||
|
|
||||||
let seal = user.request(&dist, 0, PUT_SEAL_ROUTE, request_body.clone());
|
|
||||||
tokio::pin!(seal);
|
|
||||||
let closed = wait_until(
|
|
||||||
Duration::from_secs(15),
|
|
||||||
|| async {
|
|
||||||
Ok(walkdir::WalkDir::new(disk.join(".rustfs-artifact-put-seals"))
|
|
||||||
.min_depth(2)
|
|
||||||
.max_depth(2)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.any(|entry| entry.file_name() == "closed"))
|
|
||||||
},
|
|
||||||
"PUT receiver persisted closing before physical drain",
|
|
||||||
);
|
|
||||||
tokio::pin!(closed);
|
|
||||||
tokio::select! {
|
|
||||||
result = &mut seal => {
|
|
||||||
let (status, response) = result?;
|
|
||||||
panic!("PUT seal returned before its blocked receiver closed admission: {status} {response}");
|
|
||||||
}
|
|
||||||
result = &mut closed => result?,
|
|
||||||
}
|
|
||||||
let (status, response) = timeout(Duration::from_secs(40), &mut seal).await??;
|
|
||||||
assert_eq!(
|
|
||||||
status,
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"PUT seal confirmed an unfinished physical tail: {response}"
|
|
||||||
);
|
|
||||||
assert!(response.contains("artifact operation unconfirmed"));
|
|
||||||
assert!(!destination.exists());
|
|
||||||
assert_raw_put_closed(&put, &node.url, &node.address, &[&staging, &object]).await?;
|
|
||||||
assert!(!destination.exists(), "a second admission published while the worker was paused");
|
|
||||||
|
|
||||||
barrier.release()?;
|
|
||||||
wait_until(
|
|
||||||
Duration::from_secs(15),
|
|
||||||
|| async { Ok(destination.join("part.1").is_file()) },
|
|
||||||
"detached ordinary PUT worker finished",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
confirm_seal(&dist, &user, &request_body, &target).await?;
|
|
||||||
// Seal confirmation is the boundary: already admitted work may re-encode
|
|
||||||
// metadata before this point, but the complete FileInfo must be unchanged.
|
|
||||||
let published_objects: Vec<_> = dist
|
|
||||||
.cluster
|
|
||||||
.nodes
|
|
||||||
.iter()
|
|
||||||
.map(|node| PathBuf::from(&node.data_dirs[0]).join(bucket).join(&key))
|
|
||||||
.collect();
|
|
||||||
let published_paths: Vec<_> = published_objects.iter().map(PathBuf::as_path).collect();
|
|
||||||
let sealed_snapshot = publication_snapshot(&published_paths)?;
|
|
||||||
let sealed_file_info =
|
|
||||||
FileMeta::load(&std::fs::read(object.join("xl.meta"))?)?.into_fileinfo(bucket, &key, version, true, false, true)?;
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(sealed_file_info)?,
|
|
||||||
admitted_file_info,
|
|
||||||
"admitted PUT changed FileInfo semantics"
|
|
||||||
);
|
|
||||||
for (index, baseline) in baselines.iter().enumerate() {
|
|
||||||
assert!(
|
|
||||||
census_object_version_on_disk(Path::new(&dist.cluster.nodes[index].data_dirs[0]), bucket, &key, Some(version))?
|
|
||||||
.matches_manifest(baseline)
|
|
||||||
);
|
|
||||||
assert_exact_version(&dist.client(index)?, bucket, &key, version, &payload).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage the exact request again. A source metadata sentinel makes a missing
|
|
||||||
// admission check observable even if the existing destination later causes
|
|
||||||
// ENOTEMPTY: ordinary rename rewrites source metadata before that syscall.
|
|
||||||
let staged_data = staging.join(&data_dir);
|
|
||||||
std::fs::create_dir_all(&staged_data)?;
|
|
||||||
for entry in std::fs::read_dir(&destination)? {
|
|
||||||
let entry = entry?;
|
|
||||||
assert!(entry.file_type()?.is_file(), "unexpected nested shard fixture");
|
|
||||||
std::fs::copy(entry.path(), staged_data.join(entry.file_name()))?;
|
|
||||||
}
|
|
||||||
std::fs::write(staging.join("xl.meta"), b"closed-admission-must-preserve-this-staged-metadata")?;
|
|
||||||
assert_raw_put_closed(&put, &node.url, &node.address, &[&staging, &object]).await?;
|
|
||||||
let listed = dist
|
|
||||||
.client(0)?
|
|
||||||
.list_object_versions()
|
|
||||||
.bucket(bucket)
|
|
||||||
.prefix(&key)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(listed.versions().len(), 1, "raw PUT retries created another version");
|
|
||||||
assert_eq!(listed.versions()[0].version_id(), Some(version));
|
|
||||||
assert!(listed.delete_markers().is_empty());
|
|
||||||
confirm_seal(&dist, &user, &request_body, &target).await?;
|
|
||||||
assert!(
|
|
||||||
publication_snapshot(&published_paths)? == sealed_snapshot,
|
|
||||||
"published files changed after the first successful physical-drain confirmation"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
use super::artifact_seal_test::{assert_exact_version, confirm_seal, create_artifact_attempt};
|
|
||||||
use super::harness::{DistCluster, DistLayout, TestResult, payload_for, wait_until};
|
|
||||||
use crate::chaos::census_object_version_on_disk;
|
|
||||||
use crate::common::init_logging;
|
|
||||||
use crate::storage_api::artifact_rpc::{TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest};
|
|
||||||
use http::StatusCode;
|
|
||||||
use rustfs_filemeta::FileMeta;
|
|
||||||
use rustfs_protos::proto_gen::node_service::{
|
|
||||||
ArtifactRepairRenameDataRequest, RenameDataRequest, node_service_client::NodeServiceClient,
|
|
||||||
};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::time::timeout;
|
|
||||||
use tonic::Request;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const REPAIR_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-repairs";
|
|
||||||
|
|
||||||
pub(super) struct PhysicalBarrier(PathBuf);
|
|
||||||
|
|
||||||
impl PhysicalBarrier {
|
|
||||||
pub(super) fn arm(disk: &Path, destination: &Path) -> TestResult<Self> {
|
|
||||||
let path = disk.join(".rustfs.sys/e2e-physical-rename-barrier");
|
|
||||||
std::fs::write(&path, destination.to_str().ok_or("non-UTF8 fixture destination")?)?;
|
|
||||||
Ok(Self(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn observed(&self, stage: &str) -> TestResult {
|
|
||||||
wait_until(Duration::from_secs(15), || async { Ok(self.0.with_extension(stage).is_file()) }, stage).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn release(&self) -> TestResult {
|
|
||||||
std::fs::write(self.0.with_extension("release"), b"release physical worker")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for PhysicalBarrier {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// Release even on assertion failure; the worker also has its own deadline.
|
|
||||||
let _ = self.release();
|
|
||||||
let _ = std::fs::remove_file(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_repair_seal_waits_for_disconnected_physical_tail() -> TestResult {
|
|
||||||
crate::common::rustfs_binary_path_with_features(Some("e2e-test-hooks"));
|
|
||||||
init_logging();
|
|
||||||
// Match the other raw internode signature tests sharing the OnceLock.
|
|
||||||
let _ = rustfs_credentials::set_global_rpc_secret("rustfs-internode-signature-e2e-secret".to_owned());
|
|
||||||
let rpc_secret = rustfs_credentials::try_get_rpc_token()?;
|
|
||||||
let dist = DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_RPC_SECRET", &rpc_secret)]).await?;
|
|
||||||
let (target, user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
|
|
||||||
let key = format!("{prefix}detached-rename.bin");
|
|
||||||
let body = payload_for(&key, 2 * 1024 * 1024);
|
|
||||||
let output = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
dist.client(1)?
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(&key)
|
|
||||||
.body(body.clone().into())
|
|
||||||
.send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
let version = output.version_id().ok_or("managed PUT omitted version ID")?;
|
|
||||||
let request_body = json!({"version": 1, "target": target}).to_string();
|
|
||||||
confirm_seal(&dist, &user, &request_body, &target).await?;
|
|
||||||
|
|
||||||
let node = &dist.cluster.nodes[3];
|
|
||||||
let disk = PathBuf::from(&node.data_dirs[0]).canonicalize()?;
|
|
||||||
let object = disk.join(bucket).join(&key);
|
|
||||||
let metadata = FileMeta::load(&std::fs::read(object.join("xl.meta"))?)?;
|
|
||||||
let mut fi = metadata.into_fileinfo(bucket, &key, version, true, false, true)?;
|
|
||||||
fi.set_healing();
|
|
||||||
let data_dir = fi
|
|
||||||
.data_dir
|
|
||||||
.ok_or("physical-tail fixture requires external shard files")?
|
|
||||||
.to_string();
|
|
||||||
let baseline = census_object_version_on_disk(&disk, bucket, &key, Some(version))?;
|
|
||||||
assert!(baseline.is_complete() && !baseline.expected_part_numbers.is_empty());
|
|
||||||
let source = Uuid::new_v4().to_string();
|
|
||||||
let staging = disk.join(".rustfs.sys/tmp").join(&source);
|
|
||||||
std::fs::create_dir_all(&staging)?;
|
|
||||||
let destination = object.join(&data_dir);
|
|
||||||
std::fs::rename(&destination, staging.join(&data_dir))?;
|
|
||||||
let barrier = PhysicalBarrier::arm(&disk, &destination)?;
|
|
||||||
let format: Value = serde_json::from_slice(&std::fs::read(disk.join(".rustfs.sys/format.json"))?)?;
|
|
||||||
let disk_id = Uuid::parse_str(format["xl"]["this"].as_str().ok_or("disk format omitted xl.this")?)?;
|
|
||||||
let incarnation = Uuid::parse_str(target["bucket_incarnation"].as_str().ok_or("target omitted incarnation")?)?;
|
|
||||||
let repair = ArtifactRepairRenameDataRequest {
|
|
||||||
rename: Some(RenameDataRequest {
|
|
||||||
disk: disk_id.to_string(),
|
|
||||||
src_volume: ".rustfs.sys/tmp".to_owned(),
|
|
||||||
src_path: source,
|
|
||||||
dst_volume: bucket.to_owned(),
|
|
||||||
dst_path: key.clone(),
|
|
||||||
file_info: serde_json::to_string(&fi)?,
|
|
||||||
file_info_bin: Default::default(),
|
|
||||||
scanner_publication_lease_token: Default::default(),
|
|
||||||
bucket_incarnation_id: incarnation.as_bytes().to_vec().into(),
|
|
||||||
}),
|
|
||||||
expected_disk_id: disk_id.as_bytes().to_vec().into(),
|
|
||||||
};
|
|
||||||
let canonical = rustfs_protos::canonical_artifact_repair_rename_data_request_body(&repair)?;
|
|
||||||
let mut request = Request::new(repair);
|
|
||||||
request.set_timeout(Duration::from_secs(15));
|
|
||||||
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
|
||||||
let digest = request
|
|
||||||
.metadata()
|
|
||||||
.get("x-rustfs-content-sha256")
|
|
||||||
.ok_or("missing canonical digest")?
|
|
||||||
.to_str()?
|
|
||||||
.to_owned();
|
|
||||||
let headers = gen_tonic_signature_headers(
|
|
||||||
&node.address,
|
|
||||||
TONIC_RPC_PREFIX.trim_start_matches('/'),
|
|
||||||
"ArtifactRepairRenameData",
|
|
||||||
Some(&digest),
|
|
||||||
)?;
|
|
||||||
request.metadata_mut().as_mut().extend(headers);
|
|
||||||
// An independent channel and direct receiver RPC take no coordinator attempt
|
|
||||||
// lock. Dropping this task closes the client stream while rename is paused.
|
|
||||||
let url = node.url.clone();
|
|
||||||
let mut rpc = tokio::spawn(async move {
|
|
||||||
timeout(Duration::from_secs(30), async move {
|
|
||||||
let mut client = NodeServiceClient::connect(url).await?;
|
|
||||||
client
|
|
||||||
.artifact_repair_rename_data(request)
|
|
||||||
.await
|
|
||||||
.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
tokio::select! {
|
|
||||||
result = &mut rpc => {
|
|
||||||
let response = result???.into_inner();
|
|
||||||
panic!(
|
|
||||||
"repair receiver completed before the physical barrier: success={}, error={:?}",
|
|
||||||
response.success, response.error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
arrived = barrier.observed("arrived") => arrived?,
|
|
||||||
}
|
|
||||||
assert!(!destination.exists(), "physical publication ran before its barrier");
|
|
||||||
rpc.abort();
|
|
||||||
assert!(rpc.await.expect_err("cancel the disconnected RPC waiter").is_cancelled());
|
|
||||||
// This acknowledgement is emitted by the receiver's dropped async waiter,
|
|
||||||
// not by the client and not by a delayed response.
|
|
||||||
barrier.observed("waiter-dropped").await?;
|
|
||||||
|
|
||||||
let seal = user.request(&dist, 0, REPAIR_SEAL_ROUTE, request_body.clone());
|
|
||||||
tokio::pin!(seal);
|
|
||||||
let closed = wait_until(
|
|
||||||
Duration::from_secs(15),
|
|
||||||
|| async {
|
|
||||||
Ok(walkdir::WalkDir::new(disk.join(".rustfs-artifact-repair-seals"))
|
|
||||||
.min_depth(2)
|
|
||||||
.max_depth(2)
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.any(|entry| entry.file_name() == "closed"))
|
|
||||||
},
|
|
||||||
"receiver persisted repair closure while its physical worker remains paused",
|
|
||||||
);
|
|
||||||
tokio::pin!(closed);
|
|
||||||
tokio::select! {
|
|
||||||
result = &mut seal => {
|
|
||||||
let (status, response) = result?;
|
|
||||||
panic!("repair seal returned before the blocked receiver closed admission: {status} {response}");
|
|
||||||
}
|
|
||||||
result = &mut closed => result?,
|
|
||||||
}
|
|
||||||
// Cover a complete receiver drain timeout, so early physical-owner release
|
|
||||||
// causes a successful response here and fails this runtime assertion.
|
|
||||||
let (status, response) = timeout(Duration::from_secs(40), &mut seal).await??;
|
|
||||||
assert_eq!(
|
|
||||||
status,
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"seal confirmed an unfinished physical tail: {response}"
|
|
||||||
);
|
|
||||||
assert!(response.contains("artifact operation unconfirmed"));
|
|
||||||
assert!(!destination.exists(), "worker escaped the physical barrier");
|
|
||||||
barrier.release()?;
|
|
||||||
wait_until(
|
|
||||||
Duration::from_secs(15),
|
|
||||||
|| async { Ok(destination.join("part.1").is_file()) },
|
|
||||||
"detached physical rename completes after release",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
wait_until(
|
|
||||||
Duration::from_secs(45),
|
|
||||||
|| async {
|
|
||||||
let (status, response) = user.request(&dist, 0, REPAIR_SEAL_ROUTE, request_body.clone()).await?;
|
|
||||||
if status == StatusCode::SERVICE_UNAVAILABLE && response.contains("artifact operation unconfirmed") {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
assert_eq!(status, StatusCode::OK, "repair seal did not confirm after physical drain: {response}");
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<Value>(&response)?,
|
|
||||||
json!({"version": 1, "target": target, "attempt_repairs_sealed": true})
|
|
||||||
);
|
|
||||||
Ok(true)
|
|
||||||
},
|
|
||||||
"exact retry confirms all four receivers after physical drain",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(census_object_version_on_disk(&disk, bucket, &key, Some(version))?.matches_manifest(&baseline));
|
|
||||||
assert_exact_version(&dist.client(0)?, bucket, &key, version, &body).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
use super::artifact_seal_test::{ArtifactAdmin, assert_exact_version, confirm_seal, create_artifact_attempt};
|
|
||||||
use super::harness::{
|
|
||||||
DistCluster, DistLayout, TestResult, enable_versioning, payload_for, unique_bucket, wait_for_ready, wait_until,
|
|
||||||
};
|
|
||||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post, start_root_heal_when_control_ready};
|
|
||||||
use crate::common::init_logging;
|
|
||||||
use http::StatusCode;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::time::timeout;
|
|
||||||
|
|
||||||
const REPAIR_SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-repairs";
|
|
||||||
const HEAL_BODY: &str =
|
|
||||||
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
|
||||||
const HEAL_BUDGET: Duration = Duration::from_secs(120);
|
|
||||||
|
|
||||||
pub(super) struct RepairObject {
|
|
||||||
pub(super) key: String,
|
|
||||||
pub(super) version: String,
|
|
||||||
pub(super) body: Vec<u8>,
|
|
||||||
manifests: Vec<VersionShardCensus>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn roots(dist: &DistCluster) -> Vec<PathBuf> {
|
|
||||||
dist.cluster
|
|
||||||
.nodes
|
|
||||||
.iter()
|
|
||||||
.map(|node| PathBuf::from(&node.data_dirs[0]))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RepairObject {
|
|
||||||
pub(super) async fn put(dist: &DistCluster, bucket: &str, key: String) -> TestResult<Self> {
|
|
||||||
let body = payload_for(&key, 2 * 1024 * 1024);
|
|
||||||
let output = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
dist.client(1)?
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(&key)
|
|
||||||
.body(body.clone().into())
|
|
||||||
.send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
let version = output.version_id().ok_or("managed PUT omitted version ID")?.to_owned();
|
|
||||||
let manifests = roots(dist)
|
|
||||||
.iter()
|
|
||||||
.map(|disk| census_object_version_on_disk(disk, bucket, &key, Some(&version)))
|
|
||||||
.collect::<TestResult<Vec<_>>>()?;
|
|
||||||
for manifest in &manifests {
|
|
||||||
assert!(manifest.is_complete(), "repair needs a complete baseline: {manifest:?}");
|
|
||||||
assert!(!manifest.expected_part_numbers.is_empty(), "repair must use actual shard files");
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
key,
|
|
||||||
version,
|
|
||||||
body,
|
|
||||||
manifests,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn part(&self, disk: &Path, bucket: &str, node: usize) -> TestResult<PathBuf> {
|
|
||||||
let data_dir = self.manifests[node]
|
|
||||||
.data_dir
|
|
||||||
.as_ref()
|
|
||||||
.ok_or("external shard omitted data directory")?;
|
|
||||||
Ok(disk.join(bucket).join(&self.key).join(data_dir).join("part.1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn assert_repaired(&self, dist: &DistCluster, bucket: &str) -> TestResult {
|
|
||||||
wait_until(
|
|
||||||
HEAL_BUDGET,
|
|
||||||
|| async {
|
|
||||||
for (disk, baseline) in roots(dist).iter().zip(&self.manifests) {
|
|
||||||
if !census_object_version_on_disk(disk, bucket, &self.key, Some(&self.version))?.matches_manifest(baseline) {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
},
|
|
||||||
"every configured receiver restored the exact version and original shard fingerprints",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
for node in 0..dist.cluster.nodes.len() {
|
|
||||||
assert_exact_version(&dist.client(node)?, bucket, &self.key, &self.version, &self.body).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn start_heal(dist: &DistCluster, bucket: &str, prefix: &str, remove: bool) -> TestResult<String> {
|
|
||||||
let url = format!("{}/rustfs/admin/v3/heal/{bucket}/{prefix}?forceStart=true", dist.cluster.nodes[0].url);
|
|
||||||
let body = if remove {
|
|
||||||
HEAL_BODY.replace("\"remove\":false", "\"remove\":true")
|
|
||||||
} else {
|
|
||||||
HEAL_BODY.to_owned()
|
|
||||||
};
|
|
||||||
let response = timeout(
|
|
||||||
HEAL_BUDGET,
|
|
||||||
start_root_heal_when_control_ready(&url, &body, &dist.cluster.access_key, &dist.cluster.secret_key),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
let response: Value = serde_json::from_str(&response)?;
|
|
||||||
Ok(response["clientToken"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or("heal omitted its task token")?
|
|
||||||
.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn finish_heal(dist: &DistCluster, token: &str, rejected_key: Option<&str>) -> TestResult {
|
|
||||||
let url = format!("{}/rustfs/admin/v3/heal/?clientToken={token}", dist.cluster.nodes[0].url);
|
|
||||||
wait_until(
|
|
||||||
HEAL_BUDGET,
|
|
||||||
|| async {
|
|
||||||
let response = signed_admin_post(&url, None, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
|
|
||||||
let status: Value = serde_json::from_str(&response)?;
|
|
||||||
if status["summary"] == "running" {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
if let Some(key) = rejected_key {
|
|
||||||
assert_eq!(status["summary"], "stopped", "closed repair must report failure: {status}");
|
|
||||||
assert!(
|
|
||||||
status["outcome"]["counters"]["failed"]
|
|
||||||
.as_u64()
|
|
||||||
.is_some_and(|count| count > 0)
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
status["outcome"]["objects"].as_array().is_some_and(|objects| objects
|
|
||||||
.iter()
|
|
||||||
.any(|object| { object["identity"]["object"] == key && object["disposition"]["state"] == "failed" })),
|
|
||||||
"heal must actually attempt the rejected object: {status}"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
assert_eq!(status["summary"], "finished", "heal did not complete: {status}");
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
},
|
|
||||||
"exact admin heal task reached its expected terminal outcome",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn repair_seal(dist: &DistCluster, user: &ArtifactAdmin, target: &Value, confirmed: bool) -> TestResult {
|
|
||||||
wait_until(
|
|
||||||
Duration::from_secs(45),
|
|
||||||
|| async {
|
|
||||||
let request = json!({"version": 1, "target": target}).to_string();
|
|
||||||
let (status, body) = user.request(dist, 0, REPAIR_SEAL_ROUTE, request).await?;
|
|
||||||
if !confirmed {
|
|
||||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "offline receiver repair seal: {body}");
|
|
||||||
assert!(body.contains("<Code>ServiceUnavailable</Code>"));
|
|
||||||
assert!(body.contains("artifact operation unconfirmed"));
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
if status == StatusCode::SERVICE_UNAVAILABLE
|
|
||||||
&& body.contains("<Code>ServiceUnavailable</Code>")
|
|
||||||
&& body.contains("<Message>artifact operation unconfirmed; retry the same identity</Message>")
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
assert_eq!(status, StatusCode::OK, "repair seal failed: {body}");
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<Value>(&body)?,
|
|
||||||
json!({"version": 1, "target": target, "attempt_repairs_sealed": true})
|
|
||||||
);
|
|
||||||
Ok(true)
|
|
||||||
},
|
|
||||||
"repair seal confirms every configured receiver",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_shard_and_minority_metadata_repair_after_put_seal() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
// Sub-quorum metadata recovery supports genuine legacy shards only.
|
|
||||||
let mut dist =
|
|
||||||
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
|
|
||||||
let (target, user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
|
|
||||||
let shard = RepairObject::put(&dist, bucket, format!("{prefix}shard.bin")).await?;
|
|
||||||
let metadata = RepairObject::put(&dist, bucket, format!("{prefix}metadata.bin")).await?;
|
|
||||||
let later = dist
|
|
||||||
.client(2)?
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(&shard.key)
|
|
||||||
.body(b"newer-version".to_vec().into())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let later_version = later.version_id().ok_or("second PUT omitted version ID")?.to_owned();
|
|
||||||
assert_ne!(shard.version, later_version);
|
|
||||||
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
|
|
||||||
|
|
||||||
dist.cluster.stop();
|
|
||||||
let disks = roots(&dist);
|
|
||||||
std::fs::remove_file(shard.part(&disks[3], bucket, 3)?)?;
|
|
||||||
for disk in &disks[1..] {
|
|
||||||
std::fs::remove_file(disk.join(bucket).join(&metadata.key).join("xl.meta"))?;
|
|
||||||
}
|
|
||||||
assert!(!census_object_version_on_disk(&disks[3], bucket, &shard.key, Some(&shard.version))?.is_complete());
|
|
||||||
assert!(disks[0].join(bucket).join(&metadata.key).join("xl.meta").is_file());
|
|
||||||
assert!(
|
|
||||||
disks[1..]
|
|
||||||
.iter()
|
|
||||||
.all(|disk| !disk.join(bucket).join(&metadata.key).join("xl.meta").exists())
|
|
||||||
);
|
|
||||||
dist.cluster.start().await?;
|
|
||||||
wait_for_ready(&dist.cluster).await?;
|
|
||||||
let token = start_heal(&dist, bucket, prefix, false).await?;
|
|
||||||
shard.assert_repaired(&dist, bucket).await?;
|
|
||||||
metadata.assert_repaired(&dist, bucket).await?;
|
|
||||||
finish_heal(&dist, &token, None).await?;
|
|
||||||
assert_exact_version(&dist.client(3)?, bucket, &shard.key, &later_version, b"newer-version").await?;
|
|
||||||
let listed = dist
|
|
||||||
.client(0)?
|
|
||||||
.list_object_versions()
|
|
||||||
.bucket(bucket)
|
|
||||||
.prefix(prefix)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(listed.versions().len(), 3, "repair must not publish replacement versions");
|
|
||||||
assert!(listed.delete_markers().is_empty());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_repair_seal_denies_shard_and_metadata_repair_after_restart() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
let mut dist =
|
|
||||||
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
|
|
||||||
let (target, user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
|
|
||||||
let shard = RepairObject::put(&dist, bucket, format!("{prefix}shard.bin")).await?;
|
|
||||||
let metadata = RepairObject::put(&dist, bucket, format!("{prefix}metadata.bin")).await?;
|
|
||||||
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
|
|
||||||
repair_seal(&dist, &user, &target, true).await?;
|
|
||||||
let ordinary_bucket = unique_bucket("repair-control");
|
|
||||||
dist.create_bucket(&ordinary_bucket).await?;
|
|
||||||
enable_versioning(&dist.client(0)?, &ordinary_bucket).await?;
|
|
||||||
let ordinary = RepairObject::put(&dist, &ordinary_bucket, "ordinary.bin".to_owned()).await?;
|
|
||||||
let disks = roots(&dist);
|
|
||||||
let part = shard.part(&disks[3], bucket, 3)?;
|
|
||||||
std::fs::remove_file(&part)?;
|
|
||||||
for disk in &disks[1..] {
|
|
||||||
std::fs::remove_file(disk.join(bucket).join(&metadata.key).join("xl.meta"))?;
|
|
||||||
}
|
|
||||||
for restarted in [false, true] {
|
|
||||||
if restarted {
|
|
||||||
dist.restart_with_current_binary().await?;
|
|
||||||
}
|
|
||||||
// The same receivers must still execute an ordinary bucket repair.
|
|
||||||
std::fs::remove_file(ordinary.part(&disks[3], &ordinary_bucket, 3)?)?;
|
|
||||||
let control = start_heal(&dist, &ordinary_bucket, &ordinary.key, false).await?;
|
|
||||||
ordinary.assert_repaired(&dist, &ordinary_bucket).await?;
|
|
||||||
finish_heal(&dist, &control, None).await?;
|
|
||||||
for object in [&shard, &metadata] {
|
|
||||||
let token = start_heal(&dist, bucket, &object.key, false).await?;
|
|
||||||
finish_heal(&dist, &token, Some(&object.key)).await?;
|
|
||||||
}
|
|
||||||
assert!(!part.exists(), "sealed repair recreated the missing shard");
|
|
||||||
assert!(
|
|
||||||
disks[1..]
|
|
||||||
.iter()
|
|
||||||
.all(|disk| !disk.join(bucket).join(&metadata.key).join("xl.meta").exists())
|
|
||||||
);
|
|
||||||
assert_exact_version(&dist.client(2)?, bucket, &shard.key, &shard.version, &shard.body).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_repair_seal_requires_every_receiver_on_exact_retry() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
|
|
||||||
let (target, user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let request = json!({"version": 1, "target": target}).to_string();
|
|
||||||
confirm_seal(&dist, &user, &request, &target).await?;
|
|
||||||
// Repeat after global confirmation: its persisted receipt cannot replace a
|
|
||||||
// fresh acknowledgement from an offline configured receiver.
|
|
||||||
for _ in 0..2 {
|
|
||||||
dist.cluster.stop_node(3)?;
|
|
||||||
repair_seal(&dist, &user, &target, false).await?;
|
|
||||||
dist.cluster.start_node(3).await?;
|
|
||||||
wait_for_ready(&dist.cluster).await?;
|
|
||||||
repair_seal(&dist, &user, &target, true).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_dangling_repair_preserves_surviving_metadata() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
let mut dist =
|
|
||||||
DistCluster::start_with_env(DistLayout::FourNodeFourDisk, &[("RUSTFS_SHARD_INTEGRITY_WRITE", "false")]).await?;
|
|
||||||
let (target, user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted prefix")?;
|
|
||||||
let object = RepairObject::put(&dist, bucket, format!("{prefix}dangling.bin")).await?;
|
|
||||||
confirm_seal(&dist, &user, &json!({"version": 1, "target": target}).to_string(), &target).await?;
|
|
||||||
dist.cluster.stop();
|
|
||||||
let disks = roots(&dist);
|
|
||||||
let survivor = disks[0].join(bucket).join(&object.key).join("xl.meta");
|
|
||||||
let before = std::fs::read(&survivor)?;
|
|
||||||
for (node, disk) in disks.iter().enumerate() {
|
|
||||||
std::fs::remove_file(object.part(disk, bucket, node)?)?;
|
|
||||||
if node != 0 {
|
|
||||||
std::fs::remove_file(disk.join(bucket).join(&object.key).join("xl.meta"))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dist.cluster.start().await?;
|
|
||||||
wait_for_ready(&dist.cluster).await?;
|
|
||||||
let token = start_heal(&dist, bucket, &object.key, true).await?;
|
|
||||||
finish_heal(&dist, &token, Some(&object.key)).await?;
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read(survivor)?,
|
|
||||||
before,
|
|
||||||
"distributed repair must not authorize managed dangling deletion"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
use super::harness::{
|
|
||||||
DistCluster, DistLayout, TestResult, assert_object_bytes, put_object, s3_probe_client, unique_bucket, wait_for_ready,
|
|
||||||
wait_until,
|
|
||||||
};
|
|
||||||
use crate::common::{
|
|
||||||
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, admin_request, init_logging,
|
|
||||||
};
|
|
||||||
use aws_sdk_s3::Client;
|
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
|
||||||
use http::{Method, StatusCode};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::time::timeout;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const BUCKET_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/buckets";
|
|
||||||
const INSPECT_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/buckets/inspect";
|
|
||||||
const SEAL_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-attempt";
|
|
||||||
const ADMIN_BUDGET: Duration = Duration::from_secs(45);
|
|
||||||
|
|
||||||
pub(super) struct ArtifactAdmin {
|
|
||||||
access_key: String,
|
|
||||||
secret_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ArtifactAdmin {
|
|
||||||
pub(super) async fn request(
|
|
||||||
&self,
|
|
||||||
dist: &DistCluster,
|
|
||||||
node: usize,
|
|
||||||
route: &str,
|
|
||||||
body: String,
|
|
||||||
) -> TestResult<(StatusCode, String)> {
|
|
||||||
timeout(
|
|
||||||
ADMIN_BUDGET,
|
|
||||||
admin_request(
|
|
||||||
&dist.cluster.nodes[node].url,
|
|
||||||
Method::POST,
|
|
||||||
route,
|
|
||||||
Some(body),
|
|
||||||
&self.access_key,
|
|
||||||
&self.secret_key,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_artifact_admin(dist: &DistCluster, bucket: &str, prefix: &str) -> TestResult<ArtifactAdmin> {
|
|
||||||
let user = ArtifactAdmin {
|
|
||||||
access_key: format!("artifact{}", &Uuid::new_v4().simple().to_string()[..12]),
|
|
||||||
secret_key: Uuid::new_v4().simple().to_string(),
|
|
||||||
};
|
|
||||||
let policy_name = format!("artifact{}", &Uuid::new_v4().simple().to_string()[..12]);
|
|
||||||
let policy = json!({
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Action": ["admin:CreatePluginArtifactBucket", "admin:GetPluginArtifactBucket"],
|
|
||||||
"Resource": format!("arn:aws:s3:::{bucket}"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Action": ["admin:SealPluginArtifactAttempt", "admin:SealPluginArtifactRepairs", "admin:SealPluginArtifactWrites"],
|
|
||||||
"Resource": format!("arn:aws:s3:::{bucket}/{}", prefix.trim_end_matches('/')),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
let url = &dist.cluster.nodes[0].url;
|
|
||||||
let access = &dist.cluster.access_key;
|
|
||||||
let secret = &dist.cluster.secret_key;
|
|
||||||
admin_create_user_via(AdminTransport::Signed, url, access, secret, &user.access_key, &user.secret_key).await?;
|
|
||||||
admin_add_canned_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &policy).await?;
|
|
||||||
admin_attach_user_policy_via(AdminTransport::Signed, url, access, secret, &policy_name, &user.access_key).await?;
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn create_artifact_attempt(dist: &DistCluster) -> TestResult<(Value, ArtifactAdmin)> {
|
|
||||||
let bucket = unique_bucket("artifact");
|
|
||||||
let prefix = format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
|
|
||||||
let user = create_artifact_admin(dist, &bucket, &prefix).await?;
|
|
||||||
let body = json!({"version": 1, "bucket": bucket, "base_prefix": "outputs/"}).to_string();
|
|
||||||
let (status, response) = user.request(dist, 0, BUCKET_ROUTE, body).await?;
|
|
||||||
assert_eq!(status, StatusCode::OK, "artifact bucket creation failed: {response}");
|
|
||||||
let receipt: Value = serde_json::from_str(&response)?;
|
|
||||||
assert_eq!(receipt["version"], 1);
|
|
||||||
assert_eq!(receipt["bucket"], bucket);
|
|
||||||
assert_eq!(receipt["base_prefix"], "outputs/");
|
|
||||||
assert_eq!(receipt["versioning_enabled"], true);
|
|
||||||
let incarnation = receipt["bucket_incarnation"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or("artifact receipt omitted incarnation")?;
|
|
||||||
assert!(!Uuid::parse_str(incarnation)?.is_nil());
|
|
||||||
let target = json!({
|
|
||||||
"bucket": bucket,
|
|
||||||
"bucket_incarnation": incarnation,
|
|
||||||
"attempt_prefix": prefix,
|
|
||||||
});
|
|
||||||
assert_bucket_identity(dist, &user, 1, &target).await?;
|
|
||||||
Ok((target, user))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_bucket_identity(dist: &DistCluster, user: &ArtifactAdmin, node: usize, target: &Value) -> TestResult {
|
|
||||||
let body = json!({"version": 1, "bucket": target["bucket"], "base_prefix": "outputs/"}).to_string();
|
|
||||||
wait_until(
|
|
||||||
ADMIN_BUDGET,
|
|
||||||
|| async {
|
|
||||||
let (status, response) = user.request(dist, node, INSPECT_ROUTE, body.clone()).await?;
|
|
||||||
// IAM peers may not have received the new user and policy yet.
|
|
||||||
// Storage failures must fail immediately, including the old gate's 503.
|
|
||||||
if matches!(status, StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED)
|
|
||||||
&& (response.contains("<Code>AccessDenied</Code>") || response.contains("<Code>InvalidAccessKeyId</Code>"))
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
assert_eq!(status, StatusCode::OK, "cross-node artifact inspect failed: {response}");
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<Value>(&response)?,
|
|
||||||
json!({
|
|
||||||
"version": 1,
|
|
||||||
"bucket": target["bucket"],
|
|
||||||
"base_prefix": "outputs/",
|
|
||||||
"bucket_incarnation": target["bucket_incarnation"],
|
|
||||||
"versioning_enabled": true,
|
|
||||||
}),
|
|
||||||
"inspect must return the created managed bucket identity"
|
|
||||||
);
|
|
||||||
Ok(true)
|
|
||||||
},
|
|
||||||
"artifact IAM user and policy propagation",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn confirm_seal(dist: &DistCluster, user: &ArtifactAdmin, request: &str, target: &Value) -> TestResult {
|
|
||||||
wait_until(
|
|
||||||
ADMIN_BUDGET,
|
|
||||||
|| async {
|
|
||||||
let (status, response) = user.request(dist, 0, SEAL_ROUTE, request.to_owned()).await?;
|
|
||||||
// HTTP readiness does not confirm that remote disk handles have
|
|
||||||
// recovered. Retry only the API's explicit same-identity response.
|
|
||||||
if status == StatusCode::SERVICE_UNAVAILABLE
|
|
||||||
&& response.contains("<Code>ServiceUnavailable</Code>")
|
|
||||||
&& response.contains("<Message>artifact operation unconfirmed; retry the same identity</Message>")
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
assert_eq!(status, StatusCode::OK, "artifact seal failed: {response}");
|
|
||||||
let receipt: Value = serde_json::from_str(&response)?;
|
|
||||||
assert_eq!(receipt, json!({"version": 1, "target": target, "ordinary_put_sealed": true}));
|
|
||||||
Ok(true)
|
|
||||||
},
|
|
||||||
"exact artifact seal confirmation after receiver recovery",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_seal_unconfirmed(dist: &DistCluster, user: &ArtifactAdmin, request: &str) -> TestResult {
|
|
||||||
let (status, body) = user.request(dist, 0, SEAL_ROUTE, request.to_owned()).await?;
|
|
||||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "offline receiver seal response: {body}");
|
|
||||||
assert!(
|
|
||||||
body.contains("<Code>ServiceUnavailable</Code>"),
|
|
||||||
"seal failure lost its service code: {body}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
body.contains("artifact operation unconfirmed"),
|
|
||||||
"seal failure lost its retry contract: {body}"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn assert_exact_version(client: &Client, bucket: &str, key: &str, version: &str, body: &[u8]) -> TestResult {
|
|
||||||
let output = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
client.get_object().bucket(bucket).key(key).version_id(version).send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
assert_eq!(output.version_id(), Some(version));
|
|
||||||
let actual = timeout(Duration::from_secs(30), output.body.collect()).await??.into_bytes();
|
|
||||||
assert_eq!(actual.as_ref(), body);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_put_denied(client: &Client, ordinary_bucket: &str, bucket: &str, key: &str) -> TestResult {
|
|
||||||
// The same receiver must still accept a normal write before its sealed probe.
|
|
||||||
put_object(client, ordinary_bucket, key, b"ordinary-control".to_vec()).await?;
|
|
||||||
assert_object_bytes(client, ordinary_bucket, key, b"ordinary-control").await?;
|
|
||||||
let client = s3_probe_client(client);
|
|
||||||
let result = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
client
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.body(b"must-not-publish".to_vec().into())
|
|
||||||
.send(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let error = result.expect_err("sealed artifact attempt accepted a new PUT");
|
|
||||||
let service = error
|
|
||||||
.as_service_error()
|
|
||||||
.ok_or("sealed PUT failed without a service rejection")?;
|
|
||||||
assert_eq!(
|
|
||||||
error.raw_response().map(|response| response.status().as_u16()),
|
|
||||||
Some(500),
|
|
||||||
"sealed PUT must return its current HTTP status"
|
|
||||||
);
|
|
||||||
assert_eq!(service.code(), Some("InternalError"), "sealed PUT must return its current service code");
|
|
||||||
// StorageError::Io is redacted at the API boundary; the sealed reason is
|
|
||||||
// not exposed. Success controls and exact inventory checks prove the effect.
|
|
||||||
assert_eq!(
|
|
||||||
service.message(),
|
|
||||||
Some("We encountered an internal error, please try again."),
|
|
||||||
"sealed PUT must retain the public redacted message"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_put_seal_survives_full_restart() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
|
|
||||||
let (target, user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted attempt prefix")?;
|
|
||||||
let key = format!("{prefix}result.bin");
|
|
||||||
let mut versions = Vec::new();
|
|
||||||
for (node, body) in [(1, b"first-version".as_slice()), (2, b"second-version".as_slice())] {
|
|
||||||
let client = dist.client(node)?;
|
|
||||||
let output = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
client.put_object().bucket(bucket).key(&key).body(body.to_vec().into()).send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
let version = output.version_id().ok_or("artifact PUT omitted version ID")?.to_owned();
|
|
||||||
assert!(!Uuid::parse_str(&version)?.is_nil());
|
|
||||||
assert_exact_version(&dist.client(3)?, bucket, &key, &version, body).await?;
|
|
||||||
versions.push((version, body.to_vec()));
|
|
||||||
}
|
|
||||||
assert_ne!(versions[0].0, versions[1].0);
|
|
||||||
let ordinary = unique_bucket("ordinary");
|
|
||||||
dist.create_bucket(&ordinary).await?;
|
|
||||||
put_object(&dist.client(2)?, &ordinary, "normal.bin", b"before-seal".to_vec()).await?;
|
|
||||||
assert_object_bytes(&dist.client(3)?, &ordinary, "normal.bin", b"before-seal").await?;
|
|
||||||
|
|
||||||
let request = json!({"version": 1, "target": target}).to_string();
|
|
||||||
confirm_seal(&dist, &user, &request, &target).await?;
|
|
||||||
let other_prefix = format!("outputs/6169/{}/{}/", Uuid::new_v4(), Uuid::new_v4());
|
|
||||||
for phase in ["sealed", "restarted"] {
|
|
||||||
if phase == "restarted" {
|
|
||||||
dist.cluster.stop();
|
|
||||||
dist.cluster.start().await?;
|
|
||||||
wait_for_ready(&dist.cluster).await?;
|
|
||||||
}
|
|
||||||
assert_bucket_identity(&dist, &user, 2, &target).await?;
|
|
||||||
for node in 0..dist.cluster.nodes.len() {
|
|
||||||
// A second attempt in the managed bucket must remain writable;
|
|
||||||
// a broken bucket-wide write path cannot stand in for sealing.
|
|
||||||
let client = dist.client(node)?;
|
|
||||||
let other_key = format!("{other_prefix}{phase}-{node}.bin");
|
|
||||||
let output = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
client
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(&other_key)
|
|
||||||
.body(b"other-attempt".to_vec().into())
|
|
||||||
.send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
let version = output.version_id().ok_or("other attempt PUT omitted version ID")?;
|
|
||||||
assert!(!Uuid::parse_str(version)?.is_nil());
|
|
||||||
assert_exact_version(
|
|
||||||
&dist.client((node + 1) % dist.cluster.nodes.len())?,
|
|
||||||
bucket,
|
|
||||||
&other_key,
|
|
||||||
version,
|
|
||||||
b"other-attempt",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_put_denied(&dist.client(node)?, &ordinary, bucket, &format!("{prefix}{phase}-{node}.bin")).await?;
|
|
||||||
for (version, body) in &versions {
|
|
||||||
assert_exact_version(&dist.client(node)?, bucket, &key, version, body).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let listed = dist
|
|
||||||
.client(0)?
|
|
||||||
.list_object_versions()
|
|
||||||
.bucket(bucket)
|
|
||||||
.prefix(prefix)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(listed.versions().len(), 2, "denied PUT left an artifact version: {listed:?}");
|
|
||||||
assert!(listed.delete_markers().is_empty(), "artifact seal created a delete marker");
|
|
||||||
put_object(&dist.client(1)?, &ordinary, "normal.bin", phase.as_bytes().to_vec()).await?;
|
|
||||||
assert_object_bytes(&dist.client(2)?, &ordinary, "normal.bin", phase.as_bytes()).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_seal_requires_every_configured_receiver_on_exact_retry() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
|
|
||||||
let (target, user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = target["bucket"].as_str().ok_or("target omitted bucket")?;
|
|
||||||
let prefix = target["attempt_prefix"].as_str().ok_or("target omitted attempt prefix")?;
|
|
||||||
let key = format!("{prefix}existing.bin");
|
|
||||||
let output = dist
|
|
||||||
.client(1)?
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(&key)
|
|
||||||
.body(b"existing".to_vec().into())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let version = output.version_id().ok_or("artifact PUT omitted version ID")?.to_owned();
|
|
||||||
assert_exact_version(&dist.client(3)?, bucket, &key, &version, b"existing").await?;
|
|
||||||
let ordinary = unique_bucket("ordinary");
|
|
||||||
dist.create_bucket(&ordinary).await?;
|
|
||||||
let request = json!({"version": 1, "target": target}).to_string();
|
|
||||||
|
|
||||||
// The second iteration starts from a fully acknowledged global seal. Its
|
|
||||||
// persisted record cannot replace a fresh acknowledgement from every disk.
|
|
||||||
for _ in 0..2 {
|
|
||||||
dist.cluster.stop_node(3)?;
|
|
||||||
assert_seal_unconfirmed(&dist, &user, &request).await?;
|
|
||||||
dist.cluster.start_node(3).await?;
|
|
||||||
wait_for_ready(&dist.cluster).await?;
|
|
||||||
confirm_seal(&dist, &user, &request, &target).await?;
|
|
||||||
}
|
|
||||||
assert_put_denied(&dist.client(3)?, &ordinary, bucket, &format!("{prefix}late.bin")).await?;
|
|
||||||
assert_exact_version(&dist.client(2)?, bucket, &key, &version, b"existing").await?;
|
|
||||||
let listed = timeout(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
dist.client(0)?.list_object_versions().bucket(bucket).prefix(prefix).send(),
|
|
||||||
)
|
|
||||||
.await??;
|
|
||||||
assert_eq!(listed.versions().len(), 1, "rejected PUT added a version");
|
|
||||||
assert!(listed.delete_markers().is_empty(), "seal added a delete marker");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
#![cfg(unix)]
|
|
||||||
|
|
||||||
use super::artifact_delete_test::physical_snapshot;
|
|
||||||
use super::artifact_repair_test::{RepairObject, finish_heal, start_heal};
|
|
||||||
use super::artifact_seal_test::{ArtifactAdmin, assert_exact_version, confirm_seal, create_artifact_attempt};
|
|
||||||
use super::harness::{
|
|
||||||
DistCluster, DistLayout, TestResult, enable_versioning, s3_probe_client, unique_bucket, wait_for_ready, wait_until,
|
|
||||||
};
|
|
||||||
use crate::common::init_logging;
|
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
|
||||||
use aws_sdk_s3::types::{
|
|
||||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention,
|
|
||||||
ObjectLockRetentionMode,
|
|
||||||
};
|
|
||||||
use http::StatusCode;
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
const WRITES_ROUTE: &str = "/rustfs/admin/v3/plugin-artifacts/seal-writes";
|
|
||||||
const INTERNAL_MESSAGE: &str = "We encountered an internal error, please try again.";
|
|
||||||
|
|
||||||
async fn seal_writes(dist: &DistCluster, user: &ArtifactAdmin, target: &Value, confirmed: bool) -> TestResult {
|
|
||||||
wait_until(Duration::from_secs(45), || async {
|
|
||||||
let (status, response) = user.request(dist, 0, WRITES_ROUTE, json!({"version":1,"target":target}).to_string()).await?;
|
|
||||||
if status == StatusCode::SERVICE_UNAVAILABLE && response.contains("artifact operation unconfirmed; retry the same identity") {
|
|
||||||
return Ok(!confirmed);
|
|
||||||
}
|
|
||||||
assert!(confirmed, "offline configured receiver produced a complete receipt: {status} {response}");
|
|
||||||
assert_eq!(status, StatusCode::OK, "combined writes seal: {response}");
|
|
||||||
assert_eq!(serde_json::from_str::<Value>(&response)?, json!({"version":1,"target":target,"ordinary_put_sealed":true,"attempt_repairs_sealed":true,"all_writes_sealed":true}));
|
|
||||||
Ok(true)
|
|
||||||
}, "combined seal acknowledges both domains on every receiver").await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn assert_put_closed(dist: &DistCluster, bucket: &str, key: &str) -> TestResult {
|
|
||||||
let before = physical_snapshot(dist, bucket)?;
|
|
||||||
let client = s3_probe_client(&dist.client(2)?);
|
|
||||||
let error = client
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.body(b"must-not-publish".to_vec().into())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect_err("combined seal admitted a new ordinary PUT");
|
|
||||||
let service = error.as_service_error().ok_or("PUT was not a service rejection")?;
|
|
||||||
assert_eq!(service.code(), Some("InternalError"));
|
|
||||||
assert_eq!(service.message(), Some(INTERNAL_MESSAGE));
|
|
||||||
assert!(
|
|
||||||
physical_snapshot(dist, bucket)? == before,
|
|
||||||
"rejected PUT changed the managed physical namespace"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn lock_metadata_entrypoints(dist: &DistCluster, managed: &Value, object: &RepairObject, ordinary: &str) -> TestResult {
|
|
||||||
let bucket = managed["bucket"].as_str().ok_or("bucket identity")?;
|
|
||||||
let client = s3_probe_client(&dist.client(0)?);
|
|
||||||
for bucket in [bucket, ordinary] {
|
|
||||||
client
|
|
||||||
.put_object_lock_configuration()
|
|
||||||
.bucket(bucket)
|
|
||||||
.object_lock_configuration(
|
|
||||||
ObjectLockConfiguration::builder()
|
|
||||||
.object_lock_enabled(ObjectLockEnabled::Enabled)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
wait_until(
|
|
||||||
Duration::from_secs(30),
|
|
||||||
|| async {
|
|
||||||
for node in 0..4 {
|
|
||||||
let output = match dist.client(node)?.get_object_lock_configuration().bucket(bucket).send().await {
|
|
||||||
Ok(output) => output,
|
|
||||||
Err(error)
|
|
||||||
if error.as_service_error().and_then(ProvideErrorMetadata::code)
|
|
||||||
== Some("ObjectLockConfigurationNotFoundError") =>
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error.into()),
|
|
||||||
};
|
|
||||||
if output
|
|
||||||
.object_lock_configuration()
|
|
||||||
.and_then(|configuration| configuration.object_lock_enabled())
|
|
||||||
!= Some(&ObjectLockEnabled::Enabled)
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(true)
|
|
||||||
},
|
|
||||||
"actual Object Lock configuration is enabled on all request nodes",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
let control = RepairObject::put(dist, ordinary, "lock-control.bin".to_owned()).await?;
|
|
||||||
let retention = ObjectLockRetention::builder()
|
|
||||||
.mode(ObjectLockRetentionMode::Governance)
|
|
||||||
.retain_until_date(aws_sdk_s3::primitives::DateTime::from_secs(chrono::Utc::now().timestamp() + 86400))
|
|
||||||
.build();
|
|
||||||
let hold = ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::On).build();
|
|
||||||
client
|
|
||||||
.put_object_retention()
|
|
||||||
.bucket(ordinary)
|
|
||||||
.key(&control.key)
|
|
||||||
.version_id(&control.version)
|
|
||||||
.retention(retention.clone())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
client
|
|
||||||
.get_object_retention()
|
|
||||||
.bucket(ordinary)
|
|
||||||
.key(&control.key)
|
|
||||||
.version_id(&control.version)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.retention()
|
|
||||||
.and_then(|value| value.mode()),
|
|
||||||
Some(&ObjectLockRetentionMode::Governance)
|
|
||||||
);
|
|
||||||
client
|
|
||||||
.put_object_legal_hold()
|
|
||||||
.bucket(ordinary)
|
|
||||||
.key(&control.key)
|
|
||||||
.version_id(&control.version)
|
|
||||||
.legal_hold(hold.clone())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
client
|
|
||||||
.get_object_legal_hold()
|
|
||||||
.bucket(ordinary)
|
|
||||||
.key(&control.key)
|
|
||||||
.version_id(&control.version)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.legal_hold()
|
|
||||||
.and_then(|value| value.status()),
|
|
||||||
Some(&ObjectLockLegalHoldStatus::On)
|
|
||||||
);
|
|
||||||
let before = physical_snapshot(dist, bucket)?;
|
|
||||||
let error = client
|
|
||||||
.put_object_retention()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(&object.key)
|
|
||||||
.version_id(&object.version)
|
|
||||||
.retention(retention)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect_err("managed Retention must reach and fail its unsupported metadata write");
|
|
||||||
let service = error.as_service_error().ok_or("retention was not a service rejection")?;
|
|
||||||
assert_eq!(service.code(), Some("InternalError"));
|
|
||||||
assert_eq!(service.message(), Some(INTERNAL_MESSAGE));
|
|
||||||
assert!(physical_snapshot(dist, bucket)? == before, "retention changed managed metadata");
|
|
||||||
let error = client
|
|
||||||
.put_object_legal_hold()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(&object.key)
|
|
||||||
.version_id(&object.version)
|
|
||||||
.legal_hold(hold)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect_err("managed LegalHold must reach and fail its unsupported metadata write");
|
|
||||||
let service = error.as_service_error().ok_or("legal hold was not a service rejection")?;
|
|
||||||
assert_eq!(service.code(), Some("InternalError"));
|
|
||||||
assert_eq!(service.message(), Some("Io error: artifact bucket does not support this write operation"));
|
|
||||||
assert!(physical_snapshot(dist, bucket)? == before, "legal hold changed managed metadata");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn four_node_artifact_writes_seal_first_partial_retry_offline_and_restart() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
let mut dist = DistCluster::start(DistLayout::FourNodeFourDisk).await?;
|
|
||||||
let ordinary = unique_bucket("writes-control");
|
|
||||||
dist.create_bucket(&ordinary).await?;
|
|
||||||
enable_versioning(&dist.client(0)?, &ordinary).await?;
|
|
||||||
let (fresh, fresh_user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let fresh_bucket = fresh["bucket"].as_str().ok_or("fresh bucket")?;
|
|
||||||
let fresh_prefix = fresh["attempt_prefix"].as_str().ok_or("fresh prefix")?;
|
|
||||||
let first = RepairObject::put(&dist, fresh_bucket, format!("{fresh_prefix}existing.bin")).await?;
|
|
||||||
lock_metadata_entrypoints(&dist, &fresh, &first, &ordinary).await?;
|
|
||||||
seal_writes(&dist, &fresh_user, &fresh, true).await?;
|
|
||||||
seal_writes(&dist, &fresh_user, &fresh, true).await?;
|
|
||||||
assert_put_closed(&dist, fresh_bucket, &format!("{fresh_prefix}closed.bin")).await?;
|
|
||||||
|
|
||||||
let (partial, partial_user) = create_artifact_attempt(&dist).await?;
|
|
||||||
let bucket = partial["bucket"].as_str().ok_or("partial bucket")?;
|
|
||||||
let prefix = partial["attempt_prefix"].as_str().ok_or("partial prefix")?;
|
|
||||||
let repaired = RepairObject::put(&dist, bucket, format!("{prefix}repair.bin")).await?;
|
|
||||||
// Enter the combined operation with only its ordinary domain closed.
|
|
||||||
confirm_seal(&dist, &partial_user, &json!({"version":1,"target":partial}).to_string(), &partial).await?;
|
|
||||||
let part = repaired.part(Path::new(&dist.cluster.nodes[3].data_dirs[0]), bucket, 3)?;
|
|
||||||
std::fs::remove_file(&part)?;
|
|
||||||
let token = start_heal(&dist, bucket, &repaired.key, false).await?;
|
|
||||||
repaired.assert_repaired(&dist, bucket).await?;
|
|
||||||
finish_heal(&dist, &token, None).await?;
|
|
||||||
for _ in 0..2 {
|
|
||||||
dist.cluster.stop_node(3)?;
|
|
||||||
seal_writes(&dist, &partial_user, &partial, false).await?;
|
|
||||||
dist.cluster.start_node(3).await?;
|
|
||||||
wait_for_ready(&dist.cluster).await?;
|
|
||||||
seal_writes(&dist, &partial_user, &partial, true).await?;
|
|
||||||
}
|
|
||||||
let ordinary_object = RepairObject::put(&dist, &ordinary, "repair-control.bin".to_owned()).await?;
|
|
||||||
for restarted in [false, true] {
|
|
||||||
if restarted {
|
|
||||||
dist.restart_with_current_binary().await?;
|
|
||||||
}
|
|
||||||
seal_writes(&dist, &partial_user, &partial, true).await?;
|
|
||||||
assert_put_closed(&dist, bucket, &format!("{prefix}closed-{restarted}.bin")).await?;
|
|
||||||
if part.exists() {
|
|
||||||
std::fs::remove_file(&part)?;
|
|
||||||
}
|
|
||||||
let before = physical_snapshot(&dist, bucket)?;
|
|
||||||
let token = start_heal(&dist, bucket, &repaired.key, false).await?;
|
|
||||||
finish_heal(&dist, &token, Some(&repaired.key)).await?;
|
|
||||||
assert!(!part.exists(), "combined seal admitted a remote repair publication");
|
|
||||||
assert!(physical_snapshot(&dist, bucket)? == before, "rejected repair changed metadata or shards");
|
|
||||||
assert_exact_version(&dist.client(2)?, bucket, &repaired.key, &repaired.version, &repaired.body).await?;
|
|
||||||
let ordinary_part = ordinary_object.part(Path::new(&dist.cluster.nodes[3].data_dirs[0]), &ordinary, 3)?;
|
|
||||||
std::fs::remove_file(ordinary_part)?;
|
|
||||||
let token = start_heal(&dist, &ordinary, &ordinary_object.key, false).await?;
|
|
||||||
ordinary_object.assert_repaired(&dist, &ordinary).await?;
|
|
||||||
finish_heal(&dist, &token, None).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -13,18 +13,20 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use super::harness::{
|
use super::harness::{
|
||||||
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
|
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, get_object_bytes, payload_for, put_object,
|
||||||
wait_for_ready, wait_until,
|
retrying_get_equals, retrying_put, s3_probe_client, s3_probe_error_code, unique_bucket, wait_for_ready, wait_until,
|
||||||
};
|
};
|
||||||
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
|
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
|
||||||
use crate::common::{build_test_s3_config, init_logging};
|
use crate::common::{build_test_s3_config, init_logging};
|
||||||
use crate::fault_proxy::FaultMode;
|
use crate::fault_proxy::FaultMode;
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::SdkError;
|
||||||
|
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{Barrier, mpsc};
|
use tokio::sync::{Barrier, mpsc};
|
||||||
use tokio::time::timeout;
|
use tokio::time::{Instant, timeout};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
||||||
@@ -44,6 +46,133 @@ async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ec4_partial_node_return_restores_read_write_quorum() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start_with_env(
|
||||||
|
DistLayout::FourByFour,
|
||||||
|
&[
|
||||||
|
("RUSTFS_ERASURE_SET_DRIVE_COUNT", "16"),
|
||||||
|
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let storage = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
|
||||||
|
let storage: serde_json::Value = serde_json::from_str(&storage)?;
|
||||||
|
let storage: rustfs_madmin::StorageInfo = serde_json::from_value(storage["info"].clone())?;
|
||||||
|
assert_eq!(storage.disks.len(), 16);
|
||||||
|
assert_eq!(storage.backend.total_sets, vec![1]);
|
||||||
|
assert_eq!(storage.backend.drives_per_set, vec![16]);
|
||||||
|
assert_eq!(storage.backend.standard_sc_parity, Some(4));
|
||||||
|
|
||||||
|
let bucket = unique_bucket("partial-ec4");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = s3_probe_client(&dist.client(0)?);
|
||||||
|
let baseline = payload_for("partial-recovery/baseline", 8 * 1024 * 1024);
|
||||||
|
put_object(&client, &bucket, "baseline.bin", baseline.clone()).await?;
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
|| async {
|
||||||
|
for node in &dist.cluster.nodes {
|
||||||
|
for directory in &node.data_dirs {
|
||||||
|
let census = census_object_version_on_disk(std::path::Path::new(directory), &bucket, "baseline.bin", None)?;
|
||||||
|
if !census.is_complete() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
assert_eq!(census.data_blocks, Some(12), "the object must require twelve data shards");
|
||||||
|
assert_eq!(census.parity_blocks, Some(4), "the object must have four parity shards");
|
||||||
|
assert!(!census.expected_part_numbers.is_empty(), "the baseline must use physical shards");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
"the baseline is complete on all sixteen EC:4 drives",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
dist.cluster.stop_node(2)?;
|
||||||
|
dist.cluster.stop_node(3)?;
|
||||||
|
let read_error = timeout(Duration::from_secs(60), get_object_bytes(&client, &bucket, "baseline.bin"))
|
||||||
|
.await?
|
||||||
|
.expect_err("eight unavailable drives must prevent the baseline GET");
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
s3_probe_error_code(read_error.as_ref()),
|
||||||
|
Some("SlowDown" | "SlowDownRead" | "ServiceUnavailable")
|
||||||
|
),
|
||||||
|
"quorum loss must produce an availability error: {read_error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_error
|
||||||
|
.downcast_ref::<SdkError<GetObjectError>>()
|
||||||
|
.and_then(SdkError::raw_response)
|
||||||
|
.map(|response| response.status().as_u16()),
|
||||||
|
Some(503),
|
||||||
|
"read quorum loss must remain a service availability failure"
|
||||||
|
);
|
||||||
|
let write_error = timeout(Duration::from_secs(60), put_object(&client, &bucket, "unavailable.bin", baseline.clone()))
|
||||||
|
.await?
|
||||||
|
.expect_err("eight unavailable drives must prevent a successful PUT");
|
||||||
|
assert!(
|
||||||
|
matches!(s3_probe_error_code(write_error.as_ref()), Some("SlowDown" | "ServiceUnavailable")),
|
||||||
|
"quorum loss must produce an availability error: {write_error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
write_error
|
||||||
|
.downcast_ref::<SdkError<PutObjectError>>()
|
||||||
|
.and_then(SdkError::raw_response)
|
||||||
|
.map(|response| response.status().as_u16()),
|
||||||
|
Some(503),
|
||||||
|
"write quorum loss must remain a service availability failure"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recover only four drives. The remaining node must stay stopped while
|
||||||
|
// every live coordinator serves old data and acknowledges new writes.
|
||||||
|
dist.cluster.start_node_process(2).await?;
|
||||||
|
let recovery_deadline = Instant::now() + Duration::from_secs(90);
|
||||||
|
let mut recovered_objects = Vec::new();
|
||||||
|
for node in 0..3 {
|
||||||
|
assert!(dist.cluster.nodes[3].process.is_none(), "the fourth node must remain unavailable");
|
||||||
|
let client = s3_probe_client(&dist.client(node)?);
|
||||||
|
retrying_get_equals(
|
||||||
|
&client,
|
||||||
|
&bucket,
|
||||||
|
"baseline.bin",
|
||||||
|
&baseline,
|
||||||
|
recovery_deadline.saturating_duration_since(Instant::now()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let key = format!("partial-{node}.bin");
|
||||||
|
let body = payload_for(&key, 1024 * 1024);
|
||||||
|
retrying_put(
|
||||||
|
&client,
|
||||||
|
&bucket,
|
||||||
|
&key,
|
||||||
|
body.clone(),
|
||||||
|
recovery_deadline.saturating_duration_since(Instant::now()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
recovered_objects.push((key, body));
|
||||||
|
}
|
||||||
|
for node in 0..3 {
|
||||||
|
assert!(dist.cluster.nodes[3].process.is_none(), "reads must complete before all drives return");
|
||||||
|
let client = s3_probe_client(&dist.client(node)?);
|
||||||
|
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
|
||||||
|
for (key, body) in &recovered_objects {
|
||||||
|
assert_object_bytes(&client, &bucket, key, body).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dist.cluster.start_node(3).await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
let client = s3_probe_client(&dist.client(3)?);
|
||||||
|
assert_object_bytes(&client, &bucket, "baseline.bin", &baseline).await?;
|
||||||
|
for (key, body) in &recovered_objects {
|
||||||
|
assert_object_bytes(&client, &bucket, key, body).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn full_cluster_restart_preserves_objects() -> TestResult {
|
async fn full_cluster_restart_preserves_objects() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|||||||
@@ -347,6 +347,9 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
|
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
|
||||||
|
if let Ok(filter) = std::env::var("RUSTFS_E2E_LOG_FILTER") {
|
||||||
|
cluster.set_env("RUST_LOG", filter);
|
||||||
|
}
|
||||||
if let Some(directory) = capture_dir {
|
if let Some(directory) = capture_dir {
|
||||||
std::fs::create_dir_all(directory)?;
|
std::fs::create_dir_all(directory)?;
|
||||||
for node_idx in 0..cluster.nodes.len() {
|
for node_idx in 0..cluster.nodes.len() {
|
||||||
@@ -1178,11 +1181,16 @@ pub(crate) async fn retrying_get_equals(
|
|||||||
)
|
)
|
||||||
.into()),
|
.into()),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if matches!(s3_probe_error_code(error.as_ref()), Some("SlowDown" | "ServiceUnavailable")) {
|
let retryable = match s3_probe_error_code(error.as_ref()) {
|
||||||
Ok(false)
|
Some("SlowDown" | "ServiceUnavailable") => true,
|
||||||
} else {
|
// GET preserves this compatibility code for a lost data quorum.
|
||||||
Err(error)
|
Some("SlowDownRead") => error
|
||||||
}
|
.downcast_ref::<SdkError<GetObjectError>>()
|
||||||
|
.and_then(SdkError::raw_response)
|
||||||
|
.is_some_and(|response| response.status().as_u16() == 503),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if retryable { Ok(false) } else { Err(error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1321,6 +1329,62 @@ mod retry_tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn retrying_get_allows_read_quorum_recovery() -> TestResult {
|
||||||
|
use aws_sdk_s3::config::Region;
|
||||||
|
use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
|
||||||
|
use aws_smithy_types::body::SdkBody;
|
||||||
|
|
||||||
|
let endpoint = "http://read-quorum.example.invalid";
|
||||||
|
let event = |status: u16, body: &'static str| {
|
||||||
|
ReplayEvent::new(
|
||||||
|
http::Request::builder()
|
||||||
|
.method(Method::GET)
|
||||||
|
.uri(format!("{endpoint}/retry-probe/key?x-id=GetObject"))
|
||||||
|
.body(SdkBody::empty())
|
||||||
|
.expect("build replay request"),
|
||||||
|
http::Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("content-type", "application/xml")
|
||||||
|
.header("content-length", body.len())
|
||||||
|
.body(SdkBody::from(body))
|
||||||
|
.expect("build replay response"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
for status in [503, 403, 500] {
|
||||||
|
let transport = StaticReplayClient::new(vec![
|
||||||
|
event(status, "<Error><Code>SlowDownRead</Code></Error>"),
|
||||||
|
event(200, "expected bytes"),
|
||||||
|
]);
|
||||||
|
let client = Client::from_conf(
|
||||||
|
aws_sdk_s3::Config::builder()
|
||||||
|
.behavior_version_latest()
|
||||||
|
.credentials_provider(Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "read-quorum-test"))
|
||||||
|
.region(Region::new("us-east-1"))
|
||||||
|
.endpoint_url(endpoint)
|
||||||
|
.force_path_style(true)
|
||||||
|
.retry_config(RetryConfig::disabled())
|
||||||
|
.http_client(transport.clone())
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
let result = retrying_get_equals(&client, "retry-probe", "key", b"expected bytes", Duration::from_secs(5)).await;
|
||||||
|
if status == 503 {
|
||||||
|
result?;
|
||||||
|
assert_eq!(
|
||||||
|
transport.actual_requests().count(),
|
||||||
|
2,
|
||||||
|
"the probe must retry the read-quorum error exactly once"
|
||||||
|
);
|
||||||
|
transport.assert_requests_match(&[]);
|
||||||
|
} else {
|
||||||
|
let error = result.expect_err("the compatibility code must not hide a non-503 failure");
|
||||||
|
assert_eq!(s3_probe_error_code(error.as_ref()), Some("SlowDownRead"));
|
||||||
|
assert_eq!(transport.actual_requests().count(), 1, "non-availability errors must fail immediately");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
|
async fn object_polls_distinguish_committed_and_replicated_keys() -> TestResult {
|
||||||
let target = FakeS3Target::start().await?;
|
let target = FakeS3Target::start().await?;
|
||||||
|
|||||||
@@ -104,7 +104,12 @@ async fn verified_get(
|
|||||||
stage = "response body";
|
stage = "response body";
|
||||||
let mut digest = Sha256::new();
|
let mut digest = Sha256::new();
|
||||||
while let Some(chunk) = output.body.next().await {
|
while let Some(chunk) = output.body.next().await {
|
||||||
let chunk = chunk?;
|
let chunk = chunk.inspect_err(|error| {
|
||||||
|
eprintln!(
|
||||||
|
"TLS acceptance: GET body failed: node={node}, key={}, range={:?}, concurrent={concurrent}, received={received}/{}, elapsed={:?}, error={error}",
|
||||||
|
object.key, expected.range, expected.length, started.elapsed()
|
||||||
|
);
|
||||||
|
})?;
|
||||||
received += u64::try_from(chunk.len())?;
|
received += u64::try_from(chunk.len())?;
|
||||||
assert!(received <= expected.length, "node {node}: GET exceeded the requested length");
|
assert!(received <= expected.length, "node {node}: GET exceeded the requested length");
|
||||||
digest.update(&chunk);
|
digest.update(&chunk);
|
||||||
|
|||||||
@@ -18,13 +18,6 @@
|
|||||||
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
|
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
|
||||||
//! each case starts four real `rustfs` processes.
|
//! each case starts four real `rustfs` processes.
|
||||||
|
|
||||||
mod artifact_delete_test;
|
|
||||||
mod artifact_inventory_test;
|
|
||||||
mod artifact_put_tail_test;
|
|
||||||
mod artifact_repair_tail_test;
|
|
||||||
mod artifact_repair_test;
|
|
||||||
mod artifact_seal_test;
|
|
||||||
mod artifact_writes_test;
|
|
||||||
mod chaos_test;
|
mod chaos_test;
|
||||||
mod concurrency_stability_test;
|
mod concurrency_stability_test;
|
||||||
mod concurrent_data_movement_test;
|
mod concurrent_data_movement_test;
|
||||||
@@ -35,14 +28,18 @@ mod harness;
|
|||||||
mod heal_test;
|
mod heal_test;
|
||||||
mod internode_tls_test;
|
mod internode_tls_test;
|
||||||
mod multipart_fence_acceptance_test;
|
mod multipart_fence_acceptance_test;
|
||||||
|
mod multipart_rpc_delay_test;
|
||||||
mod object_lock_test;
|
mod object_lock_test;
|
||||||
mod observability_test;
|
mod observability_test;
|
||||||
|
mod publication_log;
|
||||||
mod replication_delete_marker_test;
|
mod replication_delete_marker_test;
|
||||||
mod replication_quota_test;
|
mod replication_quota_test;
|
||||||
mod s3_basic_test;
|
mod s3_basic_test;
|
||||||
mod s3_during_data_movement_test;
|
mod s3_during_data_movement_test;
|
||||||
mod scanner_heal_topology_fixture;
|
mod scanner_heal_topology_fixture;
|
||||||
mod scanner_heal_topology_test;
|
mod scanner_heal_topology_test;
|
||||||
|
mod scanner_heal_topology_tier_test;
|
||||||
mod site_replication_test;
|
mod site_replication_test;
|
||||||
|
mod table_catalog_test;
|
||||||
mod upgrade_test;
|
mod upgrade_test;
|
||||||
mod versioning_test;
|
mod versioning_test;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
//! Follow the same append-only log files without rescanning previous JSON rows.
|
||||||
|
|
||||||
|
use same_file::Handle;
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
// Captured RPC spans already produce valid rows above 2 MiB. Keep complete
|
||||||
|
// rows bounded independently from the much smaller selected-event history.
|
||||||
|
const MAX_LINE_BYTES: usize = 4 << 20;
|
||||||
|
const MAX_EVENTS: usize = 128;
|
||||||
|
const MAX_EVENT_BYTES: usize = 1 << 20;
|
||||||
|
|
||||||
|
fn invalid(reason: &'static str) -> io::Error {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct Selector {
|
||||||
|
#[serde(default)]
|
||||||
|
event: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
op: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
bucket: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
object: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Selector {
|
||||||
|
fn matches(&self, bucket: &str, object: &str) -> bool {
|
||||||
|
self.event.as_str() == Some("object_namespace_lock")
|
||||||
|
&& self.op.as_str() == Some("data_movement_publication")
|
||||||
|
&& self.bucket.as_str() == Some(bucket)
|
||||||
|
&& self.object.as_str() == Some(object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Probe {
|
||||||
|
#[serde(default)]
|
||||||
|
event: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
op: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
bucket: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
object: Value,
|
||||||
|
// A present null is not a missing fields object. The old reader uses the
|
||||||
|
// root only when this key is absent, including for non-object JSON values.
|
||||||
|
#[serde(default, deserialize_with = "present_fields")]
|
||||||
|
fields: Option<Option<Selector>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn present_fields<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<Selector>>, D::Error> {
|
||||||
|
Option::<Selector>::deserialize(d).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Probe {
|
||||||
|
fn matches(&self, bucket: &str, object: &str) -> bool {
|
||||||
|
match &self.fields {
|
||||||
|
Some(Some(fields)) => fields.matches(bucket, object),
|
||||||
|
Some(None) => false,
|
||||||
|
None => {
|
||||||
|
self.event.as_str() == Some("object_namespace_lock")
|
||||||
|
&& self.op.as_str() == Some("data_movement_publication")
|
||||||
|
&& self.bucket.as_str() == Some(bucket)
|
||||||
|
&& self.object.as_str() == Some(object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select(line: &[u8], bucket: &str, object: &str, full_decodes: &mut u64) -> io::Result<Option<Value>> {
|
||||||
|
let text = std::str::from_utf8(line).map_err(|_| invalid("invalid UTF-8 in a completed log row"))?;
|
||||||
|
// Skip unknown fields without allocating a Value tree for every RPC trace.
|
||||||
|
// A shape/duplicate-field error falls back to the original Value parser;
|
||||||
|
// it must not discard valid escaped strings or last-key-wins JSON.
|
||||||
|
if let Ok(probe) = serde_json::from_str::<Probe>(text)
|
||||||
|
&& !probe.matches(bucket, object)
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
*full_decodes += 1;
|
||||||
|
let Ok(row) = serde_json::from_str::<Value>(text) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let fields = row.get("fields").unwrap_or(&row);
|
||||||
|
if fields.get("event").and_then(Value::as_str) != Some("object_namespace_lock")
|
||||||
|
|| fields.get("op").and_then(Value::as_str) != Some("data_movement_publication")
|
||||||
|
|| fields.get("bucket").and_then(Value::as_str) != Some(bucket)
|
||||||
|
|| fields.get("object").and_then(Value::as_str) != Some(object)
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut event = fields.clone();
|
||||||
|
event["timestamp"] = row["timestamp"].clone();
|
||||||
|
Ok(Some(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Tail {
|
||||||
|
path: PathBuf,
|
||||||
|
handle: Handle,
|
||||||
|
offset: u64,
|
||||||
|
pending: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tail {
|
||||||
|
fn open(path: PathBuf) -> io::Result<Self> {
|
||||||
|
let file = File::open(&path)?;
|
||||||
|
if !file.metadata()?.is_file() {
|
||||||
|
return Err(invalid("publication log is not a regular file"));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
handle: Handle::from_file(file)?,
|
||||||
|
offset: 0,
|
||||||
|
pending: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_identity(&self) -> io::Result<()> {
|
||||||
|
if Handle::from_path(&self.path)? != self.handle {
|
||||||
|
return Err(invalid("publication log file was replaced"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll(&mut self, bytes_read: &mut u64, mut accept: impl FnMut(&[u8]) -> io::Result<()>) -> io::Result<()> {
|
||||||
|
self.check_identity()?;
|
||||||
|
let end = self.handle.as_file().metadata()?.len();
|
||||||
|
if end < self.offset {
|
||||||
|
return Err(invalid("publication log file was truncated"));
|
||||||
|
}
|
||||||
|
// A fixed high-water snapshot bounds each poll even if writers append
|
||||||
|
// faster than the reader. Bytes after it remain for the next poll.
|
||||||
|
let mut chunk = [0_u8; 64 * 1024];
|
||||||
|
while self.offset < end {
|
||||||
|
let size = usize::try_from((end - self.offset).min(64 * 1024)).expect("a log chunk fits usize");
|
||||||
|
self.handle.as_file_mut().read_exact(&mut chunk[..size])?;
|
||||||
|
self.offset += u64::try_from(size).expect("a bounded log read fits u64");
|
||||||
|
*bytes_read += u64::try_from(size).expect("a bounded log read fits u64");
|
||||||
|
let appended_at = self.pending.len();
|
||||||
|
self.pending.extend_from_slice(&chunk[..size]);
|
||||||
|
let mut start = 0;
|
||||||
|
for end in self.pending[appended_at..]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, byte)| (*byte == b'\n').then_some(appended_at + i + 1))
|
||||||
|
{
|
||||||
|
if end - start > MAX_LINE_BYTES {
|
||||||
|
return Err(invalid("publication log row exceeds its fixed bound"));
|
||||||
|
}
|
||||||
|
accept(&self.pending[start..end])?;
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
self.pending.drain(..start);
|
||||||
|
if self.pending.len() > MAX_LINE_BYTES {
|
||||||
|
return Err(invalid("unfinished publication log row exceeds its fixed bound"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.check_identity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct PublicationEvents {
|
||||||
|
tails: Vec<Tail>,
|
||||||
|
bucket: String,
|
||||||
|
object: String,
|
||||||
|
events: Vec<Value>,
|
||||||
|
per_file: Vec<Vec<Value>>,
|
||||||
|
event_count: usize,
|
||||||
|
event_bytes: usize,
|
||||||
|
bytes_read: u64,
|
||||||
|
full_decodes: u64,
|
||||||
|
failed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PublicationEvents {
|
||||||
|
pub(super) fn new(paths: Vec<PathBuf>, bucket: &str, object: &str) -> io::Result<Self> {
|
||||||
|
if paths.is_empty() || paths.len() > 4 {
|
||||||
|
return Err(invalid("publication capture requires one to four log files"));
|
||||||
|
}
|
||||||
|
let tails: Vec<Tail> = paths.into_iter().map(Tail::open).collect::<io::Result<_>>()?;
|
||||||
|
let mut reader = Self {
|
||||||
|
per_file: vec![Vec::new(); tails.len()],
|
||||||
|
tails,
|
||||||
|
bucket: bucket.to_owned(),
|
||||||
|
object: object.to_owned(),
|
||||||
|
events: Vec::new(),
|
||||||
|
event_bytes: 0,
|
||||||
|
event_count: 0,
|
||||||
|
bytes_read: 0,
|
||||||
|
full_decodes: 0,
|
||||||
|
failed: false,
|
||||||
|
};
|
||||||
|
// Prime before holding remote DELETE or starting a movement attempt.
|
||||||
|
// Retain earlier matching events too: the caller still applies its
|
||||||
|
// original timestamp boundary, and evidence keeps the complete history.
|
||||||
|
reader.poll()?;
|
||||||
|
Ok(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn poll(&mut self) -> io::Result<&[Value]> {
|
||||||
|
if self.failed {
|
||||||
|
return Err(invalid("publication log reader was already invalidated"));
|
||||||
|
}
|
||||||
|
let result = (|| {
|
||||||
|
for (tail, history) in self.tails.iter_mut().zip(&mut self.per_file) {
|
||||||
|
tail.poll(&mut self.bytes_read, |line| {
|
||||||
|
if let Some(event) = select(line, &self.bucket, &self.object, &mut self.full_decodes)? {
|
||||||
|
let bytes = serde_json::to_vec(&event).map_err(io::Error::other)?.len();
|
||||||
|
if self.event_count >= MAX_EVENTS || bytes > MAX_EVENT_BYTES - self.event_bytes {
|
||||||
|
return Err(invalid("publication event history exceeds its fixed bound"));
|
||||||
|
}
|
||||||
|
self.event_bytes += bytes;
|
||||||
|
self.event_count += 1;
|
||||||
|
history.push(event);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
// Preserve the old file-then-row order, even when a later poll appends
|
||||||
|
// to an earlier file. Only bounded selected history is copied here.
|
||||||
|
self.events = self.per_file.iter().flatten().cloned().collect();
|
||||||
|
if let Err(error) = result {
|
||||||
|
self.failed = true;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(&self.events)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnostics only: after a read error this history can be incomplete.
|
||||||
|
pub(super) fn cached(&self) -> &[Value] {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn event(id: &str) -> Value {
|
||||||
|
json!({"timestamp":"2026-10-26T00:00:00Z","event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object","lock_attempt_id":id,"state":"acquiring","extra":{"kept":[1,2]}})
|
||||||
|
}
|
||||||
|
fn append(path: &std::path::Path, raw: &[u8]) {
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(path)
|
||||||
|
.expect("open append log")
|
||||||
|
.write_all(raw)
|
||||||
|
.expect("append log bytes");
|
||||||
|
}
|
||||||
|
fn legacy(raw: &[u8]) -> Vec<Value> {
|
||||||
|
std::str::from_utf8(raw)
|
||||||
|
.expect("fixture UTF-8")
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let row: Value = serde_json::from_str(line).ok()?;
|
||||||
|
let fields = row.get("fields").unwrap_or(&row);
|
||||||
|
if fields.get("event").and_then(Value::as_str) == Some("object_namespace_lock")
|
||||||
|
&& fields.get("op").and_then(Value::as_str) == Some("data_movement_publication")
|
||||||
|
&& fields.get("bucket").and_then(Value::as_str) == Some("bucket")
|
||||||
|
&& fields.get("object").and_then(Value::as_str) == Some("object")
|
||||||
|
{
|
||||||
|
let mut result = fields.clone();
|
||||||
|
result["timestamp"] = row["timestamp"].clone();
|
||||||
|
Some(result)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appended_events_keep_full_fields_without_history_rescans() {
|
||||||
|
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||||
|
let mut raw = (json!({"message":"unrelated trace","payload":"x".repeat(4096)}).to_string() + "\n")
|
||||||
|
.repeat(64)
|
||||||
|
.into_bytes();
|
||||||
|
raw.extend_from_slice(
|
||||||
|
format!("{}\n", json!({"event":"object_namespace_lock","op":"get_object","bucket":"bucket","object":"object","spans":"x".repeat(2401832)})).as_bytes(),
|
||||||
|
);
|
||||||
|
raw.extend_from_slice(format!("{}\n", event("first")).as_bytes());
|
||||||
|
std::fs::write(file.path(), &raw).expect("seed log");
|
||||||
|
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
|
||||||
|
assert_eq!(reader.cached(), legacy(&raw));
|
||||||
|
assert_eq!(reader.full_decodes, 1);
|
||||||
|
let bytes = reader.bytes_read;
|
||||||
|
assert_eq!(reader.poll().expect("unchanged poll"), legacy(&raw));
|
||||||
|
assert_eq!(reader.bytes_read, bytes);
|
||||||
|
let next = format!("{}\n", event("second"));
|
||||||
|
append(file.path(), next.as_bytes());
|
||||||
|
raw.extend_from_slice(next.as_bytes());
|
||||||
|
assert_eq!(reader.poll().expect("new rows"), legacy(&raw));
|
||||||
|
assert_eq!(reader.bytes_read, u64::try_from(raw.len()).expect("fixture length"));
|
||||||
|
assert_eq!(reader.full_decodes, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn later_appends_preserve_the_old_file_then_row_order() {
|
||||||
|
let first = tempfile::NamedTempFile::new().expect("first log");
|
||||||
|
let second = tempfile::NamedTempFile::new().expect("second log");
|
||||||
|
std::fs::write(first.path(), format!("{}\n", event("a1"))).expect("seed first");
|
||||||
|
std::fs::write(second.path(), format!("{}\n", event("b1"))).expect("seed second");
|
||||||
|
let mut reader = PublicationEvents::new(vec![first.path().to_owned(), second.path().to_owned()], "bucket", "object")
|
||||||
|
.expect("prime both");
|
||||||
|
append(first.path(), format!("{}\n", event("a2")).as_bytes());
|
||||||
|
let mut expected = legacy(&std::fs::read(first.path()).expect("first bytes"));
|
||||||
|
expected.extend(legacy(&std::fs::read(second.path()).expect("second bytes")));
|
||||||
|
assert_eq!(reader.poll().expect("append first after second was read"), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_nested_escaped_and_duplicate_fields_match_the_old_reader() {
|
||||||
|
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||||
|
let root = event("root");
|
||||||
|
let mut nested = json!({"timestamp":root["timestamp"],"fields":root});
|
||||||
|
nested["fields"]["extra"] = json!({"unicode":"é"});
|
||||||
|
let null = json!({"fields":null,"event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object"});
|
||||||
|
let escaped = root
|
||||||
|
.to_string()
|
||||||
|
.replace("object_namespace_lock", "\\u006fbject_namespace_lock");
|
||||||
|
let duplicate = root.to_string().replacen("{", "{\"event\":\"earlier-value\",", 1);
|
||||||
|
let raw = format!("{root}\n{nested}\n{null}\n{escaped}\n{duplicate}\nnot json\n{{\"fields\":[]}}\n");
|
||||||
|
std::fs::write(file.path(), &raw).expect("mixed log");
|
||||||
|
let reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("probe and fallback");
|
||||||
|
assert_eq!(reader.cached(), legacy(raw.as_bytes()));
|
||||||
|
assert_eq!(reader.cached().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_rows_and_split_utf8_wait_for_the_newline() {
|
||||||
|
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||||
|
let mut row = event("partial");
|
||||||
|
row["extra"] = json!("é");
|
||||||
|
let raw = format!("{row}\n").into_bytes();
|
||||||
|
let cut = raw.iter().position(|b| *b == 0xc3).expect("UTF-8 prefix") + 1;
|
||||||
|
std::fs::write(file.path(), &raw[..cut]).expect("partial code point");
|
||||||
|
let mut reader =
|
||||||
|
PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("incomplete row is pending");
|
||||||
|
assert!(reader.cached().is_empty());
|
||||||
|
append(file.path(), &raw[cut..raw.len() - 1]);
|
||||||
|
assert!(reader.poll().expect("complete JSON without delimiter").is_empty());
|
||||||
|
append(file.path(), b"\n");
|
||||||
|
assert_eq!(reader.poll().expect("published row"), legacy(&raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncation_is_sticky_even_after_the_file_grows_again() {
|
||||||
|
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||||
|
std::fs::write(file.path(), format!("{}\n", event("first"))).expect("seed log");
|
||||||
|
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
|
||||||
|
file.as_file().set_len(0).expect("truncate");
|
||||||
|
assert!(reader.poll().is_err());
|
||||||
|
append(file.path(), format!("{}\n", event("longer replacement")).as_bytes());
|
||||||
|
assert!(reader.poll().is_err());
|
||||||
|
assert_eq!(reader.cached().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn a_replaced_path_does_not_switch_to_the_new_log() {
|
||||||
|
let dir = tempfile::tempdir().expect("log dir");
|
||||||
|
let path = dir.path().join("node.log");
|
||||||
|
std::fs::write(&path, b"").expect("log file");
|
||||||
|
let mut reader = PublicationEvents::new(vec![path.clone()], "bucket", "object").expect("open log");
|
||||||
|
std::fs::rename(&path, dir.path().join("retired.log")).expect("replace log");
|
||||||
|
std::fs::write(&path, format!("{}\n", event("other-generation"))).expect("replacement");
|
||||||
|
assert!(reader.poll().is_err());
|
||||||
|
assert!(reader.cached().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_utf8_and_oversized_or_excessive_records_fail_closed() {
|
||||||
|
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||||
|
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||||
|
append(file.path(), b"\xff\n");
|
||||||
|
assert!(reader.poll().is_err());
|
||||||
|
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||||
|
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||||
|
append(file.path(), &vec![b'x'; MAX_LINE_BYTES + 1]);
|
||||||
|
assert!(reader.poll().is_err());
|
||||||
|
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||||
|
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||||
|
for _ in 0..=MAX_EVENTS {
|
||||||
|
append(file.path(), format!("{}\n", event("attempt")).as_bytes());
|
||||||
|
}
|
||||||
|
assert!(reader.poll().is_err());
|
||||||
|
assert_eq!(reader.cached().len(), MAX_EVENTS);
|
||||||
|
assert!(reader.poll().is_err());
|
||||||
|
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||||
|
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||||
|
let mut large = event("large");
|
||||||
|
large["extra"] = json!("x".repeat(600 * 1024));
|
||||||
|
for _ in 0..2 {
|
||||||
|
append(file.path(), format!("{large}\n").as_bytes());
|
||||||
|
}
|
||||||
|
assert!(reader.poll().is_err());
|
||||||
|
assert_eq!(reader.cached().len(), 1);
|
||||||
|
assert!(reader.poll().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,6 +216,10 @@ pub(crate) struct TopologyRun {
|
|||||||
|
|
||||||
impl TopologyRun {
|
impl TopologyRun {
|
||||||
pub(crate) async fn start(label: &str) -> TestResult<Self> {
|
pub(crate) async fn start(label: &str) -> TestResult<Self> {
|
||||||
|
Self::start_with_env(label, &[]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn start_with_env(label: &str, extra_env: &[(&str, &str)]) -> TestResult<Self> {
|
||||||
let run_name = format!("scanner-heal-topology-{label}-{}", Uuid::new_v4().simple());
|
let run_name = format!("scanner-heal-topology-{label}-{}", Uuid::new_v4().simple());
|
||||||
// CI uploads RUSTFS_E2E_LOG_DIR; keep node logs, barrier observations
|
// CI uploads RUSTFS_E2E_LOG_DIR; keep node logs, barrier observations
|
||||||
// and evidence there so a failed lane can be diagnosed.
|
// and evidence there so a failed lane can be diagnosed.
|
||||||
@@ -236,6 +240,7 @@ impl TopologyRun {
|
|||||||
let mut env: Vec<(&str, &str)> = TOPOLOGY_ENV.to_vec();
|
let mut env: Vec<(&str, &str)> = TOPOLOGY_ENV.to_vec();
|
||||||
env.extend_from_slice(FAST_DATA_USAGE_SCANNER_ENV);
|
env.extend_from_slice(FAST_DATA_USAGE_SCANNER_ENV);
|
||||||
env.push((MOVEMENT_BARRIER_DIR_ENV, movement_dir_value.as_str()));
|
env.push((MOVEMENT_BARRIER_DIR_ENV, movement_dir_value.as_str()));
|
||||||
|
env.extend_from_slice(extra_env);
|
||||||
let mut dist = DistCluster::new_stopped_with_env(DistLayout::SingleNodeFourDrive, &env).await?;
|
let mut dist = DistCluster::new_stopped_with_env(DistLayout::SingleNodeFourDrive, &env).await?;
|
||||||
dist.capture_logs_under(&log_dir)?;
|
dist.capture_logs_under(&log_dir)?;
|
||||||
dist.cluster.start().await?;
|
dist.cluster.start().await?;
|
||||||
@@ -329,6 +334,11 @@ impl FileBarrier {
|
|||||||
&self.nonce
|
&self.nonce
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep the previous phase's release handle while arming a fresh nonce.
|
||||||
|
pub(crate) fn next_phase(&self) -> Self {
|
||||||
|
Self::new(self.dir.clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn write_atomic(&self, name: &str, body: &[u8]) -> TestResult {
|
fn write_atomic(&self, name: &str, body: &[u8]) -> TestResult {
|
||||||
let tmp = self.dir.join(format!(".{name}.{}.tmp", Uuid::new_v4().simple()));
|
let tmp = self.dir.join(format!(".{name}.{}.tmp", Uuid::new_v4().simple()));
|
||||||
std::fs::write(&tmp, body)?;
|
std::fs::write(&tmp, body)?;
|
||||||
@@ -360,6 +370,10 @@ impl FileBarrier {
|
|||||||
self.write_atomic(&format!("release.{}", self.nonce), b"io_error")
|
self.write_atomic(&format!("release.{}", self.nonce), b"io_error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn release_movement(&self) -> TestResult {
|
||||||
|
self.write_atomic(&format!("release.{}", self.nonce), b"continue")
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn reached_observations(&self) -> TestResult<Vec<Value>> {
|
pub(crate) fn reached_observations(&self) -> TestResult<Vec<Value>> {
|
||||||
let prefix = format!("reached.{}.", self.nonce);
|
let prefix = format!("reached.{}.", self.nonce);
|
||||||
let mut observations = Vec::new();
|
let mut observations = Vec::new();
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_w
|
|||||||
run.movement_barrier.disarm()?;
|
run.movement_barrier.disarm()?;
|
||||||
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
|
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
|
||||||
let label = "after the killed leader resumed decommission";
|
let label = "after the killed leader resumed decommission";
|
||||||
let outcome = settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await;
|
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
|
||||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||||
record(
|
record(
|
||||||
&run,
|
&run,
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
// Copyright 2026 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.
|
||||||
|
|
||||||
|
//! G-TIER: real mover processes contend with a held remote DELETE while a
|
||||||
|
//! free version owns the old remote tuple beside a live transitioned version.
|
||||||
|
//! The remote is the HTTP fake S3 target, not a provider interoperability test.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DECOMMISSION_POOL_ID, DRIVES_PER_NODE, TestResult, cluster_admin_ok, decommission_status_json, enable_versioning,
|
||||||
|
rebalance_status_json, sha256_hex, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||||
|
wait_for_rebalance_complete,
|
||||||
|
};
|
||||||
|
use super::publication_log::PublicationEvents;
|
||||||
|
use super::scanner_heal_topology_fixture::{
|
||||||
|
MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL, STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP,
|
||||||
|
TopologyRun, assert_bucket_durability_strict, assert_process_durability_strict, require_test_hooks,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation, RequestRecord};
|
||||||
|
use crate::storage_api::OBJECT_LOCK_TRACE_FILTER;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{
|
||||||
|
BucketLifecycleConfiguration, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, Transition, TransitionStorageClass,
|
||||||
|
};
|
||||||
|
use http::Method;
|
||||||
|
use rustfs_filemeta::{FileMeta, TransitionVersionState};
|
||||||
|
use rustfs_utils::http::{SUFFIX_TRANSITION_TIER_DESTINATION_ID, get_consistent_str};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
|
use tokio::time::{Instant, sleep, timeout, timeout_at};
|
||||||
|
|
||||||
|
const TIER: &str = "TOPOLOGYCOLD";
|
||||||
|
const KEY: &str = "000-tier-owner.bin";
|
||||||
|
const TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
|
||||||
|
// Free-version cleanup holds its locks for at most 30 s during remote DELETE.
|
||||||
|
// Fail before that deadline instead of confusing a retry with the held attempt.
|
||||||
|
const DELETE_OVERLAP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
struct RemoteTuple {
|
||||||
|
bucket: String,
|
||||||
|
key: String,
|
||||||
|
version: String,
|
||||||
|
tier: String,
|
||||||
|
destination: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||||
|
struct Owner {
|
||||||
|
pool: usize,
|
||||||
|
drive: usize,
|
||||||
|
version: String,
|
||||||
|
free: bool,
|
||||||
|
remote: RemoteTuple,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owners(run: &TopologyRun, bucket: &str, remote_bucket: &str) -> TestResult<Vec<Owner>> {
|
||||||
|
let mut owners = Vec::new();
|
||||||
|
for node in &run.dist.cluster.nodes {
|
||||||
|
for (drive, root) in node.data_dirs.iter().enumerate() {
|
||||||
|
let bytes = match std::fs::read(Path::new(root).join(bucket).join(KEY).join("xl.meta")) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
for version in FileMeta::load(&bytes)?.get_file_info_versions(bucket, KEY, true)?.versions {
|
||||||
|
if version.transition_status != "complete" && !version.tier_free_version() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert_eq!(version.transition_version_state, TransitionVersionState::Exact);
|
||||||
|
owners.push(Owner {
|
||||||
|
pool: node.pool_idx,
|
||||||
|
drive,
|
||||||
|
version: version.version_id.ok_or("tier owner omitted its local version")?.to_string(),
|
||||||
|
free: version.tier_free_version(),
|
||||||
|
remote: RemoteTuple {
|
||||||
|
bucket: remote_bucket.to_string(),
|
||||||
|
key: version.transitioned_objname.clone(),
|
||||||
|
version: version
|
||||||
|
.transition_version
|
||||||
|
.clone()
|
||||||
|
.ok_or("tier owner omitted its remote version")?,
|
||||||
|
tier: version.transition_tier.clone(),
|
||||||
|
destination: get_consistent_str(&version.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID)
|
||||||
|
.ok_or("tier owner omitted its destination identity")?
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(owners)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_remote(target: &FakeS3Target, remote: &RemoteTuple, expected: &[u8]) -> TestResult {
|
||||||
|
assert_eq!(target.stored_versions(&remote.bucket, &remote.key), vec![(remote.version.clone(), false)]);
|
||||||
|
let (body, metadata) = target
|
||||||
|
.stored_object(&remote.bucket, &remote.key)
|
||||||
|
.ok_or("remote bytes are missing")?;
|
||||||
|
assert_eq!(body.as_ref(), expected, "remote bytes changed for {remote:?}");
|
||||||
|
assert_eq!(
|
||||||
|
get_consistent_str(&metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID),
|
||||||
|
Some(remote.destination.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(remote.tier, TIER);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transition_version(
|
||||||
|
run: &TopologyRun,
|
||||||
|
target: &FakeS3Target,
|
||||||
|
bucket: &str,
|
||||||
|
remote_bucket: &str,
|
||||||
|
body: &[u8],
|
||||||
|
) -> TestResult<(String, RemoteTuple)> {
|
||||||
|
let client = run.dist.client(0)?;
|
||||||
|
let put = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(KEY)
|
||||||
|
.body(ByteStream::from(body.to_vec()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let version = put.version_id().ok_or("versioned PUT omitted its version")?.to_string();
|
||||||
|
cluster_admin_ok(
|
||||||
|
&run.dist.cluster,
|
||||||
|
Method::POST,
|
||||||
|
&format!("/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={KEY}&tier={TIER}&dryRun=false"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let deadline = Instant::now() + TRANSITION_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let current = owners(run, bucket, remote_bucket)?;
|
||||||
|
let shards: Vec<_> = current
|
||||||
|
.iter()
|
||||||
|
.filter(|owner| owner.version == version && !owner.free)
|
||||||
|
.collect();
|
||||||
|
if let Some(first) = shards.first()
|
||||||
|
&& shards.len() == DRIVES_PER_NODE
|
||||||
|
&& shards
|
||||||
|
.iter()
|
||||||
|
.all(|owner| owner.pool == SEED_POOL && owner.remote == first.remote)
|
||||||
|
{
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(KEY)
|
||||||
|
.version_id(&version)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(head.storage_class().map(|class| class.as_str()), Some(TIER));
|
||||||
|
assert_remote(target, &first.remote, body)?;
|
||||||
|
return Ok((version, first.remote.clone()));
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(format!("transition of {bucket}/{KEY}@{version} did not reach every seed drive: {current:?}").into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_time(event: &Value) -> TestResult<OffsetDateTime> {
|
||||||
|
Ok(OffsetDateTime::parse(
|
||||||
|
event["timestamp"].as_str().ok_or("lock event omitted its timestamp")?,
|
||||||
|
&Rfc3339,
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pending_publication(events: &mut PublicationEvents, started: OffsetDateTime) -> TestResult<Value> {
|
||||||
|
loop {
|
||||||
|
let observed = events.poll()?;
|
||||||
|
for event in observed {
|
||||||
|
if event_time(event)? < started {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
event["state"], "acquired",
|
||||||
|
"mover acquired its publication lock while remote DELETE was held"
|
||||||
|
);
|
||||||
|
if event["state"] == "acquiring"
|
||||||
|
&& !observed
|
||||||
|
.iter()
|
||||||
|
.any(|later| later["lock_attempt_id"] == event["lock_attempt_id"] && later["state"] != "acquiring")
|
||||||
|
{
|
||||||
|
assert!(event["lock_attempt_id"].as_str().is_some_and(|id| !id.is_empty()));
|
||||||
|
return Ok(event.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn acquired_publication(events: &mut PublicationEvents, pending: &Value) -> TestResult<Value> {
|
||||||
|
loop {
|
||||||
|
for event in events.poll()? {
|
||||||
|
if event["lock_attempt_id"] != pending["lock_attempt_id"] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match event["state"].as_str() {
|
||||||
|
Some("acquired") => return Ok(event.clone()),
|
||||||
|
Some("failed" | "cancelled") => return Err(format!("held publication attempt did not acquire: {event}").into()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deletes(target: &FakeS3Target, remote: &RemoteTuple) -> Vec<RequestRecord> {
|
||||||
|
target
|
||||||
|
.requests()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|request| {
|
||||||
|
request.operation == Operation::DeleteObject
|
||||||
|
&& request.bucket == remote.bucket
|
||||||
|
&& request.key.as_deref() == Some(remote.key.as_str())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_live(run: &TopologyRun, bucket: &str, live_version: &str, old_version: &str, body: &[u8]) -> TestResult {
|
||||||
|
for client in run.dist.clients()? {
|
||||||
|
let got = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(KEY)
|
||||||
|
.version_id(live_version)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(got.body.collect().await?.into_bytes().as_ref(), body);
|
||||||
|
let latest = client.head_object().bucket(bucket).key(KEY).send().await?;
|
||||||
|
assert_eq!(latest.version_id(), Some(live_version));
|
||||||
|
assert_eq!(latest.storage_class().map(|class| class.as_str()), Some(TIER));
|
||||||
|
let deleted = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(KEY)
|
||||||
|
.version_id(old_version)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
let error = deleted.expect_err("deleted source version must not be visible");
|
||||||
|
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
let listed = client.list_object_versions().bucket(bucket).prefix(KEY).send().await?;
|
||||||
|
assert_eq!(listed.versions().len(), 1, "free owners must not appear as object versions");
|
||||||
|
assert_eq!(listed.versions()[0].version_id(), Some(live_version));
|
||||||
|
assert!(
|
||||||
|
listed.delete_markers().is_empty(),
|
||||||
|
"moving the free owner must not create a delete marker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tier_delete_during_movement(decommission: bool) -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
require_test_hooks();
|
||||||
|
let operation = if decommission { "decommission" } else { "rebalance" };
|
||||||
|
let target = FakeS3Target::start().await?;
|
||||||
|
let remote_bucket = unique_bucket("sht-cold");
|
||||||
|
target.create_bucket(&remote_bucket);
|
||||||
|
let mut run = TopologyRun::start_with_env(
|
||||||
|
&format!("tier-{operation}"),
|
||||||
|
&[
|
||||||
|
("RUSTFS_TIER_RUSTFS_ALLOW_LOOPBACK_ENDPOINT", "true"),
|
||||||
|
("RUSTFS_OBJECT_LOCK_DIAG_ENABLE", "true"),
|
||||||
|
("RUST_LOG", OBJECT_LOCK_TRACE_FILTER),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let tier = json!({
|
||||||
|
"type": "rustfs",
|
||||||
|
"rustfs": {
|
||||||
|
"name": TIER, "endpoint": target.endpoint(), "accessKey": FAKE_ACCESS_KEY,
|
||||||
|
"secretKey": FAKE_SECRET_KEY, "bucket": remote_bucket, "prefix": "",
|
||||||
|
"region": "us-east-1", "storageClass": ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
match cluster_admin_ok(&run.dist.cluster, Method::PUT, "/rustfs/admin/v3/tier", Some(tier.to_string())).await {
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(_) if Instant::now() < deadline => sleep(Duration::from_millis(250)).await,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bucket = unique_bucket("sht-tier");
|
||||||
|
run.dist.create_bucket(&bucket).await?;
|
||||||
|
let client = run.dist.client(0)?;
|
||||||
|
enable_versioning(&client, &bucket).await?;
|
||||||
|
let rule = LifecycleRule::builder()
|
||||||
|
.id("tier-owner")
|
||||||
|
.status(ExpirationStatus::Enabled)
|
||||||
|
.filter(LifecycleRuleFilter::builder().prefix(KEY).build())
|
||||||
|
.transitions(
|
||||||
|
Transition::builder()
|
||||||
|
.days(0)
|
||||||
|
.storage_class(TransitionStorageClass::from(TIER))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
client
|
||||||
|
.put_bucket_lifecycle_configuration()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.lifecycle_configuration(BucketLifecycleConfiguration::builder().rules(rule).build()?)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let old_body = vec![0x31; 4096];
|
||||||
|
let live_body = vec![0x92; 8192];
|
||||||
|
let (old_version, old_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &old_body).await?;
|
||||||
|
let (live_version, live_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &live_body).await?;
|
||||||
|
assert_ne!(
|
||||||
|
old_remote.key, live_remote.key,
|
||||||
|
"the obsolete and live versions need distinct remote tuples"
|
||||||
|
);
|
||||||
|
// Keep the seed pool above the rebalance goal until the first key is visited.
|
||||||
|
// These local objects are outside the transition rule's prefix.
|
||||||
|
for index in 0..16 {
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(format!("zz-load/{index:02}.bin"))
|
||||||
|
.body(ByteStream::from(vec![index as u8; 1024 * 1024]))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
|
||||||
|
run.dist.expand_to_four_pools().await?;
|
||||||
|
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
|
||||||
|
let seeded = owners(&run, &bucket, &remote_bucket)?;
|
||||||
|
assert_eq!(seeded.len(), 2 * DRIVES_PER_NODE);
|
||||||
|
assert!(seeded.iter().all(|owner| owner.pool == SEED_POOL && !owner.free));
|
||||||
|
|
||||||
|
let mut decommission_start = Box::pin(start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID));
|
||||||
|
let mut startup = Value::Null;
|
||||||
|
if decommission {
|
||||||
|
run.movement_barrier.arm_movement(
|
||||||
|
"decommission_start",
|
||||||
|
".rustfs.sys",
|
||||||
|
"pool.bin",
|
||||||
|
STAGE_AFTER_RESPONSIBILITY_PERSISTED,
|
||||||
|
)?;
|
||||||
|
let requested_at = OffsetDateTime::now_utc();
|
||||||
|
let preparation_started = Instant::now();
|
||||||
|
let reached = tokio::select! {
|
||||||
|
reached = run.movement_barrier.wait_reached(MOVEMENT_COMPLETE_TIMEOUT) => {
|
||||||
|
reached.map_err(|error| format!(
|
||||||
|
"G-TIER decommission start preparation failed before DELETE: {error}"
|
||||||
|
))?
|
||||||
|
}
|
||||||
|
result = &mut decommission_start => {
|
||||||
|
return Err(format!(
|
||||||
|
"G-TIER decommission start preparation returned before durable-start barrier {}: {result:?}",
|
||||||
|
run.movement_barrier.nonce()
|
||||||
|
).into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
startup = json!({
|
||||||
|
"requested_at": requested_at.format(&Rfc3339)?,
|
||||||
|
"parked_at": OffsetDateTime::now_utc().format(&Rfc3339)?,
|
||||||
|
"preparation_elapsed_ms": preparation_started.elapsed().as_millis(),
|
||||||
|
"barrier": reached,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The durable-start barrier has released the movement writer but has not
|
||||||
|
// started a worker. Install a separate release nonce before holding DELETE.
|
||||||
|
let cleanup_barrier = run.movement_barrier.next_phase();
|
||||||
|
cleanup_barrier.arm_movement(operation, &bucket, KEY, STAGE_BEFORE_SOURCE_CLEANUP)?;
|
||||||
|
let mut publication_events = PublicationEvents::new(run.node_log_paths(), &bucket, KEY)?;
|
||||||
|
let mut delete_gate = target.hold_delete_object(&remote_bucket, &old_remote.key);
|
||||||
|
// The public DELETE persists a free owner and enqueues its remote cleanup.
|
||||||
|
let client = run.dist.client(0)?;
|
||||||
|
client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(KEY)
|
||||||
|
.version_id(&old_version)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("G-TIER {operation} public DELETE before remote gate failed: {error}; startup={startup}"))?;
|
||||||
|
timeout(TRANSITION_TIMEOUT, delete_gate.wait_until_entered())
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("G-TIER {operation} DELETE gate entry timed out: {error}; startup={startup}"))?
|
||||||
|
.map_err(|error| format!("G-TIER {operation} DELETE gate entry failed: {error}; startup={startup}"))?;
|
||||||
|
let delete_gate_entered_at = OffsetDateTime::now_utc();
|
||||||
|
let overlap_started = Instant::now();
|
||||||
|
let overlap_deadline = overlap_started + DELETE_OVERLAP_TIMEOUT;
|
||||||
|
let held = owners(&run, &bucket, &remote_bucket)?;
|
||||||
|
assert_eq!(held.len(), 2 * DRIVES_PER_NODE);
|
||||||
|
let old_owners: Vec<_> = held.iter().filter(|owner| owner.remote == old_remote).collect();
|
||||||
|
assert_eq!(old_owners.len(), DRIVES_PER_NODE);
|
||||||
|
assert!(
|
||||||
|
old_owners
|
||||||
|
.iter()
|
||||||
|
.all(|owner| owner.pool == SEED_POOL && owner.free && owner.version != old_version)
|
||||||
|
);
|
||||||
|
assert_eq!(old_owners.iter().map(|owner| &owner.version).collect::<BTreeSet<_>>().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
held.iter()
|
||||||
|
.filter(|owner| {
|
||||||
|
owner.pool == SEED_POOL && !owner.free && owner.version == live_version && owner.remote == live_remote
|
||||||
|
})
|
||||||
|
.count(),
|
||||||
|
DRIVES_PER_NODE
|
||||||
|
);
|
||||||
|
assert_remote(&target, &old_remote, &old_body)?;
|
||||||
|
assert_remote(&target, &live_remote, &live_body)?;
|
||||||
|
let held_deletes = deletes(&target, &old_remote);
|
||||||
|
assert!(!held_deletes.is_empty());
|
||||||
|
assert!(held_deletes.iter().all(|request| {
|
||||||
|
request.prepared_response.is_none() && request.version_id.as_deref() == Some(old_remote.version.as_str())
|
||||||
|
}));
|
||||||
|
assert!(deletes(&target, &live_remote).is_empty());
|
||||||
|
|
||||||
|
let movement_started = OffsetDateTime::now_utc();
|
||||||
|
if decommission {
|
||||||
|
startup["released_at"] = json!(movement_started.format(&Rfc3339)?);
|
||||||
|
run.movement_barrier.release_movement()?;
|
||||||
|
}
|
||||||
|
let rebalance_id = timeout_at(overlap_deadline, async {
|
||||||
|
if decommission {
|
||||||
|
decommission_start.as_mut().await?;
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(start_rebalance(&run.dist.cluster).await?))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("G-TIER {operation} start acknowledgment timed out within DELETE overlap: {error}; startup={startup}")
|
||||||
|
})?
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("G-TIER {operation} start acknowledgment failed within DELETE overlap: {error}; startup={startup}")
|
||||||
|
})?;
|
||||||
|
drop(decommission_start);
|
||||||
|
let movement_acknowledged_at = OffsetDateTime::now_utc();
|
||||||
|
let pending = timeout_at(overlap_deadline, pending_publication(&mut publication_events, movement_started))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!(
|
||||||
|
"G-TIER {operation} publication pending timed out within DELETE overlap: {error}; startup={startup}; lock_events={:?}; cleanup_barrier={:?}",
|
||||||
|
publication_events.cached(), cleanup_barrier.reached_observations()
|
||||||
|
))?
|
||||||
|
.map_err(|error| format!("G-TIER {operation} publication pending failed within DELETE overlap: {error}"))?;
|
||||||
|
// Positive gate entry, exact physical ownership and the pending lock attempt
|
||||||
|
// establish the overlap; an absent log alone is not an ordering assertion.
|
||||||
|
assert_eq!(owners(&run, &bucket, &remote_bucket)?, held);
|
||||||
|
assert_remote(&target, &old_remote, &old_body)?;
|
||||||
|
let released_at = OffsetDateTime::now_utc();
|
||||||
|
let delete_overlap_elapsed_ms = overlap_started.elapsed().as_millis();
|
||||||
|
drop(delete_gate);
|
||||||
|
let acquired = timeout(DELETE_OVERLAP_TIMEOUT, acquired_publication(&mut publication_events, &pending))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!(
|
||||||
|
"G-TIER {operation} publication acquired after DELETE release timed out: {error}; pending={pending}; lock_events={:?}; cleanup_barrier={:?}",
|
||||||
|
publication_events.cached(), cleanup_barrier.reached_observations()
|
||||||
|
))?
|
||||||
|
.map_err(|error| format!("G-TIER {operation} publication acquired after DELETE release failed: {error}"))?;
|
||||||
|
assert!(event_time(&pending)? >= movement_started && event_time(&pending)? < released_at);
|
||||||
|
assert!(
|
||||||
|
event_time(&acquired)? >= released_at,
|
||||||
|
"the same publication attempt must acquire after DELETE is released"
|
||||||
|
);
|
||||||
|
drop(publication_events);
|
||||||
|
let reached = cleanup_barrier
|
||||||
|
.wait_reached_during(
|
||||||
|
&run.dist.cluster,
|
||||||
|
match rebalance_id.as_deref() {
|
||||||
|
Some(id) => MovementOperation::Rebalance(id),
|
||||||
|
None => MovementOperation::Decommission(DECOMMISSION_POOL_ID),
|
||||||
|
},
|
||||||
|
MOVEMENT_COMPLETE_TIMEOUT,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("G-TIER {operation} before-source-cleanup barrier failed: {error}"))?;
|
||||||
|
let published = owners(&run, &bucket, &remote_bucket)?;
|
||||||
|
assert!(
|
||||||
|
published
|
||||||
|
.iter()
|
||||||
|
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
|
||||||
|
);
|
||||||
|
let publication_pools: BTreeSet<_> = published.iter().map(|owner| owner.pool).collect();
|
||||||
|
assert!(publication_pools.contains(&SEED_POOL) && publication_pools.iter().any(|pool| *pool != SEED_POOL));
|
||||||
|
assert_eq!(published.len(), publication_pools.len() * DRIVES_PER_NODE);
|
||||||
|
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
|
||||||
|
assert_remote(&target, &live_remote, &live_body)?;
|
||||||
|
cleanup_barrier.release_movement()?;
|
||||||
|
cleanup_barrier.disarm()?;
|
||||||
|
let completion = match rebalance_id.as_deref() {
|
||||||
|
Some(id) => {
|
||||||
|
wait_for_rebalance_complete(&run.dist.cluster, id, MOVEMENT_COMPLETE_TIMEOUT).await?;
|
||||||
|
rebalance_status_json(&run.dist.cluster).await?
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_COMPLETE_TIMEOUT).await?;
|
||||||
|
decommission_status_json(&run.dist.cluster).await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let settled = owners(&run, &bucket, &remote_bucket)?;
|
||||||
|
assert_eq!(settled.len(), DRIVES_PER_NODE);
|
||||||
|
assert_eq!(settled.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
|
||||||
|
assert!(
|
||||||
|
settled
|
||||||
|
.iter()
|
||||||
|
.all(|owner| owner.pool != SEED_POOL && !owner.free && owner.remote == live_remote)
|
||||||
|
);
|
||||||
|
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
|
||||||
|
if decommission {
|
||||||
|
run.dist.remove_retired_pools_and_restart(&[SEED_POOL]).await?;
|
||||||
|
} else {
|
||||||
|
run.dist.restart_current_binary_gracefully().await?;
|
||||||
|
}
|
||||||
|
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
|
||||||
|
let restarted = owners(&run, &bucket, &remote_bucket)?;
|
||||||
|
assert_eq!(restarted.len(), DRIVES_PER_NODE);
|
||||||
|
assert_eq!(restarted.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
|
||||||
|
assert!(
|
||||||
|
restarted
|
||||||
|
.iter()
|
||||||
|
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
|
||||||
|
);
|
||||||
|
assert_remote(&target, &live_remote, &live_body)?;
|
||||||
|
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
|
||||||
|
assert!(
|
||||||
|
deletes(&target, &live_remote).is_empty(),
|
||||||
|
"the live transitioned tuple must never receive DELETE"
|
||||||
|
);
|
||||||
|
let remote_deletes = deletes(&target, &old_remote);
|
||||||
|
assert!(!remote_deletes.is_empty());
|
||||||
|
assert!(
|
||||||
|
remote_deletes
|
||||||
|
.iter()
|
||||||
|
.all(|request| request.version_id.as_deref() == Some(old_remote.version.as_str()))
|
||||||
|
);
|
||||||
|
let successful = remote_deletes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|request| request.prepared_response.as_ref())
|
||||||
|
.find(|response| response.status == 204)
|
||||||
|
.ok_or("old remote tuple never completed DELETE")?;
|
||||||
|
let deleted_at_ms = i128::from(
|
||||||
|
successful
|
||||||
|
.prepared_at_unix_millis
|
||||||
|
.ok_or("DELETE response omitted its timestamp")?,
|
||||||
|
);
|
||||||
|
assert!(deleted_at_ms >= released_at.unix_timestamp_nanos() / 1_000_000);
|
||||||
|
assert!(event_time(&acquired)?.unix_timestamp_nanos() / 1_000_000 >= deleted_at_ms);
|
||||||
|
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
|
||||||
|
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
|
||||||
|
run.write_evidence(
|
||||||
|
&format!("g-tier-{operation}"),
|
||||||
|
&json!({
|
||||||
|
"gate": "G-TIER", "operation": operation, "rebalance_id": rebalance_id,
|
||||||
|
"startup": startup,
|
||||||
|
"delete_gate_entered_at": delete_gate_entered_at.format(&Rfc3339)?,
|
||||||
|
"movement_overlap_started_at": movement_started.format(&Rfc3339)?,
|
||||||
|
"movement_acknowledged_at": movement_acknowledged_at.format(&Rfc3339)?,
|
||||||
|
"delete_overlap_timeout_ms": DELETE_OVERLAP_TIMEOUT.as_millis(),
|
||||||
|
"delete_overlap_elapsed_ms": delete_overlap_elapsed_ms,
|
||||||
|
"remote_backend": "HTTP fake_s3_target with versioning enabled",
|
||||||
|
"ordering": "remote DELETE held, mover publication waiting, DELETE released, same publication acquired",
|
||||||
|
"bucket": bucket, "key": KEY, "bucket_durability": bucket_durability,
|
||||||
|
"process_durability_strict_nodes": strict_nodes,
|
||||||
|
"old_version": old_version, "old_remote": old_remote, "old_sha256": sha256_hex(&old_body),
|
||||||
|
"live_version": live_version, "live_remote": live_remote, "live_sha256": sha256_hex(&live_body),
|
||||||
|
"owners_while_delete_held": held, "publication_waiting": pending, "publication_acquired": acquired,
|
||||||
|
"held_delete_sequences": held_deletes.iter().map(|request| request.sequence).collect::<Vec<_>>(),
|
||||||
|
"delete_released_at": released_at.format(&Rfc3339)?, "remote_delete_completed_at_unix_millis": deleted_at_ms,
|
||||||
|
"barrier": reached, "owners_before_source_cleanup": published, "owners_after_movement": settled,
|
||||||
|
"owners_after_restart": restarted, "completion": completion,
|
||||||
|
"old_remote_delete_attempts": remote_deletes.len(), "live_remote_delete_attempts": 0,
|
||||||
|
"source_readback_nodes": run.dist.cluster.nodes.len(),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_rebalance() -> TestResult {
|
||||||
|
tier_delete_during_movement(false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_decommission() -> TestResult {
|
||||||
|
tier_delete_during_movement(true).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Copyright 2026 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 super::harness::{DistCluster, DistLayout, TestResult, get_object_bytes, put_object, unique_bucket};
|
||||||
|
use crate::common::{init_logging, signed_request};
|
||||||
|
use http::{Method, StatusCode};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
async fn catalog_request(
|
||||||
|
dist: &DistCluster,
|
||||||
|
node: usize,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<Value>,
|
||||||
|
expected: StatusCode,
|
||||||
|
) -> TestResult<Value> {
|
||||||
|
let response = signed_request(
|
||||||
|
method.clone(),
|
||||||
|
&format!("{}{path}", dist.cluster.nodes[node].url),
|
||||||
|
&dist.cluster.access_key,
|
||||||
|
&dist.cluster.secret_key,
|
||||||
|
body.map(|value| serde_json::to_vec(&value)).transpose()?,
|
||||||
|
Some("application/json"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await?;
|
||||||
|
assert_eq!(status, expected, "node {node}: {method} {path}: {text}");
|
||||||
|
if text.is_empty() {
|
||||||
|
Ok(Value::Null)
|
||||||
|
} else {
|
||||||
|
Ok(serde_json::from_str(&text)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_bucket_state(dist: &DistCluster, bucket: &str, enabled: bool) -> TestResult {
|
||||||
|
for node in 0..dist.cluster.nodes.len() {
|
||||||
|
let response =
|
||||||
|
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/buckets/{bucket}"), None, StatusCode::OK).await?;
|
||||||
|
assert_eq!(response["enabled"], enabled, "node {node}: {response}");
|
||||||
|
assert_eq!(response["table-bucket"], bucket);
|
||||||
|
let expected = if enabled { StatusCode::OK } else { StatusCode::BAD_REQUEST };
|
||||||
|
catalog_request(dist, node, Method::GET, &format!("/iceberg/v1/{bucket}/namespaces"), None, expected).await?;
|
||||||
|
assert_eq!(
|
||||||
|
get_object_bytes(&dist.client(node)?, bucket, "ordinary.txt").await?,
|
||||||
|
b"ordinary bucket object",
|
||||||
|
"node {node}: toggling table support changed an ordinary object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_table_state(dist: &DistCluster, table_path: &str, created: &Value, pointer: &Value) -> TestResult {
|
||||||
|
for node in 0..dist.cluster.nodes.len() {
|
||||||
|
let loaded = catalog_request(dist, node, Method::GET, table_path, None, StatusCode::OK).await?;
|
||||||
|
assert_eq!(loaded["metadata-location"], created["metadata-location"], "node {node}");
|
||||||
|
assert_eq!(loaded["metadata"], created["metadata"], "node {node}");
|
||||||
|
let actual_pointer =
|
||||||
|
catalog_request(dist, node, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
|
||||||
|
assert_eq!(&actual_pointer, pointer, "node {node}: catalog pointer changed");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn table_bucket_toggles_reload_cached_metadata_on_every_peer() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_TABLE_CATALOG_BACKING", "object")]).await?;
|
||||||
|
let bucket = unique_bucket("table-peer-reload");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
put_object(&dist.client(0)?, &bucket, "ordinary.txt", b"ordinary bucket object".to_vec()).await?;
|
||||||
|
let bucket_path = format!("/iceberg/v1/buckets/{bucket}");
|
||||||
|
|
||||||
|
// Warm each node's false cache before changing the marker. Do not poll:
|
||||||
|
// successful toggles must notify peers before their response is returned.
|
||||||
|
assert_bucket_state(&dist, &bucket, false).await?;
|
||||||
|
for (node, method, enabled) in [(0, Method::PUT, true), (1, Method::DELETE, false), (2, Method::PUT, true)] {
|
||||||
|
let response = catalog_request(&dist, node, method, &bucket_path, None, StatusCode::OK).await?;
|
||||||
|
assert_eq!(response["enabled"], enabled);
|
||||||
|
assert_bucket_state(&dist, &bucket, enabled).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let namespaces = format!("/iceberg/v1/{bucket}/namespaces");
|
||||||
|
catalog_request(&dist, 3, Method::POST, &namespaces, Some(json!({"namespace": ["lab"]})), StatusCode::OK).await?;
|
||||||
|
let tables = format!("{namespaces}/lab/tables");
|
||||||
|
let created = catalog_request(
|
||||||
|
&dist,
|
||||||
|
0,
|
||||||
|
Method::POST,
|
||||||
|
&tables,
|
||||||
|
Some(json!({
|
||||||
|
"name": "events",
|
||||||
|
"schema": {
|
||||||
|
"type": "struct",
|
||||||
|
"fields": [{"id": 1, "name": "id", "required": false, "type": "long"}]
|
||||||
|
},
|
||||||
|
"stage-create": false
|
||||||
|
})),
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let uuid = created["metadata"]["table-uuid"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("create response omitted table UUID")?;
|
||||||
|
assert!(!uuid::Uuid::parse_str(uuid)?.is_nil());
|
||||||
|
assert!(
|
||||||
|
created["metadata-location"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|location| location.starts_with(&format!("s3://{bucket}/")))
|
||||||
|
);
|
||||||
|
let table_path = format!("{tables}/events");
|
||||||
|
let pointer =
|
||||||
|
catalog_request(&dist, 3, Method::GET, &format!("{table_path}/metadata-location"), None, StatusCode::OK).await?;
|
||||||
|
assert_eq!(pointer["metadata-location"], created["metadata-location"]);
|
||||||
|
assert!(pointer["version-token"].as_str().is_some_and(|token| !token.is_empty()));
|
||||||
|
assert!(pointer["generation"].as_u64().is_some());
|
||||||
|
assert_eq!(pointer["warehouse-location"], created["metadata"]["location"]);
|
||||||
|
assert_table_state(&dist, &table_path, &created, &pointer).await?;
|
||||||
|
|
||||||
|
// A rejected toggle must preserve both peer protections and catalog state.
|
||||||
|
catalog_request(&dist, 1, Method::DELETE, &bucket_path, None, StatusCode::CONFLICT).await?;
|
||||||
|
assert_bucket_state(&dist, &bucket, true).await?;
|
||||||
|
assert_table_state(&dist, &table_path, &created, &pointer).await?;
|
||||||
|
|
||||||
|
catalog_request(&dist, 2, Method::DELETE, &table_path, None, StatusCode::NO_CONTENT).await?;
|
||||||
|
catalog_request(&dist, 3, Method::DELETE, &format!("{namespaces}/lab"), None, StatusCode::NO_CONTENT).await?;
|
||||||
|
// The cluster fixture owns all remaining catalog objects and data roots.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ struct UpgradeSeed {
|
|||||||
iam_secret: &'static str,
|
iam_secret: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source_binary() -> TestResult<PathBuf> {
|
pub(super) fn source_binary() -> TestResult<PathBuf> {
|
||||||
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
|
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
|
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
|
||||||
|
|||||||
@@ -487,36 +487,43 @@ impl JournaledHeaders {
|
|||||||
struct ControlState {
|
struct ControlState {
|
||||||
scripts: HashMap<Operation, VecDeque<FaultAction>>,
|
scripts: HashMap<Operation, VecDeque<FaultAction>>,
|
||||||
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
|
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
|
||||||
held_get: Option<HeldGetObject>,
|
held_get: Option<HeldObject>,
|
||||||
|
held_delete: Option<HeldObject>,
|
||||||
requests: VecDeque<RequestRecord>,
|
requests: VecDeque<RequestRecord>,
|
||||||
next_sequence: u64,
|
next_sequence: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct HeldGetObject {
|
struct HeldObject {
|
||||||
bucket: String,
|
bucket: String,
|
||||||
key: String,
|
key: String,
|
||||||
entered: watch::Sender<usize>,
|
entered: watch::Sender<usize>,
|
||||||
released: watch::Receiver<bool>,
|
released: watch::Receiver<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holds every GET of one object, including retries, until this guard is dropped.
|
/// Holds every matching operation, including retries, until this guard is dropped.
|
||||||
#[must_use = "dropping the guard releases the held GET requests"]
|
#[must_use = "dropping the guard releases the held requests"]
|
||||||
pub struct GetObjectGate {
|
pub struct ObjectGate {
|
||||||
control: Arc<Mutex<ControlState>>,
|
control: Arc<Mutex<ControlState>>,
|
||||||
|
operation: Operation,
|
||||||
entered: watch::Receiver<usize>,
|
entered: watch::Receiver<usize>,
|
||||||
released: watch::Sender<bool>,
|
released: watch::Sender<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GetObjectGate {
|
impl ObjectGate {
|
||||||
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
|
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
|
||||||
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
|
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for GetObjectGate {
|
impl Drop for ObjectGate {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
lock(&self.control).held_get = None;
|
let mut state = lock(&self.control);
|
||||||
|
match self.operation {
|
||||||
|
Operation::GetObject => state.held_get = None,
|
||||||
|
Operation::DeleteObject => state.held_delete = None,
|
||||||
|
_ => unreachable!("only GET and DELETE have object gates"),
|
||||||
|
}
|
||||||
self.released.send_replace(true);
|
self.released.send_replace(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1108,24 +1115,40 @@ impl FakeS3Target {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Hold one exact bucket/key before any GET response can reach the client.
|
/// Hold one exact bucket/key before any GET response can reach the client.
|
||||||
/// The fixture supports one live gate; request and connection deadlines still apply.
|
/// The fixture supports one live GET gate; request and connection deadlines still apply.
|
||||||
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
|
pub fn hold_get_object(&self, bucket: &str, key: &str) -> ObjectGate {
|
||||||
|
self.hold_object_operation(Operation::GetObject, bucket, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hold one exact bucket/key before DELETE changes any stored version.
|
||||||
|
/// The fixture supports one live DELETE gate; request deadlines still apply.
|
||||||
|
pub fn hold_delete_object(&self, bucket: &str, key: &str) -> ObjectGate {
|
||||||
|
self.hold_object_operation(Operation::DeleteObject, bucket, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hold_object_operation(&self, operation: Operation, bucket: &str, key: &str) -> ObjectGate {
|
||||||
assert!(
|
assert!(
|
||||||
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
|
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
|
||||||
"held GET identifiers exceed the fixture limit"
|
"held object identifiers exceed the fixture limit"
|
||||||
);
|
);
|
||||||
let mut state = lock(&self.control);
|
let mut state = lock(&self.control);
|
||||||
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
|
let slot = match operation {
|
||||||
|
Operation::GetObject => &mut state.held_get,
|
||||||
|
Operation::DeleteObject => &mut state.held_delete,
|
||||||
|
_ => unreachable!("only GET and DELETE have object gates"),
|
||||||
|
};
|
||||||
|
assert!(slot.is_none(), "fake target already holds a {operation:?} gate");
|
||||||
let (entered, entered_rx) = watch::channel(0);
|
let (entered, entered_rx) = watch::channel(0);
|
||||||
let (released, released_rx) = watch::channel(false);
|
let (released, released_rx) = watch::channel(false);
|
||||||
state.held_get = Some(HeldGetObject {
|
*slot = Some(HeldObject {
|
||||||
bucket: bucket.to_string(),
|
bucket: bucket.to_string(),
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
entered,
|
entered,
|
||||||
released: released_rx,
|
released: released_rx,
|
||||||
});
|
});
|
||||||
GetObjectGate {
|
ObjectGate {
|
||||||
control: Arc::clone(&self.control),
|
control: Arc::clone(&self.control),
|
||||||
|
operation,
|
||||||
entered: entered_rx,
|
entered: entered_rx,
|
||||||
released,
|
released,
|
||||||
}
|
}
|
||||||
@@ -2795,6 +2818,15 @@ impl S3 for FakeBackend {
|
|||||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||||
let headers = req.headers;
|
let headers = req.headers;
|
||||||
let input = req.input;
|
let input = req.input;
|
||||||
|
let held_delete = lock(&self.control)
|
||||||
|
.held_delete
|
||||||
|
.as_ref()
|
||||||
|
.filter(|held| held.bucket == input.bucket && held.key == input.key)
|
||||||
|
.cloned();
|
||||||
|
if let Some(mut held) = held_delete {
|
||||||
|
held.entered.send_modify(|count| *count += 1);
|
||||||
|
let _ = held.released.wait_for(|released| *released).await;
|
||||||
|
}
|
||||||
let mut state = lock(&self.store);
|
let mut state = lock(&self.store);
|
||||||
let Some(versioned) = state.buckets.get(&input.bucket).map(|bucket| bucket.versioned) else {
|
let Some(versioned) = state.buckets.get(&input.bucket).map(|bucket| bucket.versioned) else {
|
||||||
return Err(s3s::s3_error!(NoSuchBucket, "bucket does not exist"));
|
return Err(s3s::s3_error!(NoSuchBucket, "bucket does not exist"));
|
||||||
@@ -3436,6 +3468,68 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_object_gate_retains_exact_version_until_release() -> Result<(), BoxError> {
|
||||||
|
let target = FakeS3Target::start().await?;
|
||||||
|
let bucket = "delete-gated-target";
|
||||||
|
target.create_bucket(bucket);
|
||||||
|
for key in ["held", "unrelated"] {
|
||||||
|
target.put_seed_object(bucket, key, Bytes::from_static(b"payload"), &SeedMetadata::default());
|
||||||
|
}
|
||||||
|
let versions = target.stored_versions(bucket, "held");
|
||||||
|
let mut gate = target.hold_delete_object(bucket, "held");
|
||||||
|
let client = client(&target);
|
||||||
|
let held_client = client.clone();
|
||||||
|
let version = versions[0].0.clone();
|
||||||
|
let mut requests = tokio::task::JoinSet::new();
|
||||||
|
requests.spawn(async move {
|
||||||
|
held_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("held")
|
||||||
|
.version_id(version)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
timeout(Duration::from_secs(2), gate.wait_until_entered()).await??;
|
||||||
|
assert_eq!(
|
||||||
|
target.stored_versions(bucket, "held"),
|
||||||
|
versions,
|
||||||
|
"the gate must precede the storage mutation"
|
||||||
|
);
|
||||||
|
assert!(requests.try_join_next().is_none());
|
||||||
|
let held = target
|
||||||
|
.requests()
|
||||||
|
.into_iter()
|
||||||
|
.find(|record| record.operation == Operation::DeleteObject && record.key.as_deref() == Some("held"))
|
||||||
|
.expect("the held DELETE must be journaled");
|
||||||
|
assert_eq!(held.version_id.as_deref(), Some(versions[0].0.as_str()));
|
||||||
|
assert_eq!(held.prepared_response, None);
|
||||||
|
client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("unrelated")
|
||||||
|
.version_id(&target.stored_versions(bucket, "unrelated")[0].0)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(target.stored_versions(bucket, "unrelated").is_empty());
|
||||||
|
assert_eq!(target.stored_versions(bucket, "held"), versions);
|
||||||
|
drop(gate);
|
||||||
|
timeout(Duration::from_secs(2), requests.join_next())
|
||||||
|
.await?
|
||||||
|
.expect("held DELETE task")??;
|
||||||
|
assert!(target.stored_versions(bucket, "held").is_empty());
|
||||||
|
let released = target
|
||||||
|
.requests()
|
||||||
|
.into_iter()
|
||||||
|
.find(|record| record.sequence == held.sequence)
|
||||||
|
.and_then(|record| record.prepared_response)
|
||||||
|
.expect("released DELETE response");
|
||||||
|
assert_eq!(released.status, 204);
|
||||||
|
assert_eq!(released.version_id.as_deref(), Some(versions[0].0.as_str()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
|
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
|
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
|
||||||
init_logging, rustfs_binary_path,
|
init_logging, local_http_client, rustfs_binary_path,
|
||||||
};
|
};
|
||||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||||
use aws_sdk_s3::{
|
use aws_sdk_s3::{
|
||||||
@@ -53,6 +53,196 @@ mod tests {
|
|||||||
expected_pool_metadata: Option<VersionShardCensus>,
|
expected_pool_metadata: Option<VersionShardCensus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pool_metadata_matches_republished_manifest(actual: &VersionShardCensus, expected: &VersionShardCensus) -> bool {
|
||||||
|
for data_dir in [&actual.data_dir, &expected.data_dir] {
|
||||||
|
if data_dir
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| uuid::Uuid::parse_str(value).ok())
|
||||||
|
.is_none_or(|value| value.is_nil())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The owning CAS republishes the same canonical payload with a fresh
|
||||||
|
// physical identity. Every shard and layout field must still match.
|
||||||
|
let mut republished = actual.clone();
|
||||||
|
republished.data_dir = expected.data_dir.clone();
|
||||||
|
republished.matches_manifest(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool_metadata_node_health_is_ready(status: u16, payload: &serde_json::Value) -> bool {
|
||||||
|
(200..300).contains(&status)
|
||||||
|
&& payload["ready"] == true
|
||||||
|
&& payload["details"]["poolMetadata"]["ready"] == true
|
||||||
|
&& payload["details"]["storage"]["readQuorum"] == true
|
||||||
|
&& payload["details"]["storage"]["writeQuorum"] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn all_nodes_pool_metadata_readiness(
|
||||||
|
cluster: &RustFSTestClusterEnvironment,
|
||||||
|
deadline: Instant,
|
||||||
|
) -> (bool, serde_json::Value) {
|
||||||
|
let http = local_http_client();
|
||||||
|
let mut ready = !cluster.nodes.is_empty();
|
||||||
|
let mut observations = Vec::with_capacity(cluster.nodes.len());
|
||||||
|
for (node, environment) in cluster.nodes.iter().enumerate() {
|
||||||
|
let response = tokio::time::timeout_at(deadline, async {
|
||||||
|
let response = http
|
||||||
|
.get(format!("{}/health/ready", environment.url))
|
||||||
|
.timeout(Duration::from_secs(3))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let payload = response.json::<serde_json::Value>().await?;
|
||||||
|
Ok::<_, reqwest::Error>((status, payload))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let observation = match response {
|
||||||
|
Ok(Ok((status, payload))) => {
|
||||||
|
let node_ready = pool_metadata_node_health_is_ready(status, &payload);
|
||||||
|
ready &= node_ready;
|
||||||
|
serde_json::json!({"node": node, "status": status, "ready": node_ready, "payload": payload})
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
ready = false;
|
||||||
|
serde_json::json!({"node": node, "error": error.to_string()})
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
ready = false;
|
||||||
|
serde_json::json!({"node": node, "error": "pool metadata health deadline elapsed"})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
observations.push(observation);
|
||||||
|
}
|
||||||
|
let deadline_elapsed = Instant::now() >= deadline;
|
||||||
|
(
|
||||||
|
ready && !deadline_elapsed,
|
||||||
|
serde_json::json!({"deadline_elapsed": deadline_elapsed, "nodes": observations}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool_metadata_manifest_fixture() -> VersionShardCensus {
|
||||||
|
VersionShardCensus {
|
||||||
|
version_id: None,
|
||||||
|
has_xl_meta: true,
|
||||||
|
data_dir: Some(uuid::Uuid::from_u128(1).to_string()),
|
||||||
|
erasure_index: Some(1),
|
||||||
|
erasure_distribution: Some(vec![1, 2, 3, 4]),
|
||||||
|
data_blocks: Some(2),
|
||||||
|
parity_blocks: Some(2),
|
||||||
|
expected_part_numbers: Default::default(),
|
||||||
|
present_part_fingerprints: Default::default(),
|
||||||
|
inline_data_fingerprint: Some(crate::chaos::PartShardFingerprint {
|
||||||
|
size: 7,
|
||||||
|
sha256: sha256_hex(b"payload"),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_metadata_republication_allows_only_valid_data_dir_change() {
|
||||||
|
let expected = pool_metadata_manifest_fixture();
|
||||||
|
let mut actual = expected.clone();
|
||||||
|
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
|
||||||
|
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
|
||||||
|
assert!(
|
||||||
|
!actual.matches_manifest(&expected),
|
||||||
|
"ordinary object manifests must retain their original identity"
|
||||||
|
);
|
||||||
|
for invalid in [
|
||||||
|
None,
|
||||||
|
Some(String::new()),
|
||||||
|
Some("invalid".to_string()),
|
||||||
|
Some(uuid::Uuid::nil().to_string()),
|
||||||
|
] {
|
||||||
|
let mut changed = actual.clone();
|
||||||
|
changed.data_dir = invalid.clone();
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
let mut changed_expected = expected.clone();
|
||||||
|
changed_expected.data_dir = invalid;
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&actual, &changed_expected));
|
||||||
|
}
|
||||||
|
let mut changed = actual.clone();
|
||||||
|
changed
|
||||||
|
.inline_data_fingerprint
|
||||||
|
.as_mut()
|
||||||
|
.expect("fixture has inline payload")
|
||||||
|
.sha256 = sha256_hex(b"corrupt");
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
let mut changed = actual.clone();
|
||||||
|
changed.erasure_index = Some(2);
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
let mut changed = actual.clone();
|
||||||
|
changed.erasure_distribution = Some(vec![2, 1, 3, 4]);
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
let mut changed = actual.clone();
|
||||||
|
changed.data_blocks = Some(1);
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
let mut changed = actual.clone();
|
||||||
|
changed.parity_blocks = Some(3);
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
let mut changed = actual.clone();
|
||||||
|
changed.version_id = Some(uuid::Uuid::from_u128(3).to_string());
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
let mut changed = actual;
|
||||||
|
changed.has_xl_meta = false;
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_metadata_republication_requires_exact_complete_part_fingerprints() {
|
||||||
|
let mut expected = pool_metadata_manifest_fixture();
|
||||||
|
expected.inline_data_fingerprint = None;
|
||||||
|
expected.expected_part_numbers.insert(1);
|
||||||
|
expected.present_part_fingerprints.insert(
|
||||||
|
1,
|
||||||
|
crate::chaos::PartShardFingerprint {
|
||||||
|
size: 7,
|
||||||
|
sha256: sha256_hex(b"payload"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut actual = expected.clone();
|
||||||
|
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
|
||||||
|
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
|
||||||
|
let mut changed = actual.clone();
|
||||||
|
changed.present_part_fingerprints.remove(&1);
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
let mut changed = actual;
|
||||||
|
changed
|
||||||
|
.present_part_fingerprints
|
||||||
|
.get_mut(&1)
|
||||||
|
.expect("fixture has part 1")
|
||||||
|
.sha256 = sha256_hex(b"corrupt");
|
||||||
|
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pool_metadata_node_health_requires_explicit_metadata_and_both_quorums() {
|
||||||
|
let expected = serde_json::json!({"ready": true, "details": {
|
||||||
|
"poolMetadata": {"ready": true},
|
||||||
|
"storage": {"readQuorum": true, "writeQuorum": true}
|
||||||
|
}});
|
||||||
|
assert!(pool_metadata_node_health_is_ready(200, &expected));
|
||||||
|
assert!(!pool_metadata_node_health_is_ready(503, &expected));
|
||||||
|
assert!(!pool_metadata_node_health_is_ready(200, &serde_json::json!({"ready": true})));
|
||||||
|
for path in [
|
||||||
|
"/ready",
|
||||||
|
"/details/poolMetadata/ready",
|
||||||
|
"/details/storage/readQuorum",
|
||||||
|
"/details/storage/writeQuorum",
|
||||||
|
] {
|
||||||
|
for value in [
|
||||||
|
serde_json::Value::Bool(false),
|
||||||
|
serde_json::Value::Null,
|
||||||
|
serde_json::json!("true"),
|
||||||
|
] {
|
||||||
|
let mut changed = expected.clone();
|
||||||
|
*changed.pointer_mut(path).expect("fixture contains required health field") = value;
|
||||||
|
assert!(!pool_metadata_node_health_is_ready(200, &changed), "required health field {path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct EvidenceBuild {
|
struct EvidenceBuild {
|
||||||
sha256: String,
|
sha256: String,
|
||||||
@@ -2232,6 +2422,7 @@ mod tests {
|
|||||||
.and_then(|value| value.parse::<u64>().ok())
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
.unwrap_or(180);
|
.unwrap_or(180);
|
||||||
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
|
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
|
||||||
|
let mut last_pool_metadata_readiness = serde_json::Value::Null;
|
||||||
loop {
|
loop {
|
||||||
if restart_recovery_admin_after_failure && !recovery_admin_task_restarted {
|
if restart_recovery_admin_after_failure && !recovery_admin_task_restarted {
|
||||||
let task_state = timeout(
|
let task_state = timeout(
|
||||||
@@ -2273,14 +2464,23 @@ mod tests {
|
|||||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
|
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
|
||||||
let pool_metadata_matches = match &expected_pool_metadata {
|
let pool_metadata_matches = match &expected_pool_metadata {
|
||||||
Some(expected) => {
|
Some(expected) => {
|
||||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
|
let actual =
|
||||||
.matches_manifest(expected)
|
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||||
|
pool_metadata_matches_republished_manifest(&actual, expected)
|
||||||
}
|
}
|
||||||
None => true,
|
None => true,
|
||||||
};
|
};
|
||||||
|
let pool_metadata_ready = if expected_pool_metadata.is_some() && pool_metadata_matches {
|
||||||
|
let (ready, observation) = all_nodes_pool_metadata_readiness(&cluster, heal_deadline).await;
|
||||||
|
last_pool_metadata_readiness = observation;
|
||||||
|
ready
|
||||||
|
} else {
|
||||||
|
expected_pool_metadata.is_none()
|
||||||
|
};
|
||||||
if matching == expected_manifests.len()
|
if matching == expected_manifests.len()
|
||||||
&& (!outage_target_manifest_required || outage_census.is_complete())
|
&& (!outage_target_manifest_required || outage_census.is_complete())
|
||||||
&& pool_metadata_matches
|
&& pool_metadata_matches
|
||||||
|
&& pool_metadata_ready
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2290,6 +2490,15 @@ mod tests {
|
|||||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
|
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
|
||||||
let pool_metadata =
|
let pool_metadata =
|
||||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||||
|
// This post-deadline observation is diagnostic only. It cannot
|
||||||
|
// make an expired recovery attempt satisfy the original budget.
|
||||||
|
let pool_metadata_readiness = if expected_pool_metadata.is_some() {
|
||||||
|
let (_, observation) =
|
||||||
|
all_nodes_pool_metadata_readiness(&cluster, Instant::now() + Duration::from_secs(3)).await;
|
||||||
|
serde_json::json!({"last_eligible_check": last_pool_metadata_readiness, "timeout_check": observation})
|
||||||
|
} else {
|
||||||
|
serde_json::Value::Null
|
||||||
|
};
|
||||||
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
|
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|err| format!("status request failed: {err}"));
|
.unwrap_or_else(|err| format!("status request failed: {err}"));
|
||||||
@@ -2313,7 +2522,7 @@ mod tests {
|
|||||||
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
|
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
|
||||||
};
|
};
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata_expected={expected_pool_metadata:?}, pool_metadata={pool_metadata:?}, pool_metadata_readiness={pool_metadata_readiness}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
||||||
expected_manifests.len()
|
expected_manifests.len()
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
|
|||||||
@@ -1129,12 +1129,14 @@ mod tests {
|
|||||||
/// and the marker plus the cursor tag.
|
/// and the marker plus the cursor tag.
|
||||||
///
|
///
|
||||||
/// Bug Reference: backlog#1047
|
/// Bug Reference: backlog#1047
|
||||||
/// The V2 continuation token appends a `[rustfs_cache:...]` cursor tag to the
|
/// Earlier releases appended a `[rustfs_cache:...]` cursor tag to the last
|
||||||
/// last returned key. The orchestration layer passed that raw string to
|
/// returned key inside the V2 continuation token. The orchestration layer
|
||||||
/// `forward_past`, so any key whose byte after the shared stem sorts below
|
/// passed that raw string to `forward_past`, so any key whose byte after the
|
||||||
/// `[` (0x5B) — `.`, `/`, `-`, digits, uppercase — was silently dropped on
|
/// shared stem sorts below `[` (0x5B) — `.`, `/`, `-`, digits, uppercase —
|
||||||
/// the next page: with keys `a`, `a.txt`, `zz` and max_keys=1, page 2
|
/// was silently dropped on the next page: with keys `a`, `a.txt`, `zz` and
|
||||||
/// returned `zz` and `a.txt` was never listed.
|
/// max_keys=1, page 2 returned `zz` and `a.txt` was never listed. The token
|
||||||
|
/// is now base64 of the plain key, and this test keeps guarding the
|
||||||
|
/// continuation boundary.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_list_objects_v2_continuation_keeps_keys_after_marker_stem() {
|
async fn test_list_objects_v2_continuation_keeps_keys_after_marker_stem() {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ use http::{Method, StatusCode};
|
|||||||
use tokio::time::{Duration, sleep, timeout};
|
use tokio::time::{Duration, sleep, timeout};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
/// A successful reporting response can still have unknown scanner usage.
|
||||||
|
fn reported_quota_usage(response: &serde_json::Value) -> std::io::Result<Option<u64>> {
|
||||||
|
let value = response
|
||||||
|
.get("current_usage")
|
||||||
|
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "quota response is missing current_usage"))?;
|
||||||
|
if value.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
value.as_u64().map(Some).ok_or_else(|| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidData, "quota current_usage is not an unsigned integer or null")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Test environment setup for quota tests
|
/// Test environment setup for quota tests
|
||||||
pub struct QuotaTestEnv {
|
pub struct QuotaTestEnv {
|
||||||
pub env: RustFSTestEnvironment,
|
pub env: RustFSTestEnvironment,
|
||||||
@@ -87,35 +100,69 @@ impl QuotaTestEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_bucket_quota_stats(&self) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
async fn wait_for_usage_response(
|
||||||
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, self.bucket_name);
|
&self,
|
||||||
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
|
method: Method,
|
||||||
|
path: &str,
|
||||||
if response.contains("error") {
|
body: Option<String>,
|
||||||
Err(format!("Failed to get quota stats: {}", response).into())
|
expected_usage: u64,
|
||||||
} else {
|
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
Ok(serde_json::from_str(&response)?)
|
let convergence = async {
|
||||||
|
loop {
|
||||||
|
let (status, response) = admin_request(
|
||||||
|
&self.env.url,
|
||||||
|
method.clone(),
|
||||||
|
path,
|
||||||
|
body.clone(),
|
||||||
|
&self.env.access_key,
|
||||||
|
&self.env.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if status.is_success() {
|
||||||
|
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||||
|
if let Some(usage) = reported_quota_usage(&response)?
|
||||||
|
&& usage == expected_usage
|
||||||
|
{
|
||||||
|
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(response);
|
||||||
|
}
|
||||||
|
} else if status != StatusCode::SERVICE_UNAVAILABLE {
|
||||||
|
return Err(format!("quota usage request failed for {path}: {status} {response}").into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match timeout(Duration::from_secs(30), convergence).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(format!("quota usage did not converge to {expected_usage} for {path} within 30 seconds").into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_bucket_quota(
|
async fn wait_for_bucket_quota_stats(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
expected_usage: u64,
|
||||||
|
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
self.wait_for_usage_response(Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None, expected_usage)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_bucket_quota_check(
|
||||||
&self,
|
&self,
|
||||||
operation_type: &str,
|
operation_type: &str,
|
||||||
operation_size: u64,
|
operation_size: u64,
|
||||||
|
expected_usage: u64,
|
||||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let url = format!("{}/rustfs/admin/v3/quota-check/{}", self.env.url, self.bucket_name);
|
|
||||||
let check_request = serde_json::json!({
|
let check_request = serde_json::json!({
|
||||||
"operation_type": operation_type,
|
"operation_type": operation_type,
|
||||||
"operation_size": operation_size
|
"operation_size": operation_size
|
||||||
});
|
});
|
||||||
|
self.wait_for_usage_response(
|
||||||
let response = awscurl_post(&url, &check_request.to_string(), &self.env.access_key, &self.env.secret_key).await?;
|
Method::POST,
|
||||||
|
&format!("/rustfs/admin/v3/quota-check/{}", self.bucket_name),
|
||||||
if response.contains("error") {
|
Some(check_request.to_string()),
|
||||||
Err(format!("Failed to check quota: {}", response).into())
|
expected_usage,
|
||||||
} else {
|
)
|
||||||
Ok(serde_json::from_str(&response)?)
|
.await
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn upload_object(&self, key: &str, size_bytes: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn upload_object(&self, key: &str, size_bytes: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
@@ -145,25 +192,9 @@ impl QuotaTestEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_bucket_usage(&self) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let stats = self.get_bucket_quota_stats().await?;
|
|
||||||
Ok(stats.get("current_usage").and_then(|v| v.as_u64()).unwrap_or(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_bucket_usage(&self, expected: u64) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
async fn wait_for_bucket_usage(&self, expected: u64) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let convergence = async {
|
let stats = self.wait_for_bucket_quota_stats(&self.bucket_name, expected).await?;
|
||||||
loop {
|
reported_quota_usage(&stats)?.ok_or_else(|| "converged quota usage is missing".into())
|
||||||
let usage = self.get_bucket_usage().await?;
|
|
||||||
if usage == expected {
|
|
||||||
return Ok::<u64, Box<dyn std::error::Error + Send + Sync>>(usage);
|
|
||||||
}
|
|
||||||
sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match timeout(Duration::from_secs(30), convergence).await {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => Err(format!("bucket usage did not converge to {expected} bytes within 30 seconds").into()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_bucket_quota_for(
|
pub async fn set_bucket_quota_for(
|
||||||
@@ -171,8 +202,6 @@ impl QuotaTestEnv {
|
|||||||
bucket: &str,
|
bucket: &str,
|
||||||
quota_bytes: u64,
|
quota_bytes: u64,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
self.wait_for_quota_usage_for(bucket).await?;
|
|
||||||
|
|
||||||
let quota_path = format!("/rustfs/admin/v3/quota/{bucket}");
|
let quota_path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||||
let quota_config = serde_json::json!({
|
let quota_config = serde_json::json!({
|
||||||
"quota": quota_bytes,
|
"quota": quota_bytes,
|
||||||
@@ -206,46 +235,6 @@ impl QuotaTestEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wait_for_quota_usage_for(&self, bucket: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let stats_path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
|
|
||||||
let readiness = async {
|
|
||||||
loop {
|
|
||||||
let (status, response) =
|
|
||||||
admin_request(&self.env.url, Method::GET, &stats_path, None, &self.env.access_key, &self.env.secret_key)
|
|
||||||
.await?;
|
|
||||||
if status.is_success() {
|
|
||||||
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
|
|
||||||
}
|
|
||||||
if status != StatusCode::SERVICE_UNAVAILABLE {
|
|
||||||
return Err(format!("quota usage readiness failed for {bucket}: {status} {response}").into());
|
|
||||||
}
|
|
||||||
sleep(Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match timeout(Duration::from_secs(30), readiness).await {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => Err(format!("quota usage did not become authoritative for {bucket} within 30 seconds").into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get bucket quota statistics for specific bucket
|
|
||||||
pub async fn get_bucket_quota_stats_for(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
debug!("Getting quota stats for bucket: {}", bucket);
|
|
||||||
|
|
||||||
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, bucket);
|
|
||||||
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
|
|
||||||
|
|
||||||
if response.contains("error") {
|
|
||||||
Err(format!("Failed to get quota stats: {}", response).into())
|
|
||||||
} else {
|
|
||||||
let stats: serde_json::Value = serde_json::from_str(&response)?;
|
|
||||||
Ok(stats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upload an object to specific bucket
|
/// Upload an object to specific bucket
|
||||||
pub async fn upload_object_to_bucket(
|
pub async fn upload_object_to_bucket(
|
||||||
&self,
|
&self,
|
||||||
@@ -270,6 +259,132 @@ impl QuotaTestEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod usage_response_tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_usage_is_not_zero() {
|
||||||
|
assert_eq!(
|
||||||
|
reported_quota_usage(&serde_json::json!({"current_usage": null})).expect("valid unknown usage"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reported_quota_usage(&serde_json::json!({"current_usage": 0})).expect("known zero usage"),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_or_invalid_usage_is_not_readiness() {
|
||||||
|
for response in [
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({"current_usage": -1}),
|
||||||
|
serde_json::json!({"current_usage": "0"}),
|
||||||
|
serde_json::json!({"current_usage": 1.5}),
|
||||||
|
serde_json::json!({"current_usage": false}),
|
||||||
|
] {
|
||||||
|
let error = reported_quota_usage(&response).expect_err("malformed usage must fail");
|
||||||
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mock_environment(
|
||||||
|
responses: Vec<(StatusCode, &'static str)>,
|
||||||
|
) -> Result<(QuotaTestEnv, JoinSet<()>), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||||
|
let address = listener.local_addr()?;
|
||||||
|
let mut tasks = JoinSet::new();
|
||||||
|
tasks.spawn(async move {
|
||||||
|
for (status, body) in responses {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept quota reporting request");
|
||||||
|
let mut request = Vec::new();
|
||||||
|
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
|
||||||
|
let mut buffer = [0_u8; 1024];
|
||||||
|
let read = stream.read(&mut buffer).await.expect("read reporting request");
|
||||||
|
assert!(read > 0 && request.len() + read <= 8192, "bounded HTTP request headers");
|
||||||
|
request.extend_from_slice(&buffer[..read]);
|
||||||
|
}
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
status.as_u16(),
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
stream
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.await
|
||||||
|
.expect("write quota reporting response");
|
||||||
|
stream.shutdown().await.expect("close reporting response");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.url = format!("http://{address}");
|
||||||
|
let client = env.create_s3_client();
|
||||||
|
Ok((
|
||||||
|
QuotaTestEnv {
|
||||||
|
env,
|
||||||
|
client,
|
||||||
|
bucket_name: "quota-reporting-fixture".to_owned(),
|
||||||
|
},
|
||||||
|
tasks,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reporting_waits_for_known_matching_usage_without_selecting_allowed()
|
||||||
|
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let (env, mut tasks) = mock_environment(vec![
|
||||||
|
(StatusCode::SERVICE_UNAVAILABLE, "unavailable"),
|
||||||
|
(StatusCode::OK, r#"{"current_usage":null}"#),
|
||||||
|
(StatusCode::OK, r#"{"current_usage":1}"#),
|
||||||
|
(StatusCode::OK, r#"{"current_usage":0,"allowed":false}"#),
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
let response = env.wait_for_usage_response(Method::GET, "/reporting", None, 0).await?;
|
||||||
|
assert_eq!(response["current_usage"], 0);
|
||||||
|
assert_eq!(response["allowed"], false, "readiness must not select a desired admission outcome");
|
||||||
|
timeout(Duration::from_secs(1), tasks.join_next())
|
||||||
|
.await?
|
||||||
|
.expect("reporting mock task")
|
||||||
|
.expect("all responses consumed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reporting_rejects_malformed_success_instead_of_retrying() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let (env, mut tasks) = mock_environment(vec![(StatusCode::OK, r#"{"current_usage":"unknown"}"#)]).await?;
|
||||||
|
let error = env
|
||||||
|
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
|
||||||
|
.await
|
||||||
|
.expect_err("malformed usage must fail");
|
||||||
|
assert!(error.to_string().contains("not an unsigned integer or null"));
|
||||||
|
timeout(Duration::from_secs(1), tasks.join_next())
|
||||||
|
.await?
|
||||||
|
.expect("reporting mock task")
|
||||||
|
.expect("response consumed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reporting_does_not_retry_authorization_failure() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let (env, mut tasks) = mock_environment(vec![(StatusCode::FORBIDDEN, "denied")]).await?;
|
||||||
|
let error = env
|
||||||
|
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
|
||||||
|
.await
|
||||||
|
.expect_err("authorization error must fail");
|
||||||
|
assert!(error.to_string().contains("403"));
|
||||||
|
timeout(Duration::from_secs(1), tasks.join_next())
|
||||||
|
.await?
|
||||||
|
.expect("reporting mock task")
|
||||||
|
.expect("response consumed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod integration_tests {
|
mod integration_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -482,7 +597,7 @@ mod integration_tests {
|
|||||||
env.upload_object("file2.txt", 256 * 1024).await?;
|
env.upload_object("file2.txt", 256 * 1024).await?;
|
||||||
|
|
||||||
// Check usage
|
// Check usage
|
||||||
let usage = env.get_bucket_usage().await?;
|
let usage = env.wait_for_bucket_usage((512 + 256) * 1024).await?;
|
||||||
assert_eq!(usage, (512 + 256) * 1024);
|
assert_eq!(usage, (512 + 256) * 1024);
|
||||||
|
|
||||||
// Delete a file
|
// Delete a file
|
||||||
@@ -517,7 +632,7 @@ mod integration_tests {
|
|||||||
env.upload_object("file2.txt", 512 * 1024).await?;
|
env.upload_object("file2.txt", 512 * 1024).await?;
|
||||||
|
|
||||||
// Get detailed quota statistics
|
// Get detailed quota statistics
|
||||||
let stats = env.get_bucket_quota_stats().await?;
|
let stats = env.wait_for_bucket_quota_stats(&env.bucket_name, (1024 + 512) * 1024).await?;
|
||||||
|
|
||||||
assert_eq!(stats.get("bucket").unwrap().as_str().unwrap(), env.bucket_name);
|
assert_eq!(stats.get("bucket").unwrap().as_str().unwrap(), env.bucket_name);
|
||||||
assert_eq!(stats.get("quota_limit").unwrap().as_u64().unwrap(), 2 * 1024 * 1024);
|
assert_eq!(stats.get("quota_limit").unwrap().as_u64().unwrap(), 2 * 1024 * 1024);
|
||||||
@@ -546,22 +661,22 @@ mod integration_tests {
|
|||||||
env.upload_object("existing_file.txt", 512 * 1024).await?;
|
env.upload_object("existing_file.txt", 512 * 1024).await?;
|
||||||
|
|
||||||
// Check if we can upload another 512KB (should succeed, exactly fill quota)
|
// Check if we can upload another 512KB (should succeed, exactly fill quota)
|
||||||
let check_result = env.check_bucket_quota("PUT", 512 * 1024).await?;
|
let check_result = env.wait_for_bucket_quota_check("PUT", 512 * 1024, 512 * 1024).await?;
|
||||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||||
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 0);
|
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 0);
|
||||||
|
|
||||||
// Note: we haven't actually uploaded the second file yet, so current_usage is still 512KB
|
// Note: we haven't actually uploaded the second file yet, so current_usage is still 512KB
|
||||||
// Check if we can upload 1KB (should succeed - we haven't used the full quota yet)
|
// Check if we can upload 1KB (should succeed - we haven't used the full quota yet)
|
||||||
let check_result = env.check_bucket_quota("PUT", 1024).await?;
|
let check_result = env.wait_for_bucket_quota_check("PUT", 1024, 512 * 1024).await?;
|
||||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||||
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 523264); // 511 * 1024
|
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 523264); // 511 * 1024
|
||||||
|
|
||||||
// Check if we can upload 600KB (should fail - would exceed quota)
|
// Check if we can upload 600KB (should fail - would exceed quota)
|
||||||
let check_result = env.check_bucket_quota("PUT", 600 * 1024).await?;
|
let check_result = env.wait_for_bucket_quota_check("PUT", 600 * 1024, 512 * 1024).await?;
|
||||||
assert!(!check_result.get("allowed").unwrap().as_bool().unwrap());
|
assert!(!check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||||
|
|
||||||
// Check delete operation (should always be allowed)
|
// Check delete operation (should always be allowed)
|
||||||
let check_result = env.check_bucket_quota("DELETE", 512 * 1024).await?;
|
let check_result = env.wait_for_bucket_quota_check("DELETE", 512 * 1024, 512 * 1024).await?;
|
||||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||||
|
|
||||||
env.cleanup_bucket().await?;
|
env.cleanup_bucket().await?;
|
||||||
@@ -593,8 +708,8 @@ mod integration_tests {
|
|||||||
env.upload_object_to_bucket(&bucket2, "another_file.txt", 512 * 1024).await?;
|
env.upload_object_to_bucket(&bucket2, "another_file.txt", 512 * 1024).await?;
|
||||||
|
|
||||||
// Verify statistics are independent
|
// Verify statistics are independent
|
||||||
let stats1 = env.get_bucket_quota_stats_for(&bucket1).await?;
|
let stats1 = env.wait_for_bucket_quota_stats(&bucket1, 1024 * 1024).await?;
|
||||||
let stats2 = env.get_bucket_quota_stats_for(&bucket2).await?;
|
let stats2 = env.wait_for_bucket_quota_stats(&bucket2, (1024 + 512) * 1024).await?;
|
||||||
|
|
||||||
assert_eq!(stats1.get("current_usage").unwrap().as_u64().unwrap(), 1024 * 1024);
|
assert_eq!(stats1.get("current_usage").unwrap().as_u64().unwrap(), 1024 * 1024);
|
||||||
assert_eq!(stats2.get("current_usage").unwrap().as_u64().unwrap(), (1024 + 512) * 1024);
|
assert_eq!(stats2.get("current_usage").unwrap().as_u64().unwrap(), (1024 + 512) * 1024);
|
||||||
@@ -655,7 +770,6 @@ mod integration_tests {
|
|||||||
let env = QuotaTestEnv::new().await?;
|
let env = QuotaTestEnv::new().await?;
|
||||||
|
|
||||||
env.create_bucket().await?;
|
env.create_bucket().await?;
|
||||||
env.wait_for_quota_usage_for(&env.bucket_name).await?;
|
|
||||||
|
|
||||||
// Test 1: GET quota for bucket without quota config
|
// Test 1: GET quota for bucket without quota config
|
||||||
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
|
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
|
||||||
|
|||||||
@@ -541,50 +541,6 @@ impl NodeService for MinimalLockNodeService {
|
|||||||
Err(Status::unimplemented("lock-only test server"))
|
Err(Status::unimplemented("lock-only test server"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn artifact_rename_data(
|
|
||||||
&self,
|
|
||||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
|
||||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
|
||||||
Err(Status::unimplemented("lock-only test server"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn artifact_repair_rename_data(
|
|
||||||
&self,
|
|
||||||
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactRepairRenameDataRequest>,
|
|
||||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
|
||||||
Err(Status::unimplemented("lock-only test server"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn artifact_repair_write_metadata(
|
|
||||||
&self,
|
|
||||||
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactRepairWriteMetadataRequest>,
|
|
||||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::WriteMetadataResponse>, Status> {
|
|
||||||
Err(Status::unimplemented("lock-only test server"))
|
|
||||||
}
|
|
||||||
|
|
||||||
type InventoryArtifactAttemptStream =
|
|
||||||
Pin<Box<dyn Stream<Item = Result<rustfs_protos::proto_gen::node_service::ArtifactInventoryFrame, Status>> + Send>>;
|
|
||||||
async fn inventory_artifact_attempt(
|
|
||||||
&self,
|
|
||||||
_request: Request<rustfs_protos::proto_gen::node_service::ArtifactInventoryRequest>,
|
|
||||||
) -> Result<Response<Self::InventoryArtifactAttemptStream>, Status> {
|
|
||||||
Err(Status::unimplemented("lock-only test server"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seal_artifact_attempt_repairs(
|
|
||||||
&self,
|
|
||||||
_request: Request<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest>,
|
|
||||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse>, Status> {
|
|
||||||
Err(Status::unimplemented("lock-only test server"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seal_artifact_attempt_puts(
|
|
||||||
&self,
|
|
||||||
_request: Request<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsRequest>,
|
|
||||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::SealArtifactAttemptPutsResponse>, Status> {
|
|
||||||
Err(Status::unimplemented("lock-only test server"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rename_data(
|
async fn rename_data(
|
||||||
&self,
|
&self,
|
||||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||||
@@ -592,6 +548,20 @@ impl NodeService for MinimalLockNodeService {
|
|||||||
Err(Status::unimplemented("lock-only test server"))
|
Err(Status::unimplemented("lock-only test server"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn rename_multipart_data(
|
||||||
|
&self,
|
||||||
|
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||||
|
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
||||||
|
Err(Status::unimplemented("lock-only test server"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recover_multipart_source(
|
||||||
|
&self,
|
||||||
|
_request: Request<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceRequest>,
|
||||||
|
) -> Result<Response<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceResponse>, Status> {
|
||||||
|
Err(Status::unimplemented("lock-only test server"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn rename_data_at_incarnation(
|
async fn rename_data_at_incarnation(
|
||||||
&self,
|
&self,
|
||||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||||
@@ -606,6 +576,25 @@ impl NodeService for MinimalLockNodeService {
|
|||||||
Err(Status::unimplemented("lock-only test server"))
|
Err(Status::unimplemented("lock-only test server"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn rename_data_with_pool_meta_cas(
|
||||||
|
&self,
|
||||||
|
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||||
|
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
||||||
|
Err(Status::unimplemented("lock-only test server"))
|
||||||
|
}
|
||||||
|
async fn undo_pool_meta_write(
|
||||||
|
&self,
|
||||||
|
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionRequest>,
|
||||||
|
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionResponse>, Status> {
|
||||||
|
Err(Status::unimplemented("lock-only test server"))
|
||||||
|
}
|
||||||
|
async fn pool_meta_native_recovery(
|
||||||
|
&self,
|
||||||
|
_request: Request<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryRequest>,
|
||||||
|
) -> Result<Response<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryResponse>, Status> {
|
||||||
|
Err(Status::unimplemented("lock-only test server"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn make_volumes(
|
async fn make_volumes(
|
||||||
&self,
|
&self,
|
||||||
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
|
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,16 @@
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
pub(crate) const OBJECT_LOCK_TRACE_FILTER: &str = "info,rustfs_ecstore::object_lock_diag=trace";
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::disk::{
|
pub(crate) use rustfs_ecstore::api::disk::{
|
||||||
DiskError, DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk,
|
DiskOption, Endpoint, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, VolumeInfo, WalkDirOptions, new_disk,
|
||||||
};
|
};
|
||||||
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||||
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
||||||
set_tonic_canonical_body_digest, verify_tonic_boot_epoch_response,
|
verify_tonic_boot_epoch_response,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
|
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
|
||||||
@@ -38,11 +40,6 @@ pub(crate) mod grpc_lock {
|
|||||||
pub(crate) use super::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
pub(crate) use super::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod artifact_rpc {
|
|
||||||
pub(crate) use super::{DiskError, TONIC_RPC_PREFIX, gen_tonic_signature_headers, set_tonic_canonical_body_digest};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signing/transport surface used by the cross-process internode RPC signature
|
/// Signing/transport surface used by the cross-process internode RPC signature
|
||||||
/// acceptance tests (backlog#1327). The signing helpers are what let a test mint
|
/// acceptance tests (backlog#1327). The signing helpers are what let a test mint
|
||||||
/// the one legitimately signed request an on-path attacker is assumed to have
|
/// the one legitimately signed request an on-path attacker is assumed to have
|
||||||
@@ -62,5 +59,24 @@ pub(crate) mod replication_extension {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod upgrade_compatibility {
|
pub(crate) mod upgrade_compatibility {
|
||||||
pub(crate) use super::{DiskOption, Endpoint, new_disk};
|
pub(crate) use super::{DiskOption, Endpoint, RUSTFS_META_MULTIPART_BUCKET, new_disk};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod multipart_rpc_delay {
|
||||||
|
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod multipart_fence_acceptance {
|
||||||
|
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod multipart_source_rpc {
|
||||||
|
pub(crate) use rustfs_ecstore::api::disk::{
|
||||||
|
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
|
||||||
|
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceSnapshot,
|
||||||
|
PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject,
|
|||||||
use crate::replication_extension_test::{
|
use crate::replication_extension_test::{
|
||||||
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
|
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
|
||||||
};
|
};
|
||||||
|
use crate::storage_api::upgrade_compatibility::RUSTFS_META_MULTIPART_BUCKET;
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
@@ -52,6 +53,8 @@ const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
|||||||
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
||||||
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
|
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
|
||||||
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
|
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
|
||||||
|
const PENDING_MULTIPART_BUCKET: &str = "upgrade-mpu-continuity";
|
||||||
|
const PENDING_MULTIPART_KEY: &str = "pending/legacy-multipart.bin";
|
||||||
const MIXED_NODE_COUNT: usize = 4;
|
const MIXED_NODE_COUNT: usize = 4;
|
||||||
const UPGRADE_READINESS_BODY: &[u8] = b"upgrade write readiness";
|
const UPGRADE_READINESS_BODY: &[u8] = b"upgrade write readiness";
|
||||||
// Written by the "previous-seed" readiness probe through node 0 before any
|
// Written by the "previous-seed" readiness probe through node 0 before any
|
||||||
@@ -301,18 +304,27 @@ async fn read_object(
|
|||||||
Ok((encryption, body))
|
Ok((encryption, body))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
|
struct PendingMultipart {
|
||||||
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
upload_id: String,
|
||||||
let upload_id = created.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
|
completed_parts: Vec<CompletedPart>,
|
||||||
let mut completed_parts = Vec::with_capacity(parts.len());
|
part_sizes: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stage_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> Result<PendingMultipart, BoxError> {
|
||||||
|
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||||
|
let upload_id = created
|
||||||
|
.upload_id()
|
||||||
|
.ok_or("CreateMultipartUpload omitted upload ID")?
|
||||||
|
.to_owned();
|
||||||
|
let mut completed_parts = Vec::with_capacity(parts.len());
|
||||||
|
let mut part_sizes = Vec::with_capacity(parts.len());
|
||||||
for (index, part) in parts.iter().enumerate() {
|
for (index, part) in parts.iter().enumerate() {
|
||||||
let part_number = i32::try_from(index + 1)?;
|
let part_number = i32::try_from(index + 1)?;
|
||||||
let uploaded = client
|
let uploaded = client
|
||||||
.upload_part()
|
.upload_part()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.upload_id(upload_id)
|
.upload_id(&upload_id)
|
||||||
.part_number(part_number)
|
.part_number(part_number)
|
||||||
.body(ByteStream::from(part.clone()))
|
.body(ByteStream::from(part.clone()))
|
||||||
.send()
|
.send()
|
||||||
@@ -323,16 +335,38 @@ async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<
|
|||||||
.e_tag(uploaded.e_tag().ok_or("UploadPart omitted ETag")?)
|
.e_tag(uploaded.e_tag().ok_or("UploadPart omitted ETag")?)
|
||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
part_sizes.push(i64::try_from(part.len())?);
|
||||||
}
|
}
|
||||||
|
Ok(PendingMultipart {
|
||||||
|
upload_id,
|
||||||
|
completed_parts,
|
||||||
|
part_sizes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
client
|
async fn complete_pending_multipart(
|
||||||
|
client: &Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
pending: &PendingMultipart,
|
||||||
|
) -> Result<aws_sdk_s3::operation::complete_multipart_upload::CompleteMultipartUploadOutput, BoxError> {
|
||||||
|
Ok(client
|
||||||
.complete_multipart_upload()
|
.complete_multipart_upload()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.upload_id(upload_id)
|
.upload_id(&pending.upload_id)
|
||||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
.multipart_upload(
|
||||||
|
CompletedMultipartUpload::builder()
|
||||||
|
.set_parts(Some(pending.completed_parts.clone()))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
|
||||||
|
let pending = stage_multipart(client, bucket, key, parts).await?;
|
||||||
|
complete_pending_multipart(client, bucket, key, &pending).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +413,330 @@ async fn write_multipart_load(clients: &[Client], phase: &str) -> Result<Vec<Str
|
|||||||
Ok(keys)
|
Ok(keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn write_mixed_put_load(clients: &[Client], phase: &str) -> Result<Vec<String>, BoxError> {
|
||||||
|
let mut tasks = JoinSet::new();
|
||||||
|
for worker in 0..MULTIPART_WORKERS {
|
||||||
|
let node = worker % clients.len();
|
||||||
|
let client = clients[node].clone();
|
||||||
|
let phase = phase.to_owned();
|
||||||
|
tasks.spawn(async move {
|
||||||
|
let mut keys = Vec::with_capacity(MULTIPART_UPLOADS_PER_WORKER);
|
||||||
|
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
|
||||||
|
let key = format!("{phase}/put/{worker:02}/{upload:02}");
|
||||||
|
tokio::time::timeout(
|
||||||
|
MIXED_MULTIPART_UPLOAD_TIMEOUT,
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(MIXED_BUCKET)
|
||||||
|
.key(&key)
|
||||||
|
.body(ByteStream::from(vec![u8::try_from(worker)?; 64 * 1024]))
|
||||||
|
.send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("node {node}: PUT {key} acknowledgement deadline exceeded"))??;
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
Ok::<_, BoxError>(keys)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut keys = Vec::with_capacity(MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
|
||||||
|
while let Some(result) = tasks.join_next().await {
|
||||||
|
keys.extend(result??);
|
||||||
|
}
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_load_bodies(clients: &[Client], phase: &str, operation: &str) -> TestResult {
|
||||||
|
for worker in 0..MULTIPART_WORKERS {
|
||||||
|
let reader = &clients[(worker + 1) % clients.len()];
|
||||||
|
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
|
||||||
|
let key = format!("{phase}/{operation}/{worker:02}/{upload:02}");
|
||||||
|
assert_eq!(
|
||||||
|
read_object(reader, MIXED_BUCKET, &key, None).await?.1,
|
||||||
|
vec![u8::try_from(worker)?; 64 * 1024],
|
||||||
|
"{phase}: acknowledged {key} body"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exercise_homogeneous_multipart(cluster: &RustFSTestClusterEnvironment, phase: &str) -> TestResult {
|
||||||
|
let clients = cluster.create_all_clients()?;
|
||||||
|
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||||
|
wait_for_drive_readmission(&clients, MIXED_BUCKET, phase, DRIVE_READMISSION_TIMEOUT).await?;
|
||||||
|
let keys = match write_multipart_load(&clients, phase).await {
|
||||||
|
Ok(keys) => keys,
|
||||||
|
Err(error) => {
|
||||||
|
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
|
||||||
|
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(keys.len(), MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
|
||||||
|
verify_load_bodies(&clients, phase, "multipart").await?;
|
||||||
|
for (reader, client) in clients.iter().enumerate() {
|
||||||
|
wait_for_phase_listing(client, phase, keys.len(), &format!("homogeneous reader {reader}")).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingSourceBytes {
|
||||||
|
root: PathBuf,
|
||||||
|
files: Vec<(PathBuf, Vec<u8>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_source_bytes(
|
||||||
|
cluster: &RustFSTestClusterEnvironment,
|
||||||
|
pending: &PendingMultipart,
|
||||||
|
) -> Result<Vec<PendingSourceBytes>, BoxError> {
|
||||||
|
let source_hash =
|
||||||
|
rustfs_utils::hex_sha256(format!("{PENDING_MULTIPART_BUCKET}/{PENDING_MULTIPART_KEY}").as_bytes(), str::to_owned);
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||||
|
let hash_root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET).join(&source_hash);
|
||||||
|
let roots = std::fs::read_dir(&hash_root)?
|
||||||
|
.map(|entry| {
|
||||||
|
let entry = entry?;
|
||||||
|
Ok(entry.file_type()?.is_dir().then(|| entry.path()))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, std::io::Error>>()?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if roots.is_empty() {
|
||||||
|
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "pending upload source has not settled").into());
|
||||||
|
}
|
||||||
|
assert_eq!(roots.len(), 1, "the dedicated key has exactly one real pending upload");
|
||||||
|
let root = roots.into_iter().next().ok_or("pending upload source missing")?;
|
||||||
|
let mut files = vec![(PathBuf::from("xl.meta"), std::fs::read(root.join("xl.meta"))?)];
|
||||||
|
let data_dirs = std::fs::read_dir(&root)?
|
||||||
|
.map(|entry| {
|
||||||
|
let entry = entry?;
|
||||||
|
Ok((entry.file_type()?.is_dir() && entry.path().join("part.1").is_file()).then(|| entry.path()))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, std::io::Error>>()?
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if data_dirs.is_empty() {
|
||||||
|
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "selected part publication has not settled").into());
|
||||||
|
}
|
||||||
|
assert_eq!(data_dirs.len(), 1, "the upload has one actual selected-part directory");
|
||||||
|
let data_dir = data_dirs.into_iter().next().ok_or("pending upload parts missing")?;
|
||||||
|
for part in &pending.completed_parts {
|
||||||
|
let number = part.part_number().ok_or("acknowledged part omitted its number")?;
|
||||||
|
for name in [format!("part.{number}"), format!("part.{number}.meta")] {
|
||||||
|
let path = data_dir.join(name);
|
||||||
|
files.push((path.strip_prefix(&root)?.to_owned(), std::fs::read(path)?));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources.push(PendingSourceBytes { root, files });
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
sources.len(),
|
||||||
|
MIXED_NODE_COUNT,
|
||||||
|
"the controlled legacy fixture requires every physical source"
|
||||||
|
);
|
||||||
|
Ok(sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_pending_source_bytes(
|
||||||
|
cluster: &RustFSTestClusterEnvironment,
|
||||||
|
pending: &PendingMultipart,
|
||||||
|
) -> Result<Vec<PendingSourceBytes>, BoxError> {
|
||||||
|
let deadline = Instant::now() + DRIVE_READMISSION_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
match pending_source_bytes(cluster, pending) {
|
||||||
|
Ok(sources) => return Ok(sources),
|
||||||
|
Err(error)
|
||||||
|
if error
|
||||||
|
.downcast_ref::<std::io::Error>()
|
||||||
|
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
|
||||||
|
&& Instant::now() < deadline =>
|
||||||
|
{
|
||||||
|
sleep(Duration::from_millis(500)).await
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_pending_parts(client: &Client, pending: &PendingMultipart) -> TestResult {
|
||||||
|
let listed = client
|
||||||
|
.list_parts()
|
||||||
|
.bucket(PENDING_MULTIPART_BUCKET)
|
||||||
|
.key(PENDING_MULTIPART_KEY)
|
||||||
|
.upload_id(&pending.upload_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(listed.upload_id(), Some(pending.upload_id.as_str()));
|
||||||
|
assert_ne!(listed.is_truncated(), Some(true));
|
||||||
|
assert_eq!(listed.parts().len(), pending.completed_parts.len());
|
||||||
|
for ((actual, expected), expected_size) in listed.parts().iter().zip(&pending.completed_parts).zip(&pending.part_sizes) {
|
||||||
|
assert_eq!(actual.part_number(), expected.part_number());
|
||||||
|
assert_eq!(actual.e_tag(), expected.e_tag());
|
||||||
|
assert_eq!(actual.size(), Some(*expected_size));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_mixed_pending_refusal(
|
||||||
|
cluster: &RustFSTestClusterEnvironment,
|
||||||
|
pending: &PendingMultipart,
|
||||||
|
sources: &[PendingSourceBytes],
|
||||||
|
) -> TestResult {
|
||||||
|
let clients = cluster.create_all_clients()?;
|
||||||
|
assert_pending_parts(&clients[0], pending).await?;
|
||||||
|
let error = upgrade_probe_client(&clients[0])
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(PENDING_MULTIPART_BUCKET)
|
||||||
|
.key(PENDING_MULTIPART_KEY)
|
||||||
|
.upload_id(&pending.upload_id)
|
||||||
|
.multipart_upload(
|
||||||
|
CompletedMultipartUpload::builder()
|
||||||
|
.set_parts(Some(pending.completed_parts.clone()))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("one native participant cannot authorize a legacy MPU completion");
|
||||||
|
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503), "{error:?}");
|
||||||
|
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("ServiceUnavailable"));
|
||||||
|
for client in &clients {
|
||||||
|
assert_pending_parts(client, pending).await?;
|
||||||
|
let error = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(PENDING_MULTIPART_BUCKET)
|
||||||
|
.key(PENDING_MULTIPART_KEY)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("the rejected Complete must not publish a target");
|
||||||
|
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||||
|
}
|
||||||
|
for source in sources {
|
||||||
|
for entry in std::fs::read_dir(&source.root)? {
|
||||||
|
assert!(
|
||||||
|
!entry?.file_name().to_string_lossy().starts_with(".mpu-"),
|
||||||
|
"the failed preflight must not upgrade one physical legacy source"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (relative, expected) in &source.files {
|
||||||
|
let actual = std::fs::read(source.root.join(relative))?;
|
||||||
|
assert!(actual == *expected, "rejected Complete changed actual source {}", relative.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompletedPendingMultipart {
|
||||||
|
version_id: String,
|
||||||
|
etag: String,
|
||||||
|
body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_resumed_multipart(clients: &[Client], completed: &CompletedPendingMultipart) -> TestResult {
|
||||||
|
for (node, client) in clients.iter().enumerate() {
|
||||||
|
let response = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(PENDING_MULTIPART_BUCKET)
|
||||||
|
.key(PENDING_MULTIPART_KEY)
|
||||||
|
.version_id(&completed.version_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.version_id(), Some(completed.version_id.as_str()), "reader {node}");
|
||||||
|
assert_eq!(response.e_tag(), Some(completed.etag.as_str()), "reader {node}");
|
||||||
|
assert_eq!(
|
||||||
|
response.body.collect().await?.into_bytes().as_ref(),
|
||||||
|
completed.body.as_slice(),
|
||||||
|
"reader {node}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_object(client, PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, None)
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
|
completed.body
|
||||||
|
);
|
||||||
|
let versions = client
|
||||||
|
.list_object_versions()
|
||||||
|
.bucket(PENDING_MULTIPART_BUCKET)
|
||||||
|
.prefix(PENDING_MULTIPART_KEY)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(versions.versions().len(), 1, "same-upload replay must not add a version");
|
||||||
|
assert_eq!(versions.versions()[0].version_id(), Some(completed.version_id.as_str()));
|
||||||
|
assert!(versions.delete_markers().is_empty());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish_pending_multipart(
|
||||||
|
clients: &[Client],
|
||||||
|
pending: &PendingMultipart,
|
||||||
|
body: Vec<u8>,
|
||||||
|
) -> Result<CompletedPendingMultipart, BoxError> {
|
||||||
|
let complete = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
|
||||||
|
let version_id = complete
|
||||||
|
.version_id()
|
||||||
|
.filter(|version| !version.is_empty() && *version != "null")
|
||||||
|
.ok_or("versioned legacy upload completion omitted a concrete version")?
|
||||||
|
.to_owned();
|
||||||
|
let etag = complete.e_tag().ok_or("CompleteMultipartUpload omitted ETag")?.to_owned();
|
||||||
|
let replay = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
|
||||||
|
assert_eq!(replay.version_id(), Some(version_id.as_str()));
|
||||||
|
assert_eq!(replay.e_tag(), Some(etag.as_str()));
|
||||||
|
let completed = CompletedPendingMultipart { version_id, etag, body };
|
||||||
|
assert_resumed_multipart(clients, &completed).await?;
|
||||||
|
Ok(completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_multipart_quiescence(cluster: &RustFSTestClusterEnvironment, sources: &[PendingSourceBytes]) -> TestResult {
|
||||||
|
let deadline = Instant::now() + LISTING_CONVERGENCE_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let pending_roots = sources.iter().filter(|source| source.root.exists()).count();
|
||||||
|
let mut native_controls = 0;
|
||||||
|
let mut remaining_upload_roots = 0;
|
||||||
|
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||||
|
let root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET);
|
||||||
|
if !root.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for entry in walkdir::WalkDir::new(root) {
|
||||||
|
let entry = match entry {
|
||||||
|
Ok(entry) => entry,
|
||||||
|
Err(error)
|
||||||
|
if error
|
||||||
|
.io_error()
|
||||||
|
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
|
||||||
|
{
|
||||||
|
native_controls += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
// Every fixture upload lives at MPI/<SHA>/<upload>. A born-only
|
||||||
|
// source can contain just xl.meta and its data directory, with
|
||||||
|
// no .mpu-* record. Only empty SHA parents may survive rollback.
|
||||||
|
if entry.depth() == 2 || (entry.depth() == 1 && !entry.file_type().is_dir()) {
|
||||||
|
remaining_upload_roots += 1;
|
||||||
|
}
|
||||||
|
if entry.file_name().to_string_lossy().starts_with(".mpu-") {
|
||||||
|
native_controls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending_roots == 0 && remaining_upload_roots == 0 && native_controls == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"downgrade requires settled native MPU state: {pending_roots} fixed pending roots, {remaining_upload_roots} inventoried upload roots, {native_controls} native controls"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn mixed_multipart_before(
|
async fn mixed_multipart_before(
|
||||||
deadline: Instant,
|
deadline: Instant,
|
||||||
node: usize,
|
node: usize,
|
||||||
@@ -625,14 +983,11 @@ async fn exercise_mixed_cluster(
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
|
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
|
||||||
|
|
||||||
let multipart_keys = match write_multipart_load(&clients, phase).await {
|
// Native MPU mutations are operationally paused while RPC generations
|
||||||
Ok(keys) => keys,
|
// differ. Keep the same mixed traffic volume with ordinary PUT instead.
|
||||||
Err(error) => {
|
let written_keys = write_mixed_put_load(&clients, phase).await?;
|
||||||
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
|
let expected_count = written_keys.len() + 2;
|
||||||
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
|
verify_load_bodies(&clients, phase, "put").await?;
|
||||||
}
|
|
||||||
};
|
|
||||||
let expected_count = multipart_keys.len() + 2;
|
|
||||||
for (label, client) in [("current", current_client), ("previous", previous_client)] {
|
for (label, client) in [("current", current_client), ("previous", previous_client)] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||||
@@ -648,9 +1003,9 @@ async fn exercise_mixed_cluster(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let last_multipart_key = format!("{phase}/multipart/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
|
let last_put_key = format!("{phase}/put/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
read_object(previous_client, MIXED_BUCKET, &last_multipart_key, None).await?.1,
|
read_object(previous_client, MIXED_BUCKET, &last_put_key, None).await?.1,
|
||||||
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
|
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -790,12 +1145,20 @@ fn previous_baseline_writers(clients: &[Client], restarted_node: usize) -> &[Cli
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnvironment, previous_binary: &Path) -> TestResult {
|
async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnvironment, previous_binary: &Path) -> TestResult {
|
||||||
|
prepare_release_baseline(cluster, previous_binary, "previous").await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_release_baseline(
|
||||||
|
cluster: &mut RustFSTestClusterEnvironment,
|
||||||
|
previous_binary: &Path,
|
||||||
|
prefix: &str,
|
||||||
|
) -> TestResult {
|
||||||
let clients: Vec<_> = cluster.create_all_clients()?.iter().map(upgrade_probe_client).collect();
|
let clients: Vec<_> = cluster.create_all_clients()?.iter().map(upgrade_probe_client).collect();
|
||||||
// rc.5 can latch a non-elected node's write fence while its first startup
|
// rc.5 can latch a non-elected node's write fence while its first startup
|
||||||
// waits for pool metadata (#7473). First prove the elected writer can
|
// waits for pool metadata (#7473). First prove the elected writer can
|
||||||
// persist data, then restart each old process once with three peers still
|
// persist data, then restart each old process once with three peers still
|
||||||
// readable. This preparation ends before any current binary is started.
|
// readable. The same preparation is reused only after a quiescent homogeneous rollback.
|
||||||
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
|
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, &format!("{prefix}-seed"), LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||||
// A node that just came back is still replaying its startup traffic against
|
// A node that just came back is still replaying its startup traffic against
|
||||||
// every peer. Killing the next node during that burst gives the fresh node
|
// every peer. Killing the next node during that burst gives the fresh node
|
||||||
// two failures in a row and it drops that peer's drive to Offline, where
|
// two failures in a row and it drops that peer's drive to Offline, where
|
||||||
@@ -805,7 +1168,7 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
|
|||||||
for node in [1, 2, 3, 0] {
|
for node in [1, 2, 3, 0] {
|
||||||
cluster.stop_node(node)?;
|
cluster.stop_node(node)?;
|
||||||
cluster.start_node_from_binary(node, previous_binary).await?;
|
cluster.start_node_from_binary(node, previous_binary).await?;
|
||||||
let context = format!("previous-restart-{node}");
|
let context = format!("{prefix}-restart-{node}");
|
||||||
wait_for_upgrade_write_readiness(
|
wait_for_upgrade_write_readiness(
|
||||||
std::slice::from_ref(&clients[node]),
|
std::slice::from_ref(&clients[node]),
|
||||||
MIXED_BUCKET,
|
MIXED_BUCKET,
|
||||||
@@ -822,15 +1185,17 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
|
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, &format!("{prefix}-baseline"), LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||||
for (reader, client) in clients.iter().enumerate() {
|
for (reader, client) in clients.iter().enumerate() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
read_object(client, MIXED_BUCKET, &format!(".upgrade-readiness/{prefix}-seed/node-0"), None)
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
UPGRADE_READINESS_BODY,
|
UPGRADE_READINESS_BODY,
|
||||||
"previous-release node {reader} must retain the seed across its rolling restart"
|
"previous-release node {reader} must retain the seed across its rolling restart"
|
||||||
);
|
);
|
||||||
for writer in 0..clients.len() {
|
for writer in 0..clients.len() {
|
||||||
let key = format!(".upgrade-readiness/previous-baseline/node-{writer}");
|
let key = format!(".upgrade-readiness/{prefix}-baseline/node-{writer}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
read_object(client, MIXED_BUCKET, &key, None).await?.1,
|
read_object(client, MIXED_BUCKET, &key, None).await?.1,
|
||||||
UPGRADE_READINESS_BODY,
|
UPGRADE_READINESS_BODY,
|
||||||
@@ -1883,10 +2248,21 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
|||||||
cluster.start_with_binary(&previous_binary).await?;
|
cluster.start_with_binary(&previous_binary).await?;
|
||||||
cluster.create_test_bucket(MIXED_BUCKET).await?;
|
cluster.create_test_bucket(MIXED_BUCKET).await?;
|
||||||
prepare_previous_release_baseline(&mut cluster, &previous_binary).await?;
|
prepare_previous_release_baseline(&mut cluster, &previous_binary).await?;
|
||||||
|
exercise_homogeneous_multipart(&cluster, "homogeneous-previous").await?;
|
||||||
|
cluster.create_test_bucket(PENDING_MULTIPART_BUCKET).await?;
|
||||||
|
let previous_clients = cluster.create_all_clients()?;
|
||||||
|
enable_versioning(&previous_clients[0], PENDING_MULTIPART_BUCKET).await?;
|
||||||
|
let pending_parts = vec![vec![0x63; 5 * 1024 * 1024], vec![0x7d; 4096]];
|
||||||
|
let pending = stage_multipart(&previous_clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, &pending_parts).await?;
|
||||||
|
let sources = wait_for_pending_source_bytes(&cluster, &pending).await?;
|
||||||
|
assert_pending_parts(&previous_clients[0], &pending).await?;
|
||||||
|
|
||||||
cluster.stop_node(0)?;
|
cluster.stop_node(0)?;
|
||||||
cluster.start_node_from_binary(0, ¤t_binary).await?;
|
cluster.start_node_from_binary(0, ¤t_binary).await?;
|
||||||
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
|
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
|
||||||
|
// One controlled out-of-policy attempt proves fail-closed behavior below
|
||||||
|
// native write quorum; positive MPU writers remain paused while mixed.
|
||||||
|
assert_mixed_pending_refusal(&cluster, &pending, &sources).await?;
|
||||||
|
|
||||||
for (node_idx, phase) in [(1, "two-current-nodes"), (2, "one-previous-node")] {
|
for (node_idx, phase) in [(1, "two-current-nodes"), (2, "one-previous-node")] {
|
||||||
cluster.stop_node(node_idx)?;
|
cluster.stop_node(node_idx)?;
|
||||||
@@ -1898,6 +2274,9 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
|||||||
cluster.start_node_from_binary(3, ¤t_binary).await?;
|
cluster.start_node_from_binary(3, ¤t_binary).await?;
|
||||||
let clients = cluster.create_all_clients()?;
|
let clients = cluster.create_all_clients()?;
|
||||||
wait_for_drive_readmission(&clients, MIXED_BUCKET, "homogeneous-current", DRIVE_READMISSION_TIMEOUT).await?;
|
wait_for_drive_readmission(&clients, MIXED_BUCKET, "homogeneous-current", DRIVE_READMISSION_TIMEOUT).await?;
|
||||||
|
let completed_pending = finish_pending_multipart(&clients, &pending, pending_parts.concat()).await?;
|
||||||
|
exercise_homogeneous_multipart(&cluster, "homogeneous-current").await?;
|
||||||
|
verify_load_bodies(&clients, "homogeneous-previous", "multipart").await?;
|
||||||
|
|
||||||
for (node_idx, client) in clients.iter().enumerate() {
|
for (node_idx, client) in clients.iter().enumerate() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1916,47 +2295,6 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(context) = evidence_context.as_ref() {
|
|
||||||
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
|
|
||||||
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
|
|
||||||
write_g09_evidence(
|
|
||||||
context,
|
|
||||||
"mixed_version_reader_evidence",
|
|
||||||
"mixed-version-reader",
|
|
||||||
&["old-writer-new-reader", "new-writer-old-reader"],
|
|
||||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
|
||||||
serde_json::json!({
|
|
||||||
"bucket": MIXED_BUCKET,
|
|
||||||
"phases": phases,
|
|
||||||
"objects_per_phase": objects_per_phase,
|
|
||||||
"assertions": [
|
|
||||||
"current node reads objects written through previous-release client",
|
|
||||||
"previous-release node reads objects written through current client",
|
|
||||||
"all nodes list every mixed-version object after homogeneous-current convergence",
|
|
||||||
"the previous-release seed survives the baseline restart and every upgrade phase"
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)?;
|
|
||||||
write_g09_evidence(
|
|
||||||
context,
|
|
||||||
"mixed_version_writer_evidence",
|
|
||||||
"mixed-version-writer",
|
|
||||||
&["old-reader-new-writer", "new-reader-old-writer"],
|
|
||||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
|
||||||
serde_json::json!({
|
|
||||||
"bucket": MIXED_BUCKET,
|
|
||||||
"phases": phases,
|
|
||||||
"objects_per_phase": objects_per_phase,
|
|
||||||
"assertions": [
|
|
||||||
"current writer publishes objects readable by previous-release node",
|
|
||||||
"previous-release writer publishes objects readable by current node",
|
|
||||||
"multipart writers continue at every individual node upgrade",
|
|
||||||
"modern DELETE fails closed and preserves every byte while old peers remain"
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let clients = cluster.create_all_clients()?;
|
let clients = cluster.create_all_clients()?;
|
||||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "all-current", LISTING_CONVERGENCE_TIMEOUT).await?;
|
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "all-current", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||||
clients[0]
|
clients[0]
|
||||||
@@ -1980,6 +2318,115 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wait_for_multipart_quiescence(&cluster, &sources).await?;
|
||||||
|
// No MPU mutation runs during a heterogeneous downgrade. All current
|
||||||
|
// callbacks and source controls settle before old binaries reopen volumes.
|
||||||
|
cluster.stop();
|
||||||
|
cluster.start_with_binary(&previous_binary).await?;
|
||||||
|
prepare_release_baseline(&mut cluster, &previous_binary, "rollback").await?;
|
||||||
|
let old_clients = cluster.create_all_clients()?;
|
||||||
|
assert_resumed_multipart(&old_clients, &completed_pending).await?;
|
||||||
|
for phase in ["homogeneous-previous", "homogeneous-current"] {
|
||||||
|
verify_load_bodies(&old_clients, phase, "multipart").await?;
|
||||||
|
for client in &old_clients {
|
||||||
|
wait_for_phase_listing(
|
||||||
|
client,
|
||||||
|
phase,
|
||||||
|
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
|
||||||
|
"previous reader after rollback",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for phase in ["one-current-node", "two-current-nodes", "one-previous-node"] {
|
||||||
|
verify_load_bodies(&old_clients, phase, "put").await?;
|
||||||
|
for client in &old_clients {
|
||||||
|
wait_for_phase_listing(
|
||||||
|
client,
|
||||||
|
phase,
|
||||||
|
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2,
|
||||||
|
"previous reader after rollback",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
for (key, body) in [
|
||||||
|
("written-by-current", format!("{phase}: current RustFS build")),
|
||||||
|
("written-by-previous", format!("{phase}: previous RustFS release")),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
read_object(client, MIXED_BUCKET, &format!("{phase}/{key}"), None).await?.1,
|
||||||
|
body.as_bytes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (node, client) in old_clients.iter().enumerate() {
|
||||||
|
let error = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(MIXED_BUCKET)
|
||||||
|
.key(PREVIOUS_RELEASE_SEED_KEY)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("current DELETE must remain visible to previous readers after rollback");
|
||||||
|
assert_eq!(
|
||||||
|
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey"),
|
||||||
|
"reader {node}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exercise_homogeneous_multipart(&cluster, "homogeneous-rollback").await?;
|
||||||
|
|
||||||
|
if let Some(context) = evidence_context.as_ref() {
|
||||||
|
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
|
||||||
|
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
|
||||||
|
write_g09_evidence(
|
||||||
|
context,
|
||||||
|
"mixed_version_reader_evidence",
|
||||||
|
"mixed-version-reader",
|
||||||
|
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||||
|
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||||
|
serde_json::json!({
|
||||||
|
"bucket": MIXED_BUCKET,
|
||||||
|
"phases": phases,
|
||||||
|
"objects_per_phase": objects_per_phase,
|
||||||
|
"mixed_load": "plain-put",
|
||||||
|
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
|
||||||
|
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
|
||||||
|
"assertions": [
|
||||||
|
"current node reads objects written through previous-release client",
|
||||||
|
"previous-release node reads objects written through current client",
|
||||||
|
"all nodes list every mixed-version object after homogeneous-current convergence",
|
||||||
|
"the previous-release seed survives the baseline restart and every upgrade phase",
|
||||||
|
"previous readers read every current-native MPU body after quiescent rollback",
|
||||||
|
"same legacy upload resumes with identical body, ETag and version after full upgrade"
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
write_g09_evidence(
|
||||||
|
context,
|
||||||
|
"mixed_version_writer_evidence",
|
||||||
|
"mixed-version-writer",
|
||||||
|
&["old-reader-new-writer", "new-reader-old-writer"],
|
||||||
|
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||||
|
serde_json::json!({
|
||||||
|
"bucket": MIXED_BUCKET,
|
||||||
|
"phases": phases,
|
||||||
|
"objects_per_phase": objects_per_phase,
|
||||||
|
"mixed_load": "plain-put",
|
||||||
|
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
|
||||||
|
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
|
||||||
|
"assertions": [
|
||||||
|
"current writer publishes objects readable by previous-release node",
|
||||||
|
"previous-release writer publishes objects readable by current node",
|
||||||
|
"mixed phases retain 16x16 plain PUT writers while positive multipart writers are paused",
|
||||||
|
"16x16 multipart load runs in homogeneous previous, current and rollback phases",
|
||||||
|
"one-current-node Complete returns typed 503 and preserves actual source authority and parts",
|
||||||
|
"the same legacy upload completes and replays once fully upgraded without adding a version",
|
||||||
|
"modern DELETE fails closed and preserves every byte while old peers remain"
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ Applies to `crates/ecstore/`.
|
|||||||
|
|
||||||
- Be careful with allocations and locking in hot paths.
|
- Be careful with allocations and locking in hot paths.
|
||||||
- Keep network and disk operations async-friendly; avoid introducing unnecessary blocking.
|
- Keep network and disk operations async-friendly; avoid introducing unnecessary blocking.
|
||||||
|
- Coalesce related `open`, `read`/`write`, `fsync`, and `close` filesystem steps into a single per-disk blocking closure rather than chaining multiple async or blocking dispatches, and route any closure that performs file or directory sync to the disk's dedicated fsync lane so stalled syncs cannot starve foreground metadata reads.
|
||||||
|
- Pin a parent directory once (`openat2(BENEATH | NO_SYMLINKS)` on Linux) and reuse the pinned handle across sibling reads or same-directory renames instead of re-walking ancestor components per file.
|
||||||
|
- When the file size is already known from `fstat` metadata, pre-size the destination buffer (`len + 1`) before reading to EOF so small metadata reads complete in a single `read()` + EOF check rather than exponential-growth reallocations and syscalls.
|
||||||
- Benchmark-sensitive changes should include measurable rationale.
|
- Benchmark-sensitive changes should include measurable rationale.
|
||||||
|
|
||||||
## Allocation Discipline in Hot Paths
|
## Allocation Discipline in Hot Paths
|
||||||
|
|||||||
@@ -33,11 +33,6 @@ workspace = true
|
|||||||
default = []
|
default = []
|
||||||
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
|
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
|
||||||
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
|
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
|
||||||
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
|
||||||
# production binary (it is always available to tests). Off by default so the
|
|
||||||
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
|
|
||||||
# state in a stock build (backlog#1832).
|
|
||||||
list-chaos = []
|
|
||||||
rio-v2 = ["dep:rustfs-rio-v2"]
|
rio-v2 = ["dep:rustfs-rio-v2"]
|
||||||
hotpath = [
|
hotpath = [
|
||||||
"hotpath/hotpath",
|
"hotpath/hotpath",
|
||||||
|
|||||||
@@ -341,9 +341,9 @@ pub mod cache {
|
|||||||
|
|
||||||
pub mod capacity {
|
pub mod capacity {
|
||||||
pub use crate::core::pools::{
|
pub use crate::core::pools::{
|
||||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
|
DecommissionUnresolvedEntry, PoolAdminDiagnostic, PoolAvailability, PoolCapacityStatus, PoolDecommissionInfo,
|
||||||
get_total_usable_capacity_free, is_pool_activation_fleet_proof_error, path2_bucket_object,
|
PoolDiagnosticStatus, PoolMeta, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
||||||
path2_bucket_object_with_base_path,
|
is_pool_activation_fleet_proof_error, path2_bucket_object, path2_bucket_object_with_base_path,
|
||||||
};
|
};
|
||||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||||
}
|
}
|
||||||
@@ -371,6 +371,15 @@ pub mod compression {
|
|||||||
|
|
||||||
pub mod config {
|
pub mod config {
|
||||||
pub mod com {
|
pub mod com {
|
||||||
|
/// Preserve empty existing objects so callers can distinguish corruption from absence.
|
||||||
|
pub async fn read_config_preserve_empty_with_metadata_opts(
|
||||||
|
api: std::sync::Arc<crate::store::ECStore>,
|
||||||
|
file: &str,
|
||||||
|
opts: &crate::object_api::ObjectOptions,
|
||||||
|
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
|
||||||
|
crate::config::com::read_config_preserve_empty_with_metadata_opts(api, file, opts).await
|
||||||
|
}
|
||||||
|
|
||||||
pub use crate::config::com::{
|
pub use crate::config::com::{
|
||||||
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
|
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
|
||||||
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_config, delete_config_no_lock,
|
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_config, delete_config_no_lock,
|
||||||
@@ -382,43 +391,6 @@ pub mod config {
|
|||||||
with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
|
with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
|
||||||
with_server_config_write_lock,
|
with_server_config_write_lock,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Observe persisted config bytes and their reader-associated metadata within a byte limit.
|
|
||||||
pub async fn read_config_observation(
|
|
||||||
api: std::sync::Arc<crate::store::ECStore>,
|
|
||||||
file: &str,
|
|
||||||
max_bytes: usize,
|
|
||||||
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
|
|
||||||
crate::config::com::read_config_observation(api, file, max_bytes).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Observe bounded config bytes while the caller retains the object's write owner.
|
|
||||||
pub async fn read_config_observation_no_lock(
|
|
||||||
api: std::sync::Arc<crate::store::ECStore>,
|
|
||||||
file: &str,
|
|
||||||
max_bytes: usize,
|
|
||||||
guard: &crate::object_api::WriteCommitGuard,
|
|
||||||
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
|
|
||||||
crate::config::com::read_config_observation_no_lock(api, file, max_bytes, guard).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read config bytes and metadata without treating an empty payload as absent.
|
|
||||||
pub async fn read_config_preserve_empty_with_metadata_opts(
|
|
||||||
api: std::sync::Arc<crate::store::ECStore>,
|
|
||||||
file: &str,
|
|
||||||
opts: &crate::object_api::ObjectOptions,
|
|
||||||
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
|
|
||||||
crate::config::com::read_config_preserve_empty_with_metadata_opts(api, file, opts).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn read_config_limited_preserve_empty_with_metadata_opts(
|
|
||||||
api: std::sync::Arc<crate::store::ECStore>,
|
|
||||||
file: &str,
|
|
||||||
opts: &crate::object_api::ObjectOptions,
|
|
||||||
max_bytes: usize,
|
|
||||||
) -> crate::error::Result<(Vec<u8>, crate::object_api::ObjectInfo)> {
|
|
||||||
crate::config::com::read_config_limited_preserve_empty_with_metadata_opts(api, file, opts, max_bytes).await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod storageclass {
|
pub mod storageclass {
|
||||||
@@ -466,21 +438,31 @@ pub mod data_usage {
|
|||||||
pub mod disk {
|
pub mod disk {
|
||||||
pub use crate::disk::disk_store::get_object_disk_read_timeout;
|
pub use crate::disk::disk_store::get_object_disk_read_timeout;
|
||||||
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
|
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub use crate::disk::local::{local_durability_modes, set_local_rename_data_fail_after_metadata_commit};
|
||||||
|
pub use crate::disk::multipart_source::{
|
||||||
|
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
|
||||||
|
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceResponse,
|
||||||
|
MultipartSourceSnapshot, PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage, capture_source_condition,
|
||||||
|
require_source_condition, strip_native_controls,
|
||||||
|
};
|
||||||
|
#[cfg(all(feature = "test-util", unix))]
|
||||||
|
pub use crate::disk::os::set_local_dir_fsync_failure;
|
||||||
#[cfg(all(feature = "test-util", not(windows)))]
|
#[cfg(all(feature = "test-util", not(windows)))]
|
||||||
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
|
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
|
||||||
pub use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion};
|
|
||||||
pub use crate::disk::{
|
pub use crate::disk::{
|
||||||
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
||||||
CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, CurrentDeleteTransition,
|
CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, CurrentDeleteTransition,
|
||||||
DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest,
|
DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest,
|
||||||
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
|
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
|
||||||
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
|
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
|
||||||
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, LegacyFileStream, LegacyFileWrite,
|
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
|
||||||
LegacyMetadataWrite, LegacyPartMutation, LegacyPartOperation, LegacyPathDelete, LegacyPathDeleteOperation,
|
POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup, PoolMetaNativeInspection,
|
||||||
LegacyRenameData, LegacyRenameFile, NsScannerOpenRequest, OldCurrentSize, PartTransactionAction,
|
PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
|
||||||
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
|
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET,
|
||||||
STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path,
|
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken,
|
||||||
new_disk, validate_batch_read_version_item_count,
|
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path, new_disk,
|
||||||
|
validate_batch_read_version_item_count,
|
||||||
};
|
};
|
||||||
pub use bytes::Bytes;
|
pub use bytes::Bytes;
|
||||||
pub use endpoint::Endpoint;
|
pub use endpoint::Endpoint;
|
||||||
@@ -553,13 +535,16 @@ pub mod metrics {
|
|||||||
pub mod notification {
|
pub mod notification {
|
||||||
pub use crate::services::notification_sys::{
|
pub use crate::services::notification_sys::{
|
||||||
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
|
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
|
||||||
LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
LegacyTransitionStateReconcileFleetProofToken, MrfLifecycleWriterFleetProofToken, NotificationPeerErr, NotificationSys,
|
||||||
acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
|
ScannerPublicationLeaseGrant, acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
|
||||||
acquire_legacy_transition_state_reconcile_fleet_proof, configured_fleet_topology_fingerprint,
|
acquire_legacy_transition_state_reconcile_fleet_proof, acquire_mrf_lifecycle_writer_fleet_proof,
|
||||||
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, ilm_recovery_export_fleet_proof_matches,
|
configured_fleet_topology_fingerprint, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||||
ilm_recovery_export_local_process_epoch, ilm_recovery_export_member_epochs_sha256,
|
ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_local_process_epoch,
|
||||||
ilm_recovery_export_topology_generation, legacy_transition_state_reconcile_fleet_proof_matches,
|
ilm_recovery_export_member_epochs_sha256, ilm_recovery_export_topology_generation,
|
||||||
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
legacy_transition_state_reconcile_fleet_proof_matches, mrf_lifecycle_reader_local_process_epoch,
|
||||||
|
mrf_lifecycle_writer_fleet_proof_current, mrf_lifecycle_writer_fleet_proof_matches, new_global_notification_sys,
|
||||||
|
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||||
|
with_mrf_lifecycle_writer_fleet_proof,
|
||||||
};
|
};
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
pub use crate::services::notification_sys::{
|
pub use crate::services::notification_sys::{
|
||||||
@@ -675,7 +660,6 @@ pub mod store_list {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod storage {
|
pub mod storage {
|
||||||
pub use crate::bucket::artifact::inventory::ArtifactAttemptInventoryFrame;
|
|
||||||
pub use crate::core::pools::{HealLifecycleExpiryContext, POOL_META_NAME};
|
pub use crate::core::pools::{HealLifecycleExpiryContext, POOL_META_NAME};
|
||||||
pub use crate::store::HealWalkVersion;
|
pub use crate::store::HealWalkVersion;
|
||||||
pub use crate::store::{
|
pub use crate::store::{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::bucket::metadata::BUCKET_INCARNATION_FILE;
|
|
||||||
use crate::config::com::read_config_observation;
|
|
||||||
use crate::object_api::WriteCompletion;
|
|
||||||
|
|
||||||
impl ECStore {
|
|
||||||
/// Permanently retain this managed bucket's versions for historical restores.
|
|
||||||
/// Lifecycle WRITE drains earlier exact deletes. The detached owner keeps
|
|
||||||
/// lifecycle -> metadata transaction locks until every metadata write drains.
|
|
||||||
pub async fn retain_artifact_backups(self: Arc<Self>, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<()> {
|
|
||||||
let bucket = bucket.to_owned();
|
|
||||||
let base_prefix = base_prefix.to_owned();
|
|
||||||
let ctx = Arc::clone(&self.ctx);
|
|
||||||
ctx.run_detached_mutation(async move {
|
|
||||||
require_put_seal_topology(&self).await?;
|
|
||||||
if self.ctx.lock_manager().is_disabled() {
|
|
||||||
return Err(Error::other("artifact backup retention requires namespace locking"));
|
|
||||||
}
|
|
||||||
let lifecycle = self.acquire_bucket_lifecycle_write_lock(&bucket).await?;
|
|
||||||
let transaction = metadata_sys::acquire_bucket_metadata_transaction_lock_in(&self.ctx, &bucket).await?;
|
|
||||||
let mut metadata = self.backup_retention_metadata(&bucket, incarnation, &base_prefix).await?;
|
|
||||||
let check = || {
|
|
||||||
if lifecycle.is_lock_lost() || transaction.is_lock_lost() {
|
|
||||||
Err(Error::other("artifact backup retention fence was lost"))
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
check()?;
|
|
||||||
// Even an idempotent retry rewrites with TailDrained: an earlier
|
|
||||||
// interrupted call may have reached only some metadata replicas.
|
|
||||||
metadata.artifact_backup_retained = true;
|
|
||||||
let mut opts = ObjectOptions {
|
|
||||||
max_parity: true,
|
|
||||||
write_completion: WriteCompletion::TailDrained,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
opts.add_bucket_lifecycle_lock_guard(&lifecycle);
|
|
||||||
opts.add_namespace_lock_guard(&transaction);
|
|
||||||
metadata.save_with_store_options(self.clone(), &opts).await?;
|
|
||||||
check()?;
|
|
||||||
let sys = metadata_sys::require_bucket_metadata_sys_in(&self.ctx)?;
|
|
||||||
let sys = sys.read().await.clone();
|
|
||||||
sys.set_validated(metadata).await?;
|
|
||||||
check()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|_| Error::other("artifact backup retention owner failed"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Observe the immutable bucket identity and retention in one locked read.
|
|
||||||
pub async fn inspect_artifact_backup_retention(&self, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<bool> {
|
|
||||||
require_put_seal_topology(self).await?;
|
|
||||||
if self.ctx.lock_manager().is_disabled() {
|
|
||||||
return Err(Error::other("artifact backup retention requires namespace locking"));
|
|
||||||
}
|
|
||||||
let lifecycle = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
|
||||||
let metadata = self.backup_retention_metadata(bucket, incarnation, base_prefix).await?;
|
|
||||||
if lifecycle.is_lock_lost() {
|
|
||||||
return Err(Error::other("artifact backup retention observation fence was lost"));
|
|
||||||
}
|
|
||||||
Ok(metadata.artifact_backup_retained)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn backup_retention_metadata(&self, bucket: &str, incarnation: Uuid, base_prefix: &str) -> Result<BucketMetadata> {
|
|
||||||
if incarnation.is_nil() || !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(base_prefix) {
|
|
||||||
return Err(Error::other("artifact backup retention identity is invalid"));
|
|
||||||
}
|
|
||||||
let mut metadata = self.read_backup_retention_metadata(bucket).await?;
|
|
||||||
let store = metadata_sys::object_store_in(&self.ctx).await?;
|
|
||||||
let (sidecar, _) =
|
|
||||||
read_config_observation(store, &format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}"), 16).await?;
|
|
||||||
metadata.parse_all_configs()?;
|
|
||||||
if metadata.bucket_incarnation_id != incarnation
|
|
||||||
|| Uuid::from_slice(&sidecar).ok() != Some(incarnation)
|
|
||||||
|| metadata.artifact_base_prefix.as_deref() != Some(base_prefix)
|
|
||||||
|| !metadata.bucket_creation_committed
|
|
||||||
|| !metadata.versioning_config.as_ref().is_some_and(|versioning| {
|
|
||||||
versioning.enabled()
|
|
||||||
&& !versioning.exclude_folders.unwrap_or(false)
|
|
||||||
&& versioning.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err(Error::other("artifact backup retention identity mismatch"));
|
|
||||||
}
|
|
||||||
metadata.bucket_incarnation_sidecar = true;
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Caller must hold the bucket lifecycle fence. An unreadable or missing
|
|
||||||
/// record cannot prove that historical backup protection was never enabled.
|
|
||||||
pub(crate) async fn require_no_artifact_backup_retention(&self, bucket: &str) -> Result<()> {
|
|
||||||
if self.read_backup_retention_metadata(bucket).await?.artifact_backup_retained {
|
|
||||||
return Err(Error::other("artifact versions are retained for backups"));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_backup_retention_metadata(&self, bucket: &str) -> Result<BucketMetadata> {
|
|
||||||
if bucket.is_empty()
|
|
||||||
|| super::super::utils::has_bad_path_component(bucket)
|
|
||||||
|| super::super::utils::is_meta_bucketname(bucket)
|
|
||||||
{
|
|
||||||
return Err(Error::other("artifact backup retention bucket is invalid"));
|
|
||||||
}
|
|
||||||
let store = metadata_sys::object_store_in(&self.ctx).await?;
|
|
||||||
if store.id != self.id {
|
|
||||||
return Err(Error::other("artifact backup retention belongs to another store"));
|
|
||||||
}
|
|
||||||
let (bytes, _) = read_config_observation(store, &BucketMetadata::new(bucket).save_file_path(), 64 * 1024 * 1024).await?;
|
|
||||||
let metadata = BucketMetadata::decode_file(&bytes)?;
|
|
||||||
if metadata.name != bucket {
|
|
||||||
return Err(Error::other("artifact backup retention bucket mismatch"));
|
|
||||||
}
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::storage_api_contracts::object::ObjectOperations as _;
|
|
||||||
|
|
||||||
/// In-process authority for exactly one drained managed version. No caller can
|
|
||||||
/// construct this capability or serialize it into a disk/RPC request.
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub struct ArtifactDeleteTarget {
|
|
||||||
store: Uuid,
|
|
||||||
record: ArtifactVersionRetirementRecord,
|
|
||||||
lifecycle: Arc<rustfs_lock::NamespaceLockGuard>,
|
|
||||||
attempt: WriteCommitGuard,
|
|
||||||
repair: WriteCommitGuard,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ArtifactDeleteTarget {
|
|
||||||
fn check_live(&self) -> Result<()> {
|
|
||||||
if self.lifecycle.is_lock_lost() || self.attempt.is_lock_lost() || self.repair.is_lock_lost() {
|
|
||||||
return Err(Error::other("artifact exact delete owned fence was lost"));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn lifecycle(&self) -> Arc<rustfs_lock::NamespaceLockGuard> {
|
|
||||||
Arc::clone(&self.lifecycle)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate(&self, store: &ECStore, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<()> {
|
|
||||||
if self.store != store.id
|
|
||||||
|| bucket != self.record.bucket
|
|
||||||
|| rustfs_utils::path::encode_dir_object(object) != self.record.object
|
|
||||||
|| opts.expected_bucket_incarnation_id != Some(self.record.incarnation_id)
|
|
||||||
|| opts.version_id.as_deref() != Some(self.record.version_id.to_string().as_str())
|
|
||||||
|| !opts.versioned
|
|
||||||
|| opts.version_suspended
|
|
||||||
|| opts.delete_prefix
|
|
||||||
|| opts.delete_marker
|
|
||||||
|| opts.data_movement
|
|
||||||
|| opts.lifecycle_delete_all.is_some()
|
|
||||||
|| self.lifecycle.is_lock_lost()
|
|
||||||
|| self.attempt.is_lock_lost()
|
|
||||||
|| self.repair.is_lock_lost()
|
|
||||||
{
|
|
||||||
return Err(Error::other("artifact exact delete target or owned fence mismatch"));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ECStore {
|
|
||||||
/// Read-only capability observation; each mutation revalidates the topology.
|
|
||||||
pub async fn supports_artifact_exact_delete(&self) -> bool {
|
|
||||||
require_local_put_seal_topology(self).await.is_ok() && !self.ctx.lock_manager().is_disabled()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an exact version in a fully sealed attempt. This authenticates the
|
|
||||||
/// storage identity and drains writers; the caller still owns GC eligibility
|
|
||||||
/// and cleanup IAM. Distributed receiver deletion is not yet supported.
|
|
||||||
pub async fn delete_artifact_object_version(
|
|
||||||
self: Arc<Self>,
|
|
||||||
bucket: &str,
|
|
||||||
expected_incarnation: Uuid,
|
|
||||||
attempt_prefix: &str,
|
|
||||||
object: &str,
|
|
||||||
version: Uuid,
|
|
||||||
) -> Result<()> {
|
|
||||||
require_local_put_seal_topology(&self).await?;
|
|
||||||
if self.ctx.lock_manager().is_disabled() {
|
|
||||||
return Err(Error::other("artifact exact deletion requires namespace locking"));
|
|
||||||
}
|
|
||||||
let lifecycle = Arc::new(self.acquire_bucket_lifecycle_read_lock(bucket).await?);
|
|
||||||
self.require_no_artifact_backup_retention(bucket).await?;
|
|
||||||
let put = self
|
|
||||||
.inventory_artifact_record(bucket, expected_incarnation, attempt_prefix)
|
|
||||||
.await?;
|
|
||||||
let repair = ArtifactRepairSealRecord::new(&put);
|
|
||||||
let record = ArtifactVersionRetirementRecord::new(&put, object, version)?;
|
|
||||||
let object = object.to_owned();
|
|
||||||
let store = Arc::clone(&self);
|
|
||||||
// Lifecycle -> attempt -> repair seal -> metadata transaction -> object.
|
|
||||||
// The spawned owner, and the disk deletion tail's typed capability, keep
|
|
||||||
// these locks after cancellation of the HTTP caller.
|
|
||||||
with_config_object_write_lock(self, put.path(), move |attempt| async move {
|
|
||||||
if !put.exists(Arc::clone(&store), &attempt).await? {
|
|
||||||
return Err(Error::other("artifact exact deletion requires sealed PUTs"));
|
|
||||||
}
|
|
||||||
let lock = store.new_ns_lock(RUSTFS_META_BUCKET, &repair.path()).await?;
|
|
||||||
let repair_owner = WriteCommitGuard::acquire(&lock, get_lock_acquire_timeout()).await?;
|
|
||||||
if !repair.exists(Arc::clone(&store), &repair_owner).await? {
|
|
||||||
return Err(Error::other("artifact exact deletion requires sealed repairs"));
|
|
||||||
}
|
|
||||||
let receivers = store.capture_artifact_receivers().await?;
|
|
||||||
store
|
|
||||||
.close_artifact_receiver_snapshot(&put, crate::disk::local::ArtifactLeaseDomain::Put, &receivers)
|
|
||||||
.await?;
|
|
||||||
store
|
|
||||||
.close_artifact_receiver_snapshot(&put, crate::disk::local::ArtifactLeaseDomain::Repair, &receivers)
|
|
||||||
.await?;
|
|
||||||
store.verify_artifact_receiver_snapshot(&put, &receivers).await?;
|
|
||||||
let target = Arc::new(ArtifactDeleteTarget {
|
|
||||||
store: store.id,
|
|
||||||
record,
|
|
||||||
lifecycle,
|
|
||||||
attempt,
|
|
||||||
repair: repair_owner,
|
|
||||||
});
|
|
||||||
let mut opts = ObjectOptions {
|
|
||||||
versioned: true,
|
|
||||||
version_id: Some(version.to_string()),
|
|
||||||
expected_bucket_incarnation_id: Some(expected_incarnation),
|
|
||||||
artifact_delete_target: Some(Arc::clone(&target)),
|
|
||||||
delete_mutation_owner: Some(target.clone()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
opts.add_write_commit_guard(&target.attempt);
|
|
||||||
opts.add_write_commit_guard(&target.repair);
|
|
||||||
// These owners fence seal records, not the object. Ordinary DELETE
|
|
||||||
// must still acquire and retain its exact object WRITE owner.
|
|
||||||
opts.no_lock = false;
|
|
||||||
target.check_live()?;
|
|
||||||
// Preserve the normal exact DELETE's Object Lock, quorum, journal,
|
|
||||||
// cache and capacity behavior. Its success is logical deletion, not
|
|
||||||
// a claim that every offline shard has been physically erased.
|
|
||||||
match store.delete_object(&put.bucket, &object, opts).await {
|
|
||||||
Ok(info) => {
|
|
||||||
// Match the S3 request layer's committed-generation quota
|
|
||||||
// delta. A strict absence retry must not subtract twice.
|
|
||||||
if !info.name.is_empty()
|
|
||||||
&& !info.delete_marker
|
|
||||||
&& let Ok(size) = crate::data_usage::quota_object_size(&info)
|
|
||||||
{
|
|
||||||
crate::data_usage::record_bucket_object_delete_memory(&put.bucket, size, false).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(Error::VersionNotFound(bucket, object, version))
|
|
||||||
if bucket == target.record.bucket
|
|
||||||
&& rustfs_utils::path::encode_dir_object(&object) == target.record.object
|
|
||||||
&& version == target.record.version_id.to_string() => {}
|
|
||||||
Err(Error::ObjectNotFound(bucket, object))
|
|
||||||
if bucket == target.record.bucket
|
|
||||||
&& rustfs_utils::path::encode_dir_object(&object) == target.record.object => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
store.verify_artifact_receiver_snapshot(&put, &receivers).await?;
|
|
||||||
store
|
|
||||||
.inventory_artifact_record(&put.bucket, expected_incarnation, &put.attempt_prefix)
|
|
||||||
.await?;
|
|
||||||
target.check_live()
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::disk::{ArtifactInventoryFrame, ArtifactInventoryVersion, Disk};
|
|
||||||
use rustfs_protos::proto_gen::node_service::ArtifactInventoryPhase;
|
|
||||||
use tokio::{sync::mpsc, time::Instant};
|
|
||||||
|
|
||||||
/// Physical observations retain their member. Replicas are not extra business
|
|
||||||
/// versions and this stream makes no quorum-readability or GC claim.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ArtifactAttemptInventoryFrame {
|
|
||||||
Observations {
|
|
||||||
endpoint: String,
|
|
||||||
disk_id: Uuid,
|
|
||||||
versions: Vec<ArtifactInventoryVersion>,
|
|
||||||
},
|
|
||||||
Complete {
|
|
||||||
members: usize,
|
|
||||||
observations: u64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ECStore {
|
|
||||||
/// Inventory one fully sealed attempt across every configured receiver.
|
|
||||||
/// Earlier output is staging only; only the final frame plus Ok is complete.
|
|
||||||
pub async fn inventory_artifact_attempt(
|
|
||||||
self: Arc<Self>,
|
|
||||||
bucket: &str,
|
|
||||||
incarnation: Uuid,
|
|
||||||
prefix: &str,
|
|
||||||
deadline: Instant,
|
|
||||||
output: mpsc::Sender<ArtifactAttemptInventoryFrame>,
|
|
||||||
) -> Result<()> {
|
|
||||||
tokio::time::timeout_at(deadline, Box::pin(async move {
|
|
||||||
require_put_seal_topology(&self).await?;
|
|
||||||
if self.ctx.lock_manager().is_disabled() { return Err(Error::other("artifact inventory requires namespace locking")); }
|
|
||||||
let lifecycle = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
|
||||||
let put = self.inventory_artifact_record(bucket, incarnation, prefix).await?;
|
|
||||||
let repair = ArtifactRepairSealRecord::new(&put);
|
|
||||||
// Lifecycle -> attempt -> repair record. Receivers do not reenter
|
|
||||||
// distributed locks; actual two-domain disk owners guard their reads.
|
|
||||||
let store = self.clone();
|
|
||||||
let bucket = bucket.to_owned();
|
|
||||||
let prefix = prefix.to_owned();
|
|
||||||
with_config_object_write_lock(self, put.path(), move |attempt| async move {
|
|
||||||
tokio::time::timeout_at(deadline, async {
|
|
||||||
if !put.exists(store.clone(), &attempt).await? { return Err(Error::other("artifact inventory requires PUT seal record")); }
|
|
||||||
let lock = store.new_ns_lock(RUSTFS_META_BUCKET, &repair.path()).await?;
|
|
||||||
let repair_owner = WriteCommitGuard::acquire(&lock, get_lock_acquire_timeout()).await?;
|
|
||||||
if !repair.exists(store.clone(), &repair_owner).await? { return Err(Error::other("artifact inventory requires repair seal record")); }
|
|
||||||
let receivers = store.capture_artifact_receivers().await?;
|
|
||||||
let mut observations = 0u64;
|
|
||||||
for (endpoint, (disk, expected)) in &receivers {
|
|
||||||
if lifecycle.is_lock_lost() || attempt.is_lock_lost() || repair_owner.is_lock_lost() {
|
|
||||||
return Err(Error::other("artifact inventory fence was lost"));
|
|
||||||
}
|
|
||||||
let (sender, mut frames) = mpsc::channel(2);
|
|
||||||
let scan = async {
|
|
||||||
match disk.as_ref() {
|
|
||||||
Disk::Local(local) => local.get_disk().inventory_artifact_attempt(*expected, &bucket, &prefix, put.identity(), deadline, sender).await,
|
|
||||||
Disk::Remote(remote) => remote.inventory_artifact_attempt(*expected, (&bucket, incarnation), &prefix, ArtifactInventoryPhase::ArtifactInventoryScan, deadline, sender).await,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::pin!(scan);
|
|
||||||
let mut sequence = 0;
|
|
||||||
let mut count = 0u64;
|
|
||||||
let mut terminal = false;
|
|
||||||
let mut ended = false;
|
|
||||||
let mut done = false;
|
|
||||||
while !ended || !done {
|
|
||||||
tokio::select! {
|
|
||||||
result = &mut scan, if !done => { result.map_err(Error::other)?; done = true; },
|
|
||||||
frame = frames.recv(), if !ended => match frame {
|
|
||||||
Some(ArtifactInventoryFrame::Entries { sequence: actual, versions }) if !terminal && actual == sequence => {
|
|
||||||
count = count.checked_add(u64::try_from(versions.len()).map_err(Error::other)?).ok_or_else(|| Error::other("artifact inventory count overflow"))?;
|
|
||||||
output.send(ArtifactAttemptInventoryFrame::Observations { endpoint: endpoint.clone(), disk_id: *expected, versions }).await
|
|
||||||
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
|
|
||||||
sequence += 1;
|
|
||||||
}
|
|
||||||
Some(ArtifactInventoryFrame::Complete { disk_id, incarnation: actual, attempt_prefix, entries })
|
|
||||||
if !terminal && disk_id == *expected && actual == incarnation && attempt_prefix == prefix && entries == count => terminal = true,
|
|
||||||
Some(_) => return Err(Error::other("artifact inventory member stream is invalid")),
|
|
||||||
None => ended = true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !terminal { return Err(Error::other("artifact inventory member is incomplete")); }
|
|
||||||
observations = observations.checked_add(count).ok_or_else(|| Error::other("artifact inventory count overflow"))?;
|
|
||||||
}
|
|
||||||
store.verify_inventory_members(&put, &receivers, deadline).await?;
|
|
||||||
let current = store.artifact_inventory_receiver_identity(&bucket, incarnation, &prefix).await?;
|
|
||||||
if current.receiver_key != put.identity().receiver_key || lifecycle.is_lock_lost() || attempt.is_lock_lost() || repair_owner.is_lock_lost() {
|
|
||||||
return Err(Error::other("artifact inventory authority changed"));
|
|
||||||
}
|
|
||||||
output.send(ArtifactAttemptInventoryFrame::Complete { members: receivers.len(), observations }).await
|
|
||||||
.map_err(|_| Error::other("artifact inventory consumer closed"))?;
|
|
||||||
Ok(())
|
|
||||||
}).await.map_err(|_| Error::other("artifact inventory deadline exceeded"))?
|
|
||||||
}).await?
|
|
||||||
})).await.map_err(|_| Error::other("artifact inventory deadline exceeded"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) async fn inventory_artifact_record(&self, bucket: &str, expected: Uuid, prefix: &str) -> Result<PutSealRecord> {
|
|
||||||
use crate::bucket::metadata::BUCKET_INCARNATION_FILE;
|
|
||||||
use crate::config::com::read_config_observation;
|
|
||||||
if expected.is_nil()
|
|
||||||
|| crate::bucket::utils::has_bad_path_component(bucket)
|
|
||||||
|| bucket.is_empty()
|
|
||||||
|| crate::bucket::utils::is_meta_bucketname(bucket)
|
|
||||||
{
|
|
||||||
return Err(Error::other("artifact inventory authority is invalid"));
|
|
||||||
}
|
|
||||||
let store = metadata_sys::object_store_in(&self.ctx).await?;
|
|
||||||
if store.id != self.id {
|
|
||||||
return Err(Error::other("artifact inventory authority belongs to another store"));
|
|
||||||
}
|
|
||||||
// Do not enter metadata_sys's lazy bucket READ/migration path: the
|
|
||||||
// coordinator already owns lifecycle READ and a writer may be queued.
|
|
||||||
// Observation reads bypass cache and suppress physical read repair.
|
|
||||||
let path = BucketMetadata::new(bucket).save_file_path();
|
|
||||||
let (bytes, _) = read_config_observation(store.clone(), &path, 64 * 1024 * 1024).await?;
|
|
||||||
let mut metadata = BucketMetadata::decode_file(&bytes)?;
|
|
||||||
let (sidecar, _) =
|
|
||||||
read_config_observation(store, &format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}"), 16).await?;
|
|
||||||
if metadata.name != bucket
|
|
||||||
|| !metadata.bucket_creation_committed
|
|
||||||
|| metadata.bucket_incarnation_id != expected
|
|
||||||
|| Uuid::from_slice(&sidecar).ok() != Some(expected)
|
|
||||||
{
|
|
||||||
return Err(Error::other("artifact inventory incarnation mismatch"));
|
|
||||||
}
|
|
||||||
metadata.parse_all_configs()?;
|
|
||||||
if !metadata.versioning_config.as_ref().is_some_and(|config| {
|
|
||||||
config.enabled()
|
|
||||||
&& !config.exclude_folders.unwrap_or(false)
|
|
||||||
&& config.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
|
|
||||||
}) {
|
|
||||||
return Err(Error::other("artifact inventory requires managed versioning"));
|
|
||||||
}
|
|
||||||
PutSealRecord::new(&metadata, prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn artifact_inventory_receiver_identity(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
expected: Uuid,
|
|
||||||
prefix: &str,
|
|
||||||
) -> Result<ArtifactPutIdentity> {
|
|
||||||
Ok(self.inventory_artifact_record(bucket, expected, prefix).await?.identity())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn verify_inventory_members(
|
|
||||||
&self,
|
|
||||||
record: &PutSealRecord,
|
|
||||||
captured: &ArtifactReceiverSnapshot,
|
|
||||||
deadline: Instant,
|
|
||||||
) -> Result<()> {
|
|
||||||
for final_pass in [false, true] {
|
|
||||||
let current = self.artifact_receiver_inventory().await?;
|
|
||||||
if current.len() != captured.len()
|
|
||||||
|| current
|
|
||||||
.iter()
|
|
||||||
.any(|(endpoint, disk)| !captured.get(endpoint).is_some_and(|(old, _)| Arc::ptr_eq(old, disk)))
|
|
||||||
{
|
|
||||||
return Err(Error::other("artifact inventory membership changed"));
|
|
||||||
}
|
|
||||||
if final_pass {
|
|
||||||
for (disk, expected) in captured.values() {
|
|
||||||
if disk.cached_disk_id().await != Some(*expected) {
|
|
||||||
return Err(Error::other("artifact inventory expected identity changed"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
for (disk, expected) in captured.values() {
|
|
||||||
if disk.cached_disk_id().await != Some(*expected) {
|
|
||||||
return Err(Error::other("artifact inventory expected identity changed"));
|
|
||||||
}
|
|
||||||
match disk.as_ref() {
|
|
||||||
Disk::Local(local) => {
|
|
||||||
let _verification = local
|
|
||||||
.get_disk()
|
|
||||||
.verify_artifact_inventory_closed(*expected, record.identity(), deadline)
|
|
||||||
.await
|
|
||||||
.map_err(Error::other)?;
|
|
||||||
}
|
|
||||||
Disk::Remote(remote) => {
|
|
||||||
let (output, _unused) = mpsc::channel(1);
|
|
||||||
remote
|
|
||||||
.inventory_artifact_attempt(
|
|
||||||
*expected,
|
|
||||||
(&record.bucket, record.incarnation_id),
|
|
||||||
&record.attempt_prefix,
|
|
||||||
ArtifactInventoryPhase::ArtifactInventoryVerify,
|
|
||||||
deadline,
|
|
||||||
output,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(Error::other)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
// Copyright 2024 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::future::Future;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex, Weak};
|
|
||||||
|
|
||||||
/// Shares only reads started after a caller arrives; retains no idle results.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct OrdinaryClassificationRegistry {
|
|
||||||
gates: Mutex<HashMap<String, Weak<Gate>>>,
|
|
||||||
#[cfg(test)]
|
|
||||||
after_unregister: Option<std::sync::Barrier>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct Gate {
|
|
||||||
started_generation: AtomicU64,
|
|
||||||
completed: tokio::sync::Mutex<Option<CompletedRead>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CompletedRead {
|
|
||||||
generation: u64,
|
|
||||||
allowed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Registration<'a> {
|
|
||||||
registry: &'a OrdinaryClassificationRegistry,
|
|
||||||
bucket: &'a str,
|
|
||||||
gate: Option<Arc<Gate>>,
|
|
||||||
observed_generation: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OrdinaryClassificationRegistry {
|
|
||||||
pub(crate) async fn classify(&self, bucket: &str, read: impl Future<Output = bool>) -> bool {
|
|
||||||
self.register(bucket).classify(read).await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn register<'a>(&'a self, bucket: &'a str) -> Registration<'a> {
|
|
||||||
// Callers requiring physical admission must register after acquiring it.
|
|
||||||
// Release the registry lock before awaiting the gate, then acquire the
|
|
||||||
// existing metadata locks.
|
|
||||||
let mut gates = self.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
let gate = gates.get(bucket).and_then(Weak::upgrade).unwrap_or_else(|| {
|
|
||||||
let gate = Arc::new(Gate::default());
|
|
||||||
gates.insert(bucket.to_owned(), Arc::downgrade(&gate));
|
|
||||||
gate
|
|
||||||
});
|
|
||||||
let observed_generation = gate.started_generation.load(Ordering::Acquire);
|
|
||||||
Registration {
|
|
||||||
registry: self,
|
|
||||||
bucket,
|
|
||||||
gate: Some(gate),
|
|
||||||
observed_generation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Registration<'_> {
|
|
||||||
async fn classify(self, read: impl Future<Output = bool>) -> bool {
|
|
||||||
let Some(gate) = self.gate.as_ref() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let mut completed = gate.completed.lock().await;
|
|
||||||
// Only a read started after this caller registered can authorize it.
|
|
||||||
// Arrivals during a read therefore wait for the next fresh read.
|
|
||||||
if let Some(result) = completed
|
|
||||||
.as_ref()
|
|
||||||
.filter(|result| result.generation > self.observed_generation)
|
|
||||||
{
|
|
||||||
return result.allowed;
|
|
||||||
}
|
|
||||||
let Ok(previous) = gate
|
|
||||||
.started_generation
|
|
||||||
.try_update(Ordering::AcqRel, Ordering::Acquire, |generation| generation.checked_add(1))
|
|
||||||
else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
// Keep the gate through the original bounded metadata read. Cancellation
|
|
||||||
// releases it without publishing a result; another caller can read again.
|
|
||||||
let allowed = read.await;
|
|
||||||
*completed = Some(CompletedRead {
|
|
||||||
generation: previous + 1,
|
|
||||||
allowed,
|
|
||||||
});
|
|
||||||
allowed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Registration<'_> {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let Some(gate) = self.gate.take() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut gates = self.registry.gates.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
// Strong references are acquired and released under this registry lock,
|
|
||||||
// so concurrent final callers cannot both miss the last reference.
|
|
||||||
if Arc::strong_count(&gate) == 1
|
|
||||||
&& gates
|
|
||||||
.get(self.bucket)
|
|
||||||
.is_some_and(|registered| registered.ptr_eq(&Arc::downgrade(&gate)))
|
|
||||||
{
|
|
||||||
gates.remove(self.bucket);
|
|
||||||
}
|
|
||||||
drop(gate);
|
|
||||||
drop(gates);
|
|
||||||
#[cfg(test)]
|
|
||||||
if let Some(barrier) = &self.registry.after_unregister {
|
|
||||||
barrier.wait();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use futures::poll;
|
|
||||||
use std::task::Poll;
|
|
||||||
use tokio::sync::oneshot;
|
|
||||||
|
|
||||||
fn assert_empty(registry: &OrdinaryClassificationRegistry) {
|
|
||||||
assert!(registry.gates.lock().expect("classification registry").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn late_arrivals_share_only_the_next_fail_closed_read() {
|
|
||||||
let registry = OrdinaryClassificationRegistry::default();
|
|
||||||
let reads = AtomicU64::new(0);
|
|
||||||
let (release, waiting) = oneshot::channel();
|
|
||||||
let mut first = Box::pin(registry.classify("bucket", async {
|
|
||||||
reads.fetch_add(1, Ordering::Relaxed);
|
|
||||||
waiting.await.expect("release first read");
|
|
||||||
true
|
|
||||||
}));
|
|
||||||
assert!(poll!(first.as_mut()).is_pending());
|
|
||||||
let mut followers = (0..16)
|
|
||||||
.map(|_| {
|
|
||||||
Box::pin(registry.classify("bucket", async {
|
|
||||||
reads.fetch_add(1, Ordering::Relaxed);
|
|
||||||
false
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
for follower in &mut followers {
|
|
||||||
assert!(poll!(follower.as_mut()).is_pending());
|
|
||||||
}
|
|
||||||
release.send(()).expect("first reader still alive");
|
|
||||||
assert_eq!(poll!(first.as_mut()), Poll::Ready(true));
|
|
||||||
for follower in &mut followers {
|
|
||||||
assert_eq!(poll!(follower.as_mut()), Poll::Ready(false));
|
|
||||||
}
|
|
||||||
assert_eq!(reads.load(Ordering::Relaxed), 2);
|
|
||||||
assert_empty(®istry);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn successive_waves_always_read_again() {
|
|
||||||
let registry = OrdinaryClassificationRegistry::default();
|
|
||||||
let reads = AtomicU64::new(0);
|
|
||||||
for expected in [true, false, true] {
|
|
||||||
assert_eq!(
|
|
||||||
registry
|
|
||||||
.classify("bucket", async {
|
|
||||||
reads.fetch_add(1, Ordering::Relaxed);
|
|
||||||
expected
|
|
||||||
})
|
|
||||||
.await,
|
|
||||||
expected
|
|
||||||
);
|
|
||||||
assert_empty(®istry);
|
|
||||||
}
|
|
||||||
assert_eq!(reads.load(Ordering::Relaxed), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn canceled_leader_does_not_publish_or_strand_waiters() {
|
|
||||||
let registry = OrdinaryClassificationRegistry::default();
|
|
||||||
let reads = AtomicU64::new(0);
|
|
||||||
let old_registration = registry.register("bucket");
|
|
||||||
assert!(
|
|
||||||
registry
|
|
||||||
.classify("bucket", async {
|
|
||||||
reads.fetch_add(1, Ordering::Relaxed);
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
);
|
|
||||||
let mut leader = Box::pin(registry.classify("bucket", async {
|
|
||||||
reads.fetch_add(1, Ordering::Relaxed);
|
|
||||||
std::future::pending::<bool>().await
|
|
||||||
}));
|
|
||||||
assert!(poll!(leader.as_mut()).is_pending());
|
|
||||||
let mut old_waiter =
|
|
||||||
Box::pin(old_registration.classify(async { panic!("old waiter must reuse the completed post-registration read") }));
|
|
||||||
assert!(poll!(old_waiter.as_mut()).is_pending());
|
|
||||||
let mut late_waiter = Box::pin(registry.classify("bucket", async {
|
|
||||||
reads.fetch_add(1, Ordering::Relaxed);
|
|
||||||
false
|
|
||||||
}));
|
|
||||||
assert!(poll!(late_waiter.as_mut()).is_pending());
|
|
||||||
drop(leader);
|
|
||||||
assert_eq!(poll!(old_waiter.as_mut()), Poll::Ready(true));
|
|
||||||
assert_eq!(poll!(late_waiter.as_mut()), Poll::Ready(false));
|
|
||||||
assert_eq!(reads.load(Ordering::Relaxed), 3);
|
|
||||||
assert_empty(®istry);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn canceled_follower_does_not_retain_the_gate() {
|
|
||||||
let registry = OrdinaryClassificationRegistry::default();
|
|
||||||
let (release, waiting) = oneshot::channel();
|
|
||||||
let mut leader = Box::pin(registry.classify("bucket", async {
|
|
||||||
waiting.await.expect("release leader");
|
|
||||||
true
|
|
||||||
}));
|
|
||||||
assert!(poll!(leader.as_mut()).is_pending());
|
|
||||||
let mut follower = Box::pin(registry.classify("bucket", async { panic!("canceled follower must not read") }));
|
|
||||||
assert!(poll!(follower.as_mut()).is_pending());
|
|
||||||
drop(follower);
|
|
||||||
assert_eq!(registry.gates.lock().expect("classification registry").len(), 1);
|
|
||||||
release.send(()).expect("leader still alive");
|
|
||||||
assert_eq!(poll!(leader.as_mut()), Poll::Ready(true));
|
|
||||||
assert_empty(®istry);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn concurrent_final_registrations_remove_the_gate() {
|
|
||||||
let registry = OrdinaryClassificationRegistry {
|
|
||||||
after_unregister: Some(std::sync::Barrier::new(2)),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let first = registry.register("bucket");
|
|
||||||
let second = registry.register("bucket");
|
|
||||||
// Both destructors reach the unlocked barrier before either returns.
|
|
||||||
// Moving the Arc release after this barrier leaves a stale registry key.
|
|
||||||
std::thread::scope(|scope| {
|
|
||||||
scope.spawn(move || drop(first));
|
|
||||||
scope.spawn(move || drop(second));
|
|
||||||
});
|
|
||||||
assert_empty(®istry);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn buckets_and_stores_do_not_share_gates() {
|
|
||||||
let first_store = OrdinaryClassificationRegistry::default();
|
|
||||||
let second_store = OrdinaryClassificationRegistry::default();
|
|
||||||
let mut blocked = Box::pin(first_store.classify("first", std::future::pending()));
|
|
||||||
assert!(poll!(blocked.as_mut()).is_pending());
|
|
||||||
assert_eq!(poll!(Box::pin(first_store.classify("second", async { true }))), Poll::Ready(true));
|
|
||||||
assert_eq!(poll!(Box::pin(second_store.classify("first", async { false }))), Poll::Ready(false));
|
|
||||||
assert_empty(&second_store);
|
|
||||||
drop(blocked);
|
|
||||||
assert_empty(&first_store);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn generation_exhaustion_fails_closed_without_reading() {
|
|
||||||
let registry = OrdinaryClassificationRegistry::default();
|
|
||||||
let registration = registry.register("bucket");
|
|
||||||
let gate = registration.gate.as_ref().expect("registered gate");
|
|
||||||
gate.started_generation.store(u64::MAX, Ordering::Release);
|
|
||||||
*gate.completed.lock().await = Some(CompletedRead {
|
|
||||||
generation: u64::MAX,
|
|
||||||
allowed: true,
|
|
||||||
});
|
|
||||||
assert!(
|
|
||||||
!registry
|
|
||||||
.classify("bucket", async { panic!("exhausted generation must deny without reading") })
|
|
||||||
.await
|
|
||||||
);
|
|
||||||
assert_eq!(gate.started_generation.load(Ordering::Acquire), u64::MAX);
|
|
||||||
drop(registration);
|
|
||||||
assert_empty(®istry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -890,7 +890,6 @@ pub(crate) async fn cleanup_free_version_exact(
|
|||||||
let bucket_guard = api
|
let bucket_guard = api
|
||||||
.acquire_bucket_lifecycle_read_lock(&oi.bucket)
|
.acquire_bucket_lifecycle_read_lock(&oi.bucket)
|
||||||
.await
|
.await
|
||||||
.map(Arc::new)
|
|
||||||
.map_err(std::io::Error::other)?;
|
.map_err(std::io::Error::other)?;
|
||||||
let (lease, delete_plan) = acquire_free_version_tier_lease(oi, &api.tier_config_mgr()).await?;
|
let (lease, delete_plan) = acquire_free_version_tier_lease(oi, &api.tier_config_mgr()).await?;
|
||||||
let local_object = encode_dir_object(&oi.name);
|
let local_object = encode_dir_object(&oi.name);
|
||||||
@@ -959,7 +958,7 @@ pub(crate) async fn cleanup_free_version_exact(
|
|||||||
};
|
};
|
||||||
delete_request.set_tier_free_version();
|
delete_request.set_tier_free_version();
|
||||||
if let Err(err) = set
|
if let Err(err) = set
|
||||||
.delete_object_version_with_lifecycle_owner(&oi.bucket, &local_object, &delete_request, Arc::clone(&bucket_guard))
|
.delete_object_version(&oi.bucket, &local_object, &delete_request, false)
|
||||||
.await
|
.await
|
||||||
&& first_error.is_none()
|
&& first_error.is_none()
|
||||||
{
|
{
|
||||||
@@ -1087,8 +1086,9 @@ impl ExpiryState {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
fn new_with_unconsumed_worker_channel(capacity: usize) -> Arc<RwLock<Self>> {
|
#[doc(hidden)]
|
||||||
|
pub fn new_with_unconsumed_worker_channel(capacity: usize) -> Arc<RwLock<Self>> {
|
||||||
let (tx, rx) = mpsc::channel(capacity);
|
let (tx, rx) = mpsc::channel(capacity);
|
||||||
Arc::new(RwLock::new(Self {
|
Arc::new(RwLock::new(Self {
|
||||||
tasks_tx: vec![tx],
|
tasks_tx: vec![tx],
|
||||||
@@ -3517,13 +3517,21 @@ async fn stale_upload_current_size_with_opts(
|
|||||||
.await
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
Some(
|
stale_upload_size_from_parts(&parts)
|
||||||
parts
|
}
|
||||||
.parts
|
|
||||||
.iter()
|
fn stale_upload_size_from_parts(parts: &crate::storage_api_contracts::multipart::ListPartsInfo) -> Option<usize> {
|
||||||
.map(|part| part.actual_size.max(part.size as i64).max(0) as usize)
|
if parts.is_truncated {
|
||||||
.sum(),
|
return None;
|
||||||
)
|
}
|
||||||
|
parts.parts.iter().try_fold(0usize, |total, part| {
|
||||||
|
// ListParts represents a quorum-confirmed missing part with an empty ETag.
|
||||||
|
if part.etag.as_deref().is_none_or(str::is_empty) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let actual_size = usize::try_from(part.actual_size.max(0)).ok()?;
|
||||||
|
total.checked_add(part.size.max(actual_size))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stale_upload_lifecycle_due(
|
async fn stale_upload_lifecycle_due(
|
||||||
@@ -3540,18 +3548,43 @@ async fn stale_upload_lifecycle_due(
|
|||||||
let bucket = metadata.get(RUSTFS_MULTIPART_BUCKET_KEY)?;
|
let bucket = metadata.get(RUSTFS_MULTIPART_BUCKET_KEY)?;
|
||||||
let object = metadata.get(RUSTFS_MULTIPART_OBJECT_KEY)?;
|
let object = metadata.get(RUSTFS_MULTIPART_OBJECT_KEY)?;
|
||||||
|
|
||||||
let lifecycle = match metadata_boundary::get_lifecycle_config(bucket).await {
|
let mut lifecycle = match metadata_boundary::get_lifecycle_config(bucket).await {
|
||||||
Ok((lifecycle, _)) => lifecycle,
|
Ok((lifecycle, _)) => lifecycle,
|
||||||
Err(_) => return None,
|
Err(_) => return None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let has_size_filter = |filter: &s3s::dto::LifecycleRuleFilter| {
|
||||||
|
filter.object_size_greater_than.is_some()
|
||||||
|
|| filter.object_size_less_than.is_some()
|
||||||
|
|| filter
|
||||||
|
.and
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|and| and.object_size_greater_than.is_some() || and.object_size_less_than.is_some())
|
||||||
|
};
|
||||||
|
let needs_size = lifecycle
|
||||||
|
.rules
|
||||||
|
.iter()
|
||||||
|
.any(|rule| rule.abort_incomplete_multipart_upload.is_some() && rule.filter.as_ref().is_some_and(has_size_filter));
|
||||||
|
let size = if needs_size {
|
||||||
|
match stale_upload_current_size_with_opts(set, metadata, upload_dir, no_lock).await {
|
||||||
|
Some(size) => size,
|
||||||
|
None => {
|
||||||
|
// An unknown size cannot authorize a size-filtered abort.
|
||||||
|
// Unrelated rules can still be evaluated independently.
|
||||||
|
lifecycle
|
||||||
|
.rules
|
||||||
|
.retain(|rule| !rule.filter.as_ref().is_some_and(has_size_filter));
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
let object_opts = ObjectOpts {
|
let object_opts = ObjectOpts {
|
||||||
name: object.clone(),
|
name: object.clone(),
|
||||||
user_tags: metadata.get(AMZ_OBJECT_TAGGING).cloned().unwrap_or_default(),
|
user_tags: metadata.get(AMZ_OBJECT_TAGGING).cloned().unwrap_or_default(),
|
||||||
mod_time: Some(initiated),
|
mod_time: Some(initiated),
|
||||||
size: stale_upload_current_size_with_opts(set, metadata, upload_dir, no_lock)
|
size,
|
||||||
.await
|
|
||||||
.unwrap_or_default(),
|
|
||||||
is_latest: true,
|
is_latest: true,
|
||||||
delete_marker: false,
|
delete_marker: false,
|
||||||
user_defined: metadata.clone(),
|
user_defined: metadata.clone(),
|
||||||
@@ -3804,7 +3837,8 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
|
|||||||
for candidate in candidates.into_values() {
|
for candidate in candidates.into_values() {
|
||||||
let upload_dir = candidate.path.rsplit('/').next().unwrap_or_default().to_string();
|
let upload_dir = candidate.path.rsplit('/').next().unwrap_or_default().to_string();
|
||||||
let mut due = stale_upload_default_due(candidate.initiated, default_expiry);
|
let mut due = stale_upload_default_due(candidate.initiated, default_expiry);
|
||||||
if let Some(metadata) = candidate.metadata.as_ref()
|
if now < due
|
||||||
|
&& let Some(metadata) = candidate.metadata.as_ref()
|
||||||
&& let Some(lifecycle_due) = stale_upload_lifecycle_due(set, metadata, candidate.initiated, &upload_dir, false).await
|
&& let Some(lifecycle_due) = stale_upload_lifecycle_due(set, metadata, candidate.initiated, &upload_dir, false).await
|
||||||
&& lifecycle_due < due
|
&& lifecycle_due < due
|
||||||
{
|
{
|
||||||
@@ -3833,8 +3867,9 @@ async fn cleanup_stale_multipart_uploads_in_set(set: &Arc<SetDisks>, now: Offset
|
|||||||
let current_metadata = cleanup_guard.file_info().metadata.clone();
|
let current_metadata = cleanup_guard.file_info().metadata.clone();
|
||||||
let current_initiated = initiated_from_upload_dir(&upload_dir, cleanup_guard.file_info().mod_time);
|
let current_initiated = initiated_from_upload_dir(&upload_dir, cleanup_guard.file_info().mod_time);
|
||||||
let mut current_due = stale_upload_default_due(current_initiated, default_expiry);
|
let mut current_due = stale_upload_default_due(current_initiated, default_expiry);
|
||||||
if let Some(lifecycle_due) =
|
if now < current_due
|
||||||
stale_upload_lifecycle_due(set, ¤t_metadata, current_initiated, &upload_dir, true).await
|
&& let Some(lifecycle_due) =
|
||||||
|
stale_upload_lifecycle_due(set, ¤t_metadata, current_initiated, &upload_dir, true).await
|
||||||
&& lifecycle_due < current_due
|
&& lifecycle_due < current_due
|
||||||
{
|
{
|
||||||
current_due = lifecycle_due;
|
current_due = lifecycle_due;
|
||||||
@@ -8497,104 +8532,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn artifact_delete_entrypoints_free_version_cleaner_completes_with_queued_lifecycle_writer() {
|
|
||||||
let (disk_paths, ecstore) = setup_test_env().await;
|
|
||||||
let bucket = format!("free-version-queued-writer-{}", Uuid::new_v4());
|
|
||||||
let object = "free-version";
|
|
||||||
create_test_bucket(&ecstore, &bucket).await;
|
|
||||||
let (backend, identity) = register_recovery_mock_tier(&ecstore).await;
|
|
||||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, Some(identity)).await;
|
|
||||||
let oi = list_tier_free_versions(Arc::clone(&ecstore), 100, None, None, CancellationToken::new())
|
|
||||||
.await
|
|
||||||
.expect("list real persisted free version")
|
|
||||||
.items
|
|
||||||
.into_iter()
|
|
||||||
.find(|oi| oi.bucket == bucket)
|
|
||||||
.expect("free-version task must address this fixture");
|
|
||||||
assert!(oi.transitioned_object.free_version);
|
|
||||||
backend
|
|
||||||
.set_put_remote_version(Some(oi.transitioned_object.version_id.clone()))
|
|
||||||
.await;
|
|
||||||
let remote = TierConfigMgr::acquire_operation_lease(&ecstore.tier_config_mgr(), "WARM")
|
|
||||||
.await
|
|
||||||
.expect("remote fixture lease");
|
|
||||||
remote
|
|
||||||
.put(&oi.transitioned_object.name, ReaderImpl::Body(Bytes::from_static(b"body")), 4)
|
|
||||||
.await
|
|
||||||
.expect("seed exact remote object");
|
|
||||||
drop(remote);
|
|
||||||
|
|
||||||
let barrier = Arc::new(super::FreeVersionPostRemoteDeleteTestBarrier::default());
|
|
||||||
let cleanup_barrier = Arc::clone(&barrier);
|
|
||||||
let cleanup_store = Arc::clone(&ecstore);
|
|
||||||
let cleanup_oi = oi.clone();
|
|
||||||
let mut cleanup = tokio::spawn(async move {
|
|
||||||
super::FREE_VERSION_POST_REMOTE_DELETE_TEST_BARRIER
|
|
||||||
.scope(cleanup_barrier, async move {
|
|
||||||
super::cleanup_free_version_exact(cleanup_store, &cleanup_oi, &CancellationToken::new()).await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
tokio::time::timeout(StdDuration::from_secs(10), barrier.arrived.notified())
|
|
||||||
.await
|
|
||||||
.expect("cleaner owns lifecycle READ and has completed remote DELETE");
|
|
||||||
assert!(!backend.contains(&oi.transitioned_object.name).await);
|
|
||||||
for disk in &disk_paths {
|
|
||||||
assert!(
|
|
||||||
disk.join(&bucket).join(object).join(STORAGE_FORMAT_FILE).is_file(),
|
|
||||||
"local exact cleanup must still be pending at the boundary"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve first so the pending poll is the actual local lock wait.
|
|
||||||
// A later READ would normally be admitted alongside the cleaner's READ;
|
|
||||||
// its pending state independently proves this WRITE is now queued.
|
|
||||||
let lifecycle = ecstore
|
|
||||||
.new_ns_lock(&bucket, crate::storage_api_contracts::bucket::BUCKET_LIFECYCLE_LOCK_OBJECT)
|
|
||||||
.await
|
|
||||||
.expect("lifecycle namespace");
|
|
||||||
let mut writer = Box::pin(lifecycle.get_write_lock(StdDuration::from_secs(30)));
|
|
||||||
let writer_queued = futures::poll!(&mut writer).is_pending();
|
|
||||||
let mut later_reader = Box::pin(lifecycle.get_read_lock(StdDuration::from_secs(30)));
|
|
||||||
let later_reader_blocked = futures::poll!(&mut later_reader).is_pending();
|
|
||||||
drop(later_reader);
|
|
||||||
barrier.release.notify_one();
|
|
||||||
assert!(writer_queued, "cleaner must retain the real lifecycle READ owner");
|
|
||||||
assert!(later_reader_blocked, "queued lifecycle WRITE must block a later READ");
|
|
||||||
|
|
||||||
// Keep the same WRITE future queued, without cancelling/retrying it.
|
|
||||||
// Re-entering READ here would deadlock behind this waiting writer.
|
|
||||||
let finished = tokio::time::timeout(StdDuration::from_secs(10), &mut cleanup).await;
|
|
||||||
if finished.is_err() {
|
|
||||||
cleanup.abort();
|
|
||||||
drop(writer);
|
|
||||||
let _ = tokio::time::timeout(StdDuration::from_secs(5), &mut cleanup).await;
|
|
||||||
panic!("exact local free-version cleanup re-entered lifecycle READ behind the queued writer");
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
finished
|
|
||||||
.expect("bounded cleaner completion")
|
|
||||||
.expect("cleaner task")
|
|
||||||
.expect("exact local cleanup")
|
|
||||||
);
|
|
||||||
let writer = tokio::time::timeout(StdDuration::from_secs(10), writer)
|
|
||||||
.await
|
|
||||||
.expect("queued lifecycle WRITE proceeds after real cleanup drains")
|
|
||||||
.expect("lifecycle WRITE acquired");
|
|
||||||
for disk in &disk_paths {
|
|
||||||
assert!(!disk.join(&bucket).join(object).exists(), "free version remains on a real local disk");
|
|
||||||
}
|
|
||||||
assert_eq!(backend.remove_count().await, 1, "cleaner must delete the exact remote tuple once");
|
|
||||||
drop(writer);
|
|
||||||
ecstore
|
|
||||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("remove empty fixture bucket");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
@@ -11413,13 +11350,7 @@ mod tests {
|
|||||||
let marker = marker
|
let marker = marker
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.expect("production checkpoint should persist an object marker");
|
.expect("production checkpoint should persist an object marker");
|
||||||
let marker_suffix = marker
|
assert_eq!(marker, expected_marker, "production checkpoint should stop at the first page boundary");
|
||||||
.strip_prefix(&expected_marker)
|
|
||||||
.expect("production checkpoint should stop at the first page boundary");
|
|
||||||
assert!(
|
|
||||||
marker_suffix.is_empty() || marker_suffix.starts_with("[rustfs_cache:v2,"),
|
|
||||||
"unexpected production checkpoint marker suffix: {marker_suffix}"
|
|
||||||
);
|
|
||||||
assert_eq!(version_marker.as_deref(), Some("null"));
|
assert_eq!(version_marker.as_deref(), Some("null"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15973,6 +15904,261 @@ mod tests {
|
|||||||
assert!(ecstore.is_ok(), "fresh local ECStore boot should succeed, got {ecstore:?}");
|
assert!(ecstore.is_ok(), "fresh local ECStore boot should succeed, got {ecstore:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[serial]
|
||||||
|
async fn stale_multipart_cleanup_preserves_partial_metadata_without_upload_part_warning() {
|
||||||
|
use tracing::instrument::WithSubscriber as _;
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct CapturedLogs(Arc<StdMutex<Vec<u8>>>);
|
||||||
|
struct LogWriter(Arc<StdMutex<Vec<u8>>>);
|
||||||
|
impl std::io::Write for LogWriter {
|
||||||
|
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||||
|
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||||
|
Ok(bytes.len())
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs {
|
||||||
|
type Writer = LogWriter;
|
||||||
|
fn make_writer(&'a self) -> Self::Writer {
|
||||||
|
LogWriter(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (paths, ecstore) = setup_test_env().await;
|
||||||
|
let bucket = format!("stale-partial-{}", Uuid::new_v4().simple());
|
||||||
|
create_test_bucket(&ecstore, &bucket).await;
|
||||||
|
for copies in [1, 2] {
|
||||||
|
let object = format!("partial-{copies}.bin");
|
||||||
|
let initiated = OffsetDateTime::now_utc() - time::Duration::hours(30);
|
||||||
|
let upload = ecstore
|
||||||
|
.new_multipart_upload(
|
||||||
|
&bucket,
|
||||||
|
&object,
|
||||||
|
&ObjectOptions {
|
||||||
|
mod_time: Some(initiated),
|
||||||
|
max_parity: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("multipart upload should be created");
|
||||||
|
let decoded = base64_simd::URL_SAFE_NO_PAD
|
||||||
|
.decode_to_vec(upload.upload_id.as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
let decoded = String::from_utf8(decoded).unwrap();
|
||||||
|
let (_, upload_dir) = decoded.split_once('.').expect("deployment-prefixed upload id");
|
||||||
|
let upload_path = format!("{}/{upload_dir}", multipart_sha_dir(&bucket, &object));
|
||||||
|
let physical_paths: Vec<_> = paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.join(RUSTFS_META_MULTIPART_BUCKET).join(&upload_path))
|
||||||
|
.collect();
|
||||||
|
let original: Vec<_> = physical_paths
|
||||||
|
.iter()
|
||||||
|
.take(copies)
|
||||||
|
.map(|path| std::fs::read(path.join(STORAGE_FORMAT_FILE)).unwrap())
|
||||||
|
.collect();
|
||||||
|
let info = super::get_file_info(
|
||||||
|
&original[0],
|
||||||
|
RUSTFS_META_MULTIPART_BUCKET,
|
||||||
|
&upload_path,
|
||||||
|
"",
|
||||||
|
super::FileInfoOpts {
|
||||||
|
data: false,
|
||||||
|
include_free_versions: false,
|
||||||
|
include_part_checksums: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((info.erasure.data_blocks, info.erasure.parity_blocks), (2, 2));
|
||||||
|
for path in physical_paths.iter().skip(copies) {
|
||||||
|
fs::remove_dir_all(path).await.expect("remove selected metadata copies");
|
||||||
|
}
|
||||||
|
if copies == 2 {
|
||||||
|
ecstore
|
||||||
|
.get_multipart_info(&bucket, &object, &upload.upload_id, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("two metadata copies must meet read quorum");
|
||||||
|
}
|
||||||
|
|
||||||
|
let logs = CapturedLogs::default();
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::DEBUG)
|
||||||
|
.with_writer(logs.clone())
|
||||||
|
.with_ansi(false)
|
||||||
|
.without_time()
|
||||||
|
.finish();
|
||||||
|
cleanup_stale_multipart_uploads_once_at(
|
||||||
|
ecstore.clone(),
|
||||||
|
OffsetDateTime::now_utc(),
|
||||||
|
StdDuration::from_secs(24 * 60 * 60),
|
||||||
|
)
|
||||||
|
.with_subscriber(subscriber)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for (path, expected) in physical_paths.iter().zip(&original) {
|
||||||
|
assert_eq!(fs::read(path.join(STORAGE_FORMAT_FILE)).await.unwrap(), *expected);
|
||||||
|
}
|
||||||
|
let log_output = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
|
||||||
|
assert!(
|
||||||
|
log_output.contains(&upload_path),
|
||||||
|
"missing background diagnostic for {upload_path}: {log_output}"
|
||||||
|
);
|
||||||
|
assert!(log_output.contains("multipart_cleanup_lock_or_recheck_failed"), "{log_output}");
|
||||||
|
assert!(!log_output.contains("op=\"upload_part\""), "unexpected upload-part warning: {log_output}");
|
||||||
|
for path in physical_paths.iter().take(copies) {
|
||||||
|
fs::remove_dir_all(path).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
#[serial]
|
||||||
|
async fn stale_multipart_cleanup_lists_parts_only_when_size_is_needed() {
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use tracing::instrument::WithSubscriber as _;
|
||||||
|
use tracing_subscriber::prelude::*;
|
||||||
|
|
||||||
|
struct PartListCounter(Arc<AtomicUsize>);
|
||||||
|
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for PartListCounter {
|
||||||
|
fn on_new_span(
|
||||||
|
&self,
|
||||||
|
attrs: &tracing::span::Attributes<'_>,
|
||||||
|
_id: &tracing::span::Id,
|
||||||
|
_ctx: tracing_subscriber::layer::Context<'_, S>,
|
||||||
|
) {
|
||||||
|
if attrs.metadata().name() == "list_object_parts" {
|
||||||
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, ecstore) = setup_test_env().await;
|
||||||
|
let bucket = format!("stale-list-parts-{}", Uuid::new_v4().simple());
|
||||||
|
create_test_bucket(&ecstore, &bucket).await;
|
||||||
|
set_abort_incomplete_lifecycle(&bucket, "logs/", 0).await;
|
||||||
|
let now = OffsetDateTime::now_utc();
|
||||||
|
for (object, age, size_filter, should_list) in [
|
||||||
|
("logs/default-expired", time::Duration::hours(30), false, false),
|
||||||
|
("logs/lifecycle-only", time::Duration::minutes(5), false, false),
|
||||||
|
("logs/size-filter", time::Duration::minutes(5), true, true),
|
||||||
|
] {
|
||||||
|
if size_filter {
|
||||||
|
set_abort_incomplete_lifecycle_with_size(&bucket, "logs/", 0, 0).await;
|
||||||
|
}
|
||||||
|
let upload = ecstore
|
||||||
|
.new_multipart_upload(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
&ObjectOptions {
|
||||||
|
mod_time: Some(now - age),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create multipart upload");
|
||||||
|
if size_filter {
|
||||||
|
let mut data = PutObjReader::from_vec(vec![1, 2, 3, 4]);
|
||||||
|
ecstore
|
||||||
|
.put_object_part(&bucket, object, &upload.upload_id, 1, &mut data, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("upload a part for size filtering");
|
||||||
|
}
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let subscriber = tracing_subscriber::registry().with(PartListCounter(Arc::clone(&calls)));
|
||||||
|
cleanup_stale_multipart_uploads_once_at(ecstore.clone(), now, StdDuration::from_secs(24 * 60 * 60))
|
||||||
|
.with_subscriber(subscriber)
|
||||||
|
.await;
|
||||||
|
let listed = calls.load(Ordering::Relaxed);
|
||||||
|
assert_eq!(listed > 0, should_list, "unexpected ListParts calls for {object}: {listed}");
|
||||||
|
let result = ecstore
|
||||||
|
.get_multipart_info(&bucket, object, &upload.upload_id, &ObjectOptions::default())
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(ref err) if is_err_invalid_upload_id(err)),
|
||||||
|
"upload must be removed: {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_multipart_cleanup_rejects_incomplete_or_overflowing_part_sizes() {
|
||||||
|
use crate::storage_api_contracts::multipart::{ListPartsInfo, PartInfo};
|
||||||
|
|
||||||
|
let part = |size, actual_size, etag: &str| PartInfo {
|
||||||
|
size,
|
||||||
|
actual_size,
|
||||||
|
etag: Some(etag.to_owned()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut parts = ListPartsInfo {
|
||||||
|
parts: vec![part(4, 6, "valid")],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(super::stale_upload_size_from_parts(&parts), Some(6));
|
||||||
|
parts.is_truncated = true;
|
||||||
|
assert_eq!(super::stale_upload_size_from_parts(&parts), None);
|
||||||
|
parts.is_truncated = false;
|
||||||
|
parts.parts = vec![part(0, 0, "")];
|
||||||
|
assert_eq!(super::stale_upload_size_from_parts(&parts), None);
|
||||||
|
parts.parts = vec![part(usize::MAX, 0, "valid"), part(1, 1, "valid")];
|
||||||
|
assert_eq!(super::stale_upload_size_from_parts(&parts), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn stale_multipart_cleanup_handles_unknown_size_without_false_abort() {
|
||||||
|
let (_, ecstore) = setup_test_env().await;
|
||||||
|
let bucket = format!("stale-unknown-size-{}", Uuid::new_v4().simple());
|
||||||
|
let object = "logs/object";
|
||||||
|
create_test_bucket(&ecstore, &bucket).await;
|
||||||
|
let size_rule = r#"<Rule><ID>size</ID><Status>Enabled</Status><Filter><ObjectSizeLessThan>5</ObjectSizeLessThan></Filter><AbortIncompleteMultipartUpload><DaysAfterInitiation>0</DaysAfterInitiation></AbortIncompleteMultipartUpload></Rule>"#;
|
||||||
|
let independent_rule = r#"<Rule><ID>prefix</ID><Status>Enabled</Status><Filter><Prefix>logs/</Prefix></Filter><AbortIncompleteMultipartUpload><DaysAfterInitiation>0</DaysAfterInitiation></AbortIncompleteMultipartUpload></Rule>"#;
|
||||||
|
let lifecycle = |rules: &str| format!("<LifecycleConfiguration>{rules}</LifecycleConfiguration>").into_bytes();
|
||||||
|
metadata_sys::update(&bucket, BUCKET_LIFECYCLE_CONFIG, lifecycle(size_rule))
|
||||||
|
.await
|
||||||
|
.expect("install upper-size lifecycle rule");
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
(super::RUSTFS_MULTIPART_BUCKET_KEY.to_owned(), bucket.clone()),
|
||||||
|
(super::RUSTFS_MULTIPART_OBJECT_KEY.to_owned(), object.to_owned()),
|
||||||
|
]);
|
||||||
|
let initiated = OffsetDateTime::now_utc() - time::Duration::hours(1);
|
||||||
|
let set = &ecstore.pools[0].disk_set[0];
|
||||||
|
let upload = ecstore
|
||||||
|
.new_multipart_upload(&bucket, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create empty multipart upload");
|
||||||
|
let decoded = base64_simd::URL_SAFE_NO_PAD
|
||||||
|
.decode_to_vec(upload.upload_id.as_bytes())
|
||||||
|
.expect("decode upload ID");
|
||||||
|
let decoded = String::from_utf8(decoded).expect("upload ID must be UTF-8");
|
||||||
|
let (_, upload_dir) = decoded.split_once('.').expect("deployment-prefixed upload ID");
|
||||||
|
assert!(
|
||||||
|
super::stale_upload_lifecycle_due(set, &metadata, initiated, upload_dir, false)
|
||||||
|
.await
|
||||||
|
.is_some(),
|
||||||
|
"a known empty upload must match the upper-size rule"
|
||||||
|
);
|
||||||
|
let missing_upload = format!("{}x{}", Uuid::new_v4(), initiated.unix_timestamp_nanos());
|
||||||
|
assert!(
|
||||||
|
super::stale_upload_lifecycle_due(set, &metadata, initiated, &missing_upload, false)
|
||||||
|
.await
|
||||||
|
.is_none(),
|
||||||
|
"an unreadable size must not match the upper-size rule"
|
||||||
|
);
|
||||||
|
metadata_sys::update(&bucket, BUCKET_LIFECYCLE_CONFIG, lifecycle(&format!("{size_rule}{independent_rule}")))
|
||||||
|
.await
|
||||||
|
.expect("install size-independent lifecycle rule");
|
||||||
|
assert!(
|
||||||
|
super::stale_upload_lifecycle_due(set, &metadata, initiated, &missing_upload, false)
|
||||||
|
.await
|
||||||
|
.is_some(),
|
||||||
|
"a size-independent rule remains eligible when size is unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn stale_multipart_cleanup_uses_default_expiry_without_lifecycle() {
|
async fn stale_multipart_cleanup_uses_default_expiry_without_lifecycle() {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use rand::RngExt as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
@@ -70,6 +71,7 @@ pub(crate) const EVENT_LIFECYCLE_TIER_DELETE_JOURNAL: &str = "lifecycle_tier_del
|
|||||||
// successful pages while its wall-clock budget remains.
|
// successful pages while its wall-clock budget remains.
|
||||||
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 8;
|
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 8;
|
||||||
const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
|
const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
|
const TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||||
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
|
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
const TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET: Duration = Duration::from_secs(240);
|
const TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET: Duration = Duration::from_secs(240);
|
||||||
const TIER_DELETE_REMOTE_DEADLINE: Duration = Duration::from_secs(30);
|
const TIER_DELETE_REMOTE_DEADLINE: Duration = Duration::from_secs(30);
|
||||||
@@ -6104,57 +6106,114 @@ pub(crate) async fn recover_test_tier_delete_journal_pass_with_budget(
|
|||||||
recover_tier_delete_journal_pass(api, cancel_token, manifest_marker, journal_marker, budget).await
|
recover_tier_delete_journal_pass(api, cancel_token, manifest_marker, journal_marker, budget).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
|
fn tier_delete_journal_recovery_pass_is_idle(stats: &TierDeleteJournalRecoveryPassStats) -> bool {
|
||||||
|
stats.manifest_pages > 0
|
||||||
|
&& stats.journal_pages > 0
|
||||||
|
&& stats.manifest_errors == 0
|
||||||
|
&& stats.journal_errors == 0
|
||||||
|
&& !stats.deadline_exhausted
|
||||||
|
&& !stats.canceled
|
||||||
|
&& stats.manifests == TierDeleteDispatchManifestRecoveryStats::default()
|
||||||
|
&& stats.journals == TierDeleteJournalRecoveryStats::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounded_tier_delete_journal_recovery_delay(delay: Duration, percent: u32) -> Duration {
|
||||||
|
(delay.saturating_mul(percent) / 100)
|
||||||
|
.clamp(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jitter_tier_delete_journal_recovery_delay(delay: Duration) -> Duration {
|
||||||
|
bounded_tier_delete_journal_recovery_delay(delay, rand::rng().random_range(90..=110))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drive_tier_delete_journal_recovery(
|
||||||
|
cancel_token: &CancellationToken,
|
||||||
|
wait_for_wakeup: impl AsyncFn(),
|
||||||
|
mut recover: impl AsyncFnMut() -> TierDeleteJournalRecoveryPassStats,
|
||||||
|
jitter_delay: fn(Duration) -> Duration,
|
||||||
|
) {
|
||||||
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
|
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
|
||||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
let mut marker: Option<String> = None;
|
let mut idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||||
let mut manifest_marker: Option<String> = None;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
_ = cancel_token.cancelled() => return,
|
_ = cancel_token.cancelled() => return,
|
||||||
|
_ = wait_for_wakeup() => {
|
||||||
|
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||||
|
interval.reset();
|
||||||
|
},
|
||||||
_ = interval.tick() => {},
|
_ = interval.tick() => {},
|
||||||
_ = api.ctx.wait_for_tier_delete_journal_recovery() => {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let stats = recover_tier_delete_journal_pass(
|
let stats = recover().await;
|
||||||
api.clone(),
|
|
||||||
&cancel_token,
|
|
||||||
&mut manifest_marker,
|
|
||||||
&mut marker,
|
|
||||||
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if stats.canceled {
|
if stats.canceled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debug!(
|
if tier_delete_journal_recovery_pass_is_idle(&stats) {
|
||||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
// Only a completed empty sweep may defer discovery. Retained,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
// failed, or paginated work keeps the original retry tick budget.
|
||||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
interval.reset_after(jitter_delay(idle_interval));
|
||||||
manifest_pages = stats.manifest_pages,
|
idle_interval = idle_interval
|
||||||
manifest_scanned = stats.manifests.scanned,
|
.saturating_mul(2)
|
||||||
manifest_advanced = stats.manifests.advanced,
|
.min(TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
|
||||||
manifest_deleted = stats.manifests.deleted,
|
} else {
|
||||||
manifest_retained = stats.manifests.retained,
|
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||||
manifest_failed = stats.manifests.failed,
|
}
|
||||||
manifest_errors = stats.manifest_errors,
|
|
||||||
manifest_truncated = stats.manifests.truncated,
|
|
||||||
manifest_next_marker = ?manifest_marker,
|
|
||||||
journal_pages = stats.journal_pages,
|
|
||||||
journal_scanned = stats.journals.scanned,
|
|
||||||
journal_deleted = stats.journals.deleted,
|
|
||||||
journal_failed = stats.journals.failed,
|
|
||||||
journal_errors = stats.journal_errors,
|
|
||||||
journal_truncated = stats.journals.truncated,
|
|
||||||
journal_next_marker = ?marker,
|
|
||||||
deadline_exhausted = stats.deadline_exhausted,
|
|
||||||
"Recovered tier delete journal pass"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
|
||||||
|
let mut marker: Option<String> = None;
|
||||||
|
let mut manifest_marker: Option<String> = None;
|
||||||
|
let wake_context = api.ctx.clone();
|
||||||
|
let recovery_cancel_token = cancel_token.clone();
|
||||||
|
drive_tier_delete_journal_recovery(
|
||||||
|
&cancel_token,
|
||||||
|
async move || wake_context.wait_for_tier_delete_journal_recovery().await,
|
||||||
|
async move || {
|
||||||
|
let stats = recover_tier_delete_journal_pass(
|
||||||
|
api.clone(),
|
||||||
|
&recovery_cancel_token,
|
||||||
|
&mut manifest_marker,
|
||||||
|
&mut marker,
|
||||||
|
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if stats.canceled {
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||||
|
manifest_pages = stats.manifest_pages,
|
||||||
|
manifest_scanned = stats.manifests.scanned,
|
||||||
|
manifest_advanced = stats.manifests.advanced,
|
||||||
|
manifest_deleted = stats.manifests.deleted,
|
||||||
|
manifest_retained = stats.manifests.retained,
|
||||||
|
manifest_failed = stats.manifests.failed,
|
||||||
|
manifest_errors = stats.manifest_errors,
|
||||||
|
manifest_truncated = stats.manifests.truncated,
|
||||||
|
manifest_next_marker = ?manifest_marker,
|
||||||
|
journal_pages = stats.journal_pages,
|
||||||
|
journal_scanned = stats.journals.scanned,
|
||||||
|
journal_deleted = stats.journals.deleted,
|
||||||
|
journal_failed = stats.journals.failed,
|
||||||
|
journal_errors = stats.journal_errors,
|
||||||
|
journal_truncated = stats.journals.truncated,
|
||||||
|
journal_next_marker = ?marker,
|
||||||
|
deadline_exhausted = stats.deadline_exhausted,
|
||||||
|
"Recovered tier delete journal pass"
|
||||||
|
);
|
||||||
|
stats
|
||||||
|
},
|
||||||
|
jitter_tier_delete_journal_recovery_delay,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn await_tier_delete_journal_recovery<T, F>(
|
async fn await_tier_delete_journal_recovery<T, F>(
|
||||||
cancel_token: &CancellationToken,
|
cancel_token: &CancellationToken,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@@ -6180,16 +6239,20 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
PersistedTierDeleteJournalEntry, TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE,
|
PersistedTierDeleteJournalEntry, TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE,
|
||||||
TIER_DELETE_DISPATCH_PARENT_VERSION, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX,
|
TIER_DELETE_DISPATCH_PARENT_VERSION, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX,
|
||||||
|
TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL,
|
||||||
TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION,
|
TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION,
|
||||||
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V6_PREFIX,
|
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V6_PREFIX,
|
||||||
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState,
|
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest,
|
||||||
TierDeleteDispatchParent, TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
|
TierDeleteDispatchManifestRecoveryStats, TierDeleteDispatchManifestState, TierDeleteDispatchParent,
|
||||||
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
|
TierDeleteDispatchParentState, TierDeleteDispatchRecord, TierDeleteJournalRecoveryPassStats,
|
||||||
|
await_tier_delete_journal_recovery, bounded_tier_delete_journal_recovery_delay, decode_tier_delete_dispatch_record,
|
||||||
|
decode_tier_delete_journal_entry, drive_tier_delete_journal_recovery, encode_tier_delete_dispatch_manifest,
|
||||||
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
|
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
|
||||||
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
|
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
|
||||||
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
|
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
|
||||||
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
|
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
|
||||||
tier_delete_source_matches_dispatch_scope, validate_legacy_tier_delete_recovery_source,
|
tier_delete_journal_recovery_pass_is_idle, tier_delete_source_matches_dispatch_scope,
|
||||||
|
validate_legacy_tier_delete_recovery_source,
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::tier_sweeper::{
|
use crate::bucket::lifecycle::tier_sweeper::{
|
||||||
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||||
@@ -6833,6 +6896,213 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("decode tier delete journal failed"));
|
assert!(err.to_string().contains("decode tier delete journal failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn empty_recovery_pass() -> TierDeleteJournalRecoveryPassStats {
|
||||||
|
TierDeleteJournalRecoveryPassStats {
|
||||||
|
manifest_pages: 1,
|
||||||
|
journal_pages: 1,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_delete_journal_recovery_requires_both_completed_empty_queues_for_idle() {
|
||||||
|
assert!(tier_delete_journal_recovery_pass_is_idle(&empty_recovery_pass()));
|
||||||
|
let mut cases = Vec::new();
|
||||||
|
cases.push(TierDeleteJournalRecoveryPassStats::default());
|
||||||
|
for change in [
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_pages = 0,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_pages = 0,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_errors = 1,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_errors = 1,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.deadline_exhausted = true,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.canceled = true,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.scanned = 1,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.retained = 1,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.failed = 1,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.truncated = true,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.next_marker = Some("next".to_owned()),
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.scanned = 1,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.failed = 1,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.truncated = true,
|
||||||
|
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.next_marker = Some("next".to_owned()),
|
||||||
|
] {
|
||||||
|
let mut stats = empty_recovery_pass();
|
||||||
|
change(&mut stats);
|
||||||
|
cases.push(stats);
|
||||||
|
}
|
||||||
|
for stats in cases {
|
||||||
|
assert!(
|
||||||
|
!tier_delete_journal_recovery_pass_is_idle(&stats),
|
||||||
|
"unfinished recovery was considered idle: {stats:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_delete_journal_recovery_jitter_preserves_discovery_bounds() {
|
||||||
|
for delay in [
|
||||||
|
Duration::ZERO,
|
||||||
|
Duration::from_secs(60),
|
||||||
|
Duration::from_secs(120),
|
||||||
|
Duration::from_secs(300),
|
||||||
|
Duration::MAX,
|
||||||
|
] {
|
||||||
|
for percent in [90, 100, 110] {
|
||||||
|
let jittered = bounded_tier_delete_journal_recovery_delay(delay, percent);
|
||||||
|
assert!(jittered >= TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
|
||||||
|
assert!(jittered <= TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
|
||||||
|
if delay == Duration::from_secs(120) {
|
||||||
|
assert!((Duration::from_secs(108)..=Duration::from_secs(132)).contains(&jittered));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn tier_delete_journal_recovery_loop_starts_immediately_backs_off_and_wakes() {
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
let wake = tokio::sync::Notify::new();
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let started = tokio::time::Instant::now();
|
||||||
|
let recovery = drive_tier_delete_journal_recovery(
|
||||||
|
&cancel,
|
||||||
|
async || wake.notified().await,
|
||||||
|
async || {
|
||||||
|
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
|
||||||
|
empty_recovery_pass()
|
||||||
|
},
|
||||||
|
|delay| bounded_tier_delete_journal_recovery_delay(delay, 110),
|
||||||
|
);
|
||||||
|
let exercise = async {
|
||||||
|
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
|
||||||
|
let mut elapsed = Duration::ZERO;
|
||||||
|
for delay in [66, 132, 264, 300, 300] {
|
||||||
|
tokio::time::advance(Duration::from_secs(delay - 1)).await;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert!(rx.try_recv().is_err(), "idle pass ran before its backoff");
|
||||||
|
tokio::time::advance(Duration::from_secs(1)).await;
|
||||||
|
elapsed += Duration::from_secs(delay);
|
||||||
|
assert_eq!(rx.recv().await.expect("bounded idle recovery must run"), started + elapsed);
|
||||||
|
}
|
||||||
|
wake.notify_one();
|
||||||
|
assert_eq!(rx.recv().await.expect("notification must bypass idle delay"), started + elapsed);
|
||||||
|
let first_idle_delay = Duration::from_secs(66);
|
||||||
|
tokio::time::advance(first_idle_delay).await;
|
||||||
|
assert_eq!(
|
||||||
|
rx.recv().await.expect("wakeup must reset idle backoff"),
|
||||||
|
started + elapsed + first_idle_delay
|
||||||
|
);
|
||||||
|
cancel.cancel();
|
||||||
|
};
|
||||||
|
tokio::join!(recovery, exercise);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn tier_delete_journal_recovery_loop_preserves_known_work_tick_deadline() {
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let started = tokio::time::Instant::now();
|
||||||
|
let mut pass = 0;
|
||||||
|
let recovery = drive_tier_delete_journal_recovery(
|
||||||
|
&cancel,
|
||||||
|
async || std::future::pending::<()>().await,
|
||||||
|
async || {
|
||||||
|
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
|
||||||
|
pass += 1;
|
||||||
|
if pass <= 3 {
|
||||||
|
return empty_recovery_pass();
|
||||||
|
}
|
||||||
|
if pass == 4 {
|
||||||
|
tokio::time::sleep(Duration::from_secs(40)).await;
|
||||||
|
}
|
||||||
|
let mut stats = empty_recovery_pass();
|
||||||
|
match pass {
|
||||||
|
4 => {
|
||||||
|
stats.manifests = TierDeleteDispatchManifestRecoveryStats {
|
||||||
|
retained: 1,
|
||||||
|
scanned: 1,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
5 => stats.manifest_errors = 1,
|
||||||
|
6 => stats.journal_errors = 1,
|
||||||
|
7 => stats.manifests.truncated = true,
|
||||||
|
8 => stats.journals.failed = 1,
|
||||||
|
9 => stats.deadline_exhausted = true,
|
||||||
|
_ => stats.journals.next_marker = Some("next-page".to_owned()),
|
||||||
|
}
|
||||||
|
stats
|
||||||
|
},
|
||||||
|
std::convert::identity,
|
||||||
|
);
|
||||||
|
let exercise = async {
|
||||||
|
for expected in [0, 60, 180, 420] {
|
||||||
|
assert_eq!(
|
||||||
|
rx.recv().await.expect("idle and work passes must run"),
|
||||||
|
started + Duration::from_secs(expected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tokio::time::advance(Duration::from_secs(59)).await;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert!(rx.try_recv().is_err(), "known work ran before the next tick");
|
||||||
|
tokio::time::advance(Duration::from_secs(1)).await;
|
||||||
|
for expected in [480, 540, 600, 660, 720, 780, 840] {
|
||||||
|
assert_eq!(
|
||||||
|
rx.recv().await.expect("unfinished work must retain the base tick deadline"),
|
||||||
|
started + Duration::from_secs(expected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cancel.cancel();
|
||||||
|
};
|
||||||
|
tokio::join!(recovery, exercise);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn tier_delete_journal_recovery_loop_keeps_wakeup_received_during_a_pass() {
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
let wake = tokio::sync::Notify::new();
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
let started = tokio::time::Instant::now();
|
||||||
|
let mut first = true;
|
||||||
|
let recovery = drive_tier_delete_journal_recovery(
|
||||||
|
&cancel,
|
||||||
|
async || wake.notified().await,
|
||||||
|
async || {
|
||||||
|
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
|
||||||
|
if first {
|
||||||
|
first = false;
|
||||||
|
tokio::time::sleep(Duration::from_secs(40)).await;
|
||||||
|
}
|
||||||
|
empty_recovery_pass()
|
||||||
|
},
|
||||||
|
std::convert::identity,
|
||||||
|
);
|
||||||
|
let exercise = async {
|
||||||
|
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
|
||||||
|
wake.notify_one();
|
||||||
|
tokio::time::advance(Duration::from_secs(40)).await;
|
||||||
|
assert_eq!(
|
||||||
|
rx.recv().await.expect("a notification during recovery must remain pending"),
|
||||||
|
started + Duration::from_secs(40)
|
||||||
|
);
|
||||||
|
cancel.cancel();
|
||||||
|
};
|
||||||
|
tokio::join!(recovery, exercise);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn tier_delete_journal_recovery_loop_cancellation_wins_over_ready_wakeup() {
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
cancel.cancel();
|
||||||
|
drive_tier_delete_journal_recovery(
|
||||||
|
&cancel,
|
||||||
|
async || {},
|
||||||
|
async || panic!("shutdown must prevent admission even when a wakeup and startup tick are ready"),
|
||||||
|
std::convert::identity,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
|
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
|
||||||
let result = await_tier_delete_journal_recovery(
|
let result = await_tier_delete_journal_recovery(
|
||||||
|
|||||||
@@ -437,8 +437,6 @@ pub struct BucketMetadata {
|
|||||||
pub created: OffsetDateTime,
|
pub created: OffsetDateTime,
|
||||||
pub lock_enabled: bool, // While marked as unused, it may need to be retained
|
pub lock_enabled: bool, // While marked as unused, it may need to be retained
|
||||||
pub bucket_incarnation_id: Uuid,
|
pub bucket_incarnation_id: Uuid,
|
||||||
pub artifact_base_prefix: Option<String>,
|
|
||||||
pub artifact_backup_retained: bool,
|
|
||||||
pub(crate) bucket_incarnation_sidecar: bool,
|
pub(crate) bucket_incarnation_sidecar: bool,
|
||||||
pub(crate) bucket_creation_committed: bool,
|
pub(crate) bucket_creation_committed: bool,
|
||||||
pub policy_config_json: Vec<u8>,
|
pub policy_config_json: Vec<u8>,
|
||||||
@@ -514,8 +512,6 @@ impl Default for BucketMetadata {
|
|||||||
created: OffsetDateTime::UNIX_EPOCH,
|
created: OffsetDateTime::UNIX_EPOCH,
|
||||||
lock_enabled: Default::default(),
|
lock_enabled: Default::default(),
|
||||||
bucket_incarnation_id: Uuid::nil(),
|
bucket_incarnation_id: Uuid::nil(),
|
||||||
artifact_base_prefix: None,
|
|
||||||
artifact_backup_retained: false,
|
|
||||||
bucket_incarnation_sidecar: false,
|
bucket_incarnation_sidecar: false,
|
||||||
bucket_creation_committed: false,
|
bucket_creation_committed: false,
|
||||||
policy_config_json: Default::default(),
|
policy_config_json: Default::default(),
|
||||||
@@ -611,7 +607,7 @@ impl BucketMetadata {
|
|||||||
/// legacy migration path, which mints the sidecar first; it must not be
|
/// legacy migration path, which mints the sidecar first; it must not be
|
||||||
/// rewritten by this migration.
|
/// rewritten by this migration.
|
||||||
pub(crate) fn needs_bucket_creation_commit(&self) -> bool {
|
pub(crate) fn needs_bucket_creation_commit(&self) -> bool {
|
||||||
(self.lock_enabled || self.artifact_base_prefix.is_some())
|
self.lock_enabled
|
||||||
&& !self.bucket_creation_committed
|
&& !self.bucket_creation_committed
|
||||||
&& self.bucket_incarnation_sidecar
|
&& self.bucket_incarnation_sidecar
|
||||||
&& !self.bucket_incarnation_id.is_nil()
|
&& !self.bucket_incarnation_id.is_nil()
|
||||||
@@ -708,22 +704,6 @@ impl BucketMetadata {
|
|||||||
self.bucket_incarnation_id =
|
self.bucket_incarnation_id =
|
||||||
Uuid::from_slice(&bytes).map_err(|err| Error::other(format!("invalid BucketIncarnationID: {err}")))?;
|
Uuid::from_slice(&bytes).map_err(|err| Error::other(format!("invalid BucketIncarnationID: {err}")))?;
|
||||||
}
|
}
|
||||||
"ArtifactBackupRetained" => {
|
|
||||||
if self.artifact_backup_retained || !rmp::decode::read_bool(rd)? {
|
|
||||||
return Err(Error::other("invalid artifact backup retention marker"));
|
|
||||||
}
|
|
||||||
self.artifact_backup_retained = true;
|
|
||||||
}
|
|
||||||
"ArtifactBasePrefix" => {
|
|
||||||
if self.artifact_base_prefix.is_some() {
|
|
||||||
return Err(Error::other("duplicate artifact namespace"));
|
|
||||||
}
|
|
||||||
let prefix = read_msgp_str(rd)?;
|
|
||||||
if !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(&prefix) {
|
|
||||||
return Err(Error::other("invalid artifact namespace"));
|
|
||||||
}
|
|
||||||
self.artifact_base_prefix = Some(prefix);
|
|
||||||
}
|
|
||||||
"PolicyConfigJSON" | "PolicyConfigJson" => self.policy_config_json = read_msgp_bin(rd)?,
|
"PolicyConfigJSON" | "PolicyConfigJson" => self.policy_config_json = read_msgp_bin(rd)?,
|
||||||
"NotificationConfigXML" | "NotificationConfigXml" => self.notification_config_xml = read_msgp_bin(rd)?,
|
"NotificationConfigXML" | "NotificationConfigXml" => self.notification_config_xml = read_msgp_bin(rd)?,
|
||||||
"LifecycleConfigXML" | "LifecycleConfigXml" => self.lifecycle_config_xml = read_msgp_bin(rd)?,
|
"LifecycleConfigXML" | "LifecycleConfigXml" => self.lifecycle_config_xml = read_msgp_bin(rd)?,
|
||||||
@@ -786,7 +766,7 @@ impl BucketMetadata {
|
|||||||
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
|
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
|
||||||
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
|
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
|
||||||
// Map size: MinIO fields (25) + RustFS extensions (22)
|
// Map size: MinIO fields (25) + RustFS extensions (22)
|
||||||
let map_len: u32 = 47 + u32::from(self.artifact_base_prefix.is_some()) + u32::from(self.artifact_backup_retained);
|
let map_len: u32 = 47;
|
||||||
rmp::encode::write_map_len(wr, map_len)?;
|
rmp::encode::write_map_len(wr, map_len)?;
|
||||||
|
|
||||||
// MinIO field order (same as Go struct)
|
// MinIO field order (same as Go struct)
|
||||||
@@ -800,15 +780,6 @@ impl BucketMetadata {
|
|||||||
rmp::encode::write_bool(wr, self.lock_enabled)?;
|
rmp::encode::write_bool(wr, self.lock_enabled)?;
|
||||||
|
|
||||||
write_bin_field(wr, "BucketIncarnationID", self.bucket_incarnation_id.as_bytes())?;
|
write_bin_field(wr, "BucketIncarnationID", self.bucket_incarnation_id.as_bytes())?;
|
||||||
if let Some(prefix) = &self.artifact_base_prefix {
|
|
||||||
rmp::encode::write_str(wr, "ArtifactBasePrefix")?;
|
|
||||||
rmp::encode::write_str(wr, prefix)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.artifact_backup_retained {
|
|
||||||
rmp::encode::write_str(wr, "ArtifactBackupRetained")?;
|
|
||||||
rmp::encode::write_bool(wr, true)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
write_bin_field(wr, "PolicyConfigJSON", &self.policy_config_json)?;
|
write_bin_field(wr, "PolicyConfigJSON", &self.policy_config_json)?;
|
||||||
write_bin_field(wr, "NotificationConfigXML", &self.notification_config_xml)?;
|
write_bin_field(wr, "NotificationConfigXML", &self.notification_config_xml)?;
|
||||||
@@ -909,28 +880,13 @@ impl BucketMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match version {
|
match version {
|
||||||
BUCKET_METADATA_VERSION | 2 => {}
|
BUCKET_METADATA_VERSION => {}
|
||||||
_ => return Err(Error::other("read_bucket_metadata: version invalid")),
|
_ => return Err(Error::other("read_bucket_metadata: version invalid")),
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Version two is reserved for permanent artifact backup retention. Older
|
|
||||||
/// readers reject its header instead of skipping the protection field.
|
|
||||||
pub(crate) fn decode_file(bytes: &[u8]) -> Result<Self> {
|
|
||||||
Self::check_header(bytes)?;
|
|
||||||
let metadata = Self::unmarshal(&bytes[4..])?;
|
|
||||||
let retained_header = LittleEndian::read_u16(&bytes[2..4]) == 2;
|
|
||||||
if retained_header != metadata.artifact_backup_retained {
|
|
||||||
return Err(Error::other("artifact backup retention header mismatch"));
|
|
||||||
}
|
|
||||||
if metadata.artifact_backup_retained {
|
|
||||||
metadata.validate_artifact_configuration()?;
|
|
||||||
}
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn default_timestamps(&mut self) {
|
pub(crate) fn default_timestamps(&mut self) {
|
||||||
if self.policy_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
if self.policy_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
||||||
self.policy_config_updated_at = self.created
|
self.policy_config_updated_at = self.created
|
||||||
@@ -1069,9 +1025,6 @@ impl BucketMetadata {
|
|||||||
}
|
}
|
||||||
Some(config)
|
Some(config)
|
||||||
};
|
};
|
||||||
if self.artifact_base_prefix.is_some() && !config.as_ref().is_some_and(artifact_versioning_enabled) {
|
|
||||||
return Err(Error::other("artifact bucket versioning must remain enabled"));
|
|
||||||
}
|
|
||||||
self.versioning_config_xml = data;
|
self.versioning_config_xml = data;
|
||||||
self.versioning_config = config;
|
self.versioning_config = config;
|
||||||
self.versioning_config_updated_at = updated;
|
self.versioning_config_updated_at = updated;
|
||||||
@@ -1174,34 +1127,13 @@ impl BucketMetadata {
|
|||||||
&mut self,
|
&mut self,
|
||||||
store: std::sync::Arc<crate::store::ECStore>,
|
store: std::sync::Arc<crate::store::ECStore>,
|
||||||
write_completion: WriteCompletion,
|
write_completion: WriteCompletion,
|
||||||
) -> Result<()> {
|
|
||||||
let opts = crate::object_api::ObjectOptions {
|
|
||||||
max_parity: true,
|
|
||||||
write_completion,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
self.save_with_store_options(store, &opts).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn save_with_store_options(
|
|
||||||
&mut self,
|
|
||||||
store: Arc<ECStore>,
|
|
||||||
opts: &crate::object_api::ObjectOptions,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
self.parse_all_configs()?;
|
self.parse_all_configs()?;
|
||||||
self.validate_artifact_update(store.clone()).await?;
|
|
||||||
let mut buf: Vec<u8> = vec![0; 4];
|
let mut buf: Vec<u8> = vec![0; 4];
|
||||||
|
|
||||||
LittleEndian::write_u16(&mut buf[0..2], BUCKET_METADATA_FORMAT);
|
LittleEndian::write_u16(&mut buf[0..2], BUCKET_METADATA_FORMAT);
|
||||||
|
|
||||||
LittleEndian::write_u16(
|
LittleEndian::write_u16(&mut buf[2..4], BUCKET_METADATA_VERSION);
|
||||||
&mut buf[2..4],
|
|
||||||
if self.artifact_backup_retained {
|
|
||||||
2
|
|
||||||
} else {
|
|
||||||
BUCKET_METADATA_VERSION
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let data = self
|
let data = self
|
||||||
.marshal_msg()
|
.marshal_msg()
|
||||||
@@ -1209,52 +1141,21 @@ impl BucketMetadata {
|
|||||||
|
|
||||||
buf.extend_from_slice(&data);
|
buf.extend_from_slice(&data);
|
||||||
|
|
||||||
save_config_with_opts(store, self.save_file_path().as_str(), buf, opts).await?;
|
save_config_with_opts(
|
||||||
|
store,
|
||||||
|
self.save_file_path().as_str(),
|
||||||
|
buf,
|
||||||
|
&crate::object_api::ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
write_completion,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_artifact_configuration(&self) -> Result<()> {
|
|
||||||
if self.artifact_backup_retained && (self.artifact_base_prefix.is_none() || !self.bucket_creation_committed) {
|
|
||||||
return Err(Error::other("artifact backup retention requires a committed managed bucket"));
|
|
||||||
}
|
|
||||||
if let Some(prefix) = &self.artifact_base_prefix {
|
|
||||||
if !crate::storage_api_contracts::bucket::valid_artifact_base_prefix(prefix) || self.bucket_incarnation_id.is_nil() {
|
|
||||||
return Err(Error::other("invalid artifact bucket identity"));
|
|
||||||
}
|
|
||||||
let versioning = deserialize::<VersioningConfiguration>(&self.versioning_config_xml)?;
|
|
||||||
if !artifact_versioning_enabled(&versioning) {
|
|
||||||
return Err(Error::other("artifact bucket versioning must remain enabled"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full metadata replacement must not change a generation's creation identity.
|
|
||||||
pub(crate) async fn validate_artifact_update(&self, store: Arc<ECStore>) -> Result<()> {
|
|
||||||
self.validate_artifact_configuration()?;
|
|
||||||
// An existing empty/corrupt metadata object is not permission to create an identity.
|
|
||||||
match read_config_preserve_empty(store, &self.save_file_path()).await {
|
|
||||||
Ok(data) => {
|
|
||||||
let previous = Self::decode_file(&data)?;
|
|
||||||
previous.validate_artifact_configuration()?;
|
|
||||||
if previous.artifact_backup_retained && !self.artifact_backup_retained {
|
|
||||||
return Err(Error::other("artifact backup retention cannot be removed"));
|
|
||||||
}
|
|
||||||
// Deletion removes this record before recreation. A caller-supplied
|
|
||||||
// incarnation cannot turn an existing bucket into a new generation.
|
|
||||||
if previous.artifact_base_prefix != self.artifact_base_prefix
|
|
||||||
|| (previous.artifact_base_prefix.is_some() && previous.bucket_incarnation_id != self.bucket_incarnation_id)
|
|
||||||
{
|
|
||||||
return Err(Error::other("artifact bucket identity is immutable"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(Error::ConfigNotFound) => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_policy_config(&mut self) -> Result<()> {
|
fn parse_policy_config(&mut self) -> Result<()> {
|
||||||
if !self.policy_config_json.is_empty() {
|
if !self.policy_config_json.is_empty() {
|
||||||
self.policy_config = Some(serde_json::from_slice(&self.policy_config_json)?);
|
self.policy_config = Some(serde_json::from_slice(&self.policy_config_json)?);
|
||||||
@@ -1298,7 +1199,6 @@ impl BucketMetadata {
|
|||||||
/// `rustfs_bucket_metadata_parse_failed_total`, reflected in
|
/// `rustfs_bucket_metadata_parse_failed_total`, reflected in
|
||||||
/// `rustfs_bucket_metadata_unparsable_current`, and logged at error level.
|
/// `rustfs_bucket_metadata_unparsable_current`, and logged at error level.
|
||||||
pub(super) fn parse_all_configs(&mut self) -> Result<()> {
|
pub(super) fn parse_all_configs(&mut self) -> Result<()> {
|
||||||
self.validate_artifact_configuration()?;
|
|
||||||
if let Err(e) = self.parse_policy_config() {
|
if let Err(e) = self.parse_policy_config() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
event = "bucket_metadata_parse_failed",
|
event = "bucket_metadata_parse_failed",
|
||||||
@@ -1564,10 +1464,6 @@ impl BucketMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn artifact_versioning_enabled(config: &VersioningConfiguration) -> bool {
|
|
||||||
config.enabled() && !config.exclude_folders.unwrap_or(false) && config.excluded_prefixes.as_ref().is_none_or(Vec::is_empty)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn load_bucket_incarnation(api: Arc<ECStore>, bucket: &str) -> Result<Option<Uuid>> {
|
pub(crate) async fn load_bucket_incarnation(api: Arc<ECStore>, bucket: &str) -> Result<Option<Uuid>> {
|
||||||
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}");
|
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}");
|
||||||
let data = match read_config_preserve_empty(api, &path).await {
|
let data = match read_config_preserve_empty(api, &path).await {
|
||||||
@@ -1664,7 +1560,9 @@ async fn read_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<BucketM
|
|||||||
|
|
||||||
let data = read_config(api, &file_path).await?;
|
let data = read_config(api, &file_path).await?;
|
||||||
|
|
||||||
let bm = BucketMetadata::decode_file(&data)?;
|
BucketMetadata::check_header(&data)?;
|
||||||
|
|
||||||
|
let bm = BucketMetadata::unmarshal(&data[4..])?;
|
||||||
|
|
||||||
Ok(bm)
|
Ok(bm)
|
||||||
}
|
}
|
||||||
@@ -1688,98 +1586,6 @@ where
|
|||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artifact_metadata_roundtrip_and_corrupt_prefix_rejection() {
|
|
||||||
let mut metadata = BucketMetadata::new("artifacts");
|
|
||||||
metadata.artifact_base_prefix = Some("images/".to_owned());
|
|
||||||
let encoded = metadata.marshal_msg().expect("encode artifact metadata");
|
|
||||||
let decoded = BucketMetadata::unmarshal(&encoded).expect("decode artifact metadata");
|
|
||||||
assert_eq!(decoded.artifact_base_prefix, metadata.artifact_base_prefix, "ARTIFACT_METADATA_ROUNDTRIP");
|
|
||||||
assert_eq!(decoded.bucket_incarnation_id, metadata.bucket_incarnation_id);
|
|
||||||
let legacy = BucketMetadata::new("ordinary")
|
|
||||||
.marshal_msg()
|
|
||||||
.expect("encode ordinary metadata");
|
|
||||||
assert!(
|
|
||||||
BucketMetadata::unmarshal(&legacy)
|
|
||||||
.expect("decode ordinary metadata")
|
|
||||||
.artifact_base_prefix
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
for prefix in ["", "../", "outputs", "a b/"] {
|
|
||||||
let mut invalid = Vec::new();
|
|
||||||
rmp::encode::write_map_len(&mut invalid, 1).expect("encode map");
|
|
||||||
rmp::encode::write_str(&mut invalid, "ArtifactBasePrefix").expect("encode field");
|
|
||||||
rmp::encode::write_str(&mut invalid, prefix).expect("encode invalid prefix");
|
|
||||||
assert!(BucketMetadata::unmarshal(&invalid).is_err(), "ARTIFACT_CORRUPT_PREFIX");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artifact_backup_retention_marker_rejects_false_and_duplicates() {
|
|
||||||
for count in [1, 2] {
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
rmp::encode::write_map_len(&mut bytes, count).unwrap();
|
|
||||||
for _ in 0..count {
|
|
||||||
rmp::encode::write_str(&mut bytes, "ArtifactBackupRetained").unwrap();
|
|
||||||
rmp::encode::write_bool(&mut bytes, count == 2).unwrap();
|
|
||||||
}
|
|
||||||
assert!(BucketMetadata::unmarshal(&bytes).is_err(), "O02P3_INVALID_RETENTION_MARKER");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artifact_backup_retention_requires_matching_header_and_identity() {
|
|
||||||
let mut metadata = BucketMetadata::new("retained");
|
|
||||||
metadata.bucket_incarnation_id = Uuid::new_v4();
|
|
||||||
metadata.bucket_creation_committed = true;
|
|
||||||
metadata.artifact_base_prefix = Some("outputs/".to_owned());
|
|
||||||
metadata.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
|
|
||||||
let file = |metadata: &BucketMetadata, version: u16| {
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
bytes.extend_from_slice(&BUCKET_METADATA_FORMAT.to_le_bytes());
|
|
||||||
bytes.extend_from_slice(&version.to_le_bytes());
|
|
||||||
bytes.extend_from_slice(&metadata.marshal_msg().unwrap());
|
|
||||||
bytes
|
|
||||||
};
|
|
||||||
assert!(BucketMetadata::decode_file(&file(&metadata, 1)).is_ok(), "O02P3_ORDINARY_V1_COMPATIBLE");
|
|
||||||
assert!(BucketMetadata::decode_file(&file(&metadata, 2)).is_err(), "O02P3_MARKER_REQUIRED");
|
|
||||||
metadata.artifact_backup_retained = true;
|
|
||||||
assert!(
|
|
||||||
BucketMetadata::decode_file(&file(&metadata, 1)).is_err(),
|
|
||||||
"O02P3_PROTECTION_CANNOT_DOWNGRADE_HEADER"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
BucketMetadata::decode_file(&file(&metadata, 2))
|
|
||||||
.unwrap()
|
|
||||||
.artifact_backup_retained,
|
|
||||||
"O02P3_PROTECTED_V2_ROUNDTRIP"
|
|
||||||
);
|
|
||||||
metadata.artifact_base_prefix = None;
|
|
||||||
assert!(
|
|
||||||
BucketMetadata::decode_file(&file(&metadata, 2)).is_err(),
|
|
||||||
"O02P3_PROTECTION_REQUIRES_MANAGED_BUCKET"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artifact_versioning_cannot_be_disabled_or_excluded() {
|
|
||||||
let mut metadata = BucketMetadata::new("artifacts");
|
|
||||||
metadata.artifact_base_prefix = Some("images/".to_owned());
|
|
||||||
for xml in [
|
|
||||||
"",
|
|
||||||
"<VersioningConfiguration><Status>Suspended</Status></VersioningConfiguration>",
|
|
||||||
"<VersioningConfiguration><Status>Enabled</Status><ExcludeFolders>true</ExcludeFolders></VersioningConfiguration>",
|
|
||||||
"<VersioningConfiguration><Status>Enabled</Status><ExcludedPrefixes><Prefix>images/</Prefix></ExcludedPrefixes></VersioningConfiguration>",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
metadata
|
|
||||||
.update_config(BUCKET_VERSIONING_CONFIG, xml.as_bytes().to_vec())
|
|
||||||
.is_err(),
|
|
||||||
"ARTIFACT_VERSIONING_REQUIRED"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// rustfs/backlog#1734: `StorageError::clone` rebuilds I/O errors from
|
/// rustfs/backlog#1734: `StorageError::clone` rebuilds I/O errors from
|
||||||
/// their text. The unreadable-config refusal must stay typed across that
|
/// their text. The unreadable-config refusal must stay typed across that
|
||||||
/// clone, or a cloned error stops mapping to its retryable S3 response.
|
/// clone, or a cloned error stops mapping to its retryable S3 response.
|
||||||
|
|||||||
@@ -293,10 +293,8 @@ pub(super) fn get_bucket_metadata_sys() -> Result<Arc<RwLock<BucketMetadataSys>>
|
|||||||
pub async fn set_bucket_metadata(bucket: String, bm: BucketMetadata) -> Result<()> {
|
pub async fn set_bucket_metadata(bucket: String, bm: BucketMetadata) -> Result<()> {
|
||||||
let sys = get_bucket_metadata_sys()?;
|
let sys = get_bucket_metadata_sys()?;
|
||||||
let lock = sys.write().await;
|
let lock = sys.write().await;
|
||||||
if bucket != bm.name {
|
lock.set(bucket, Arc::new(bm)).await;
|
||||||
return Err(Error::other("bucket metadata identity mismatch"));
|
Ok(())
|
||||||
}
|
|
||||||
lock.set_validated(bm).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn reload_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<()> {
|
pub async fn reload_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<()> {
|
||||||
@@ -587,15 +585,6 @@ pub(crate) async fn get_cached_bucket_incarnation_id_in(
|
|||||||
sys.get_bucket_incarnation_id(bucket).await
|
sys.get_bucket_incarnation_id(bucket).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_bucket_incarnation_metadata_in(
|
|
||||||
ctx: &crate::runtime::instance::InstanceContext,
|
|
||||||
bucket: &str,
|
|
||||||
) -> Result<Arc<BucketMetadata>> {
|
|
||||||
let sys = bucket_metadata_sys_of(ctx)?;
|
|
||||||
let sys = sys.read().await.clone();
|
|
||||||
sys.get_bucket_incarnation_metadata(bucket).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn set_bucket_metadata_in(ctx: &crate::runtime::instance::InstanceContext, bm: BucketMetadata) -> Result<()> {
|
pub(crate) async fn set_bucket_metadata_in(ctx: &crate::runtime::instance::InstanceContext, bm: BucketMetadata) -> Result<()> {
|
||||||
let sys = bucket_metadata_sys_of(ctx)?;
|
let sys = bucket_metadata_sys_of(ctx)?;
|
||||||
let lock = sys.read().await;
|
let lock = sys.read().await;
|
||||||
@@ -2143,16 +2132,7 @@ impl BucketMetadataSys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn set_validated(&self, mut bm: BucketMetadata) -> Result<()> {
|
pub async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
|
||||||
if bm.artifact_base_prefix.is_some() {
|
|
||||||
bm.parse_all_configs()?;
|
|
||||||
}
|
|
||||||
bm.validate_artifact_update(self.object_store()).await?;
|
|
||||||
self.set(bm.name.clone(), Arc::new(bm)).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
|
|
||||||
if !is_meta_bucketname(&bucket) {
|
if !is_meta_bucketname(&bucket) {
|
||||||
let publish_lock = self.metadata_publish_lock(&bucket);
|
let publish_lock = self.metadata_publish_lock(&bucket);
|
||||||
let _publish_guard = publish_lock.lock().await;
|
let _publish_guard = publish_lock.lock().await;
|
||||||
@@ -2167,11 +2147,6 @@ impl BucketMetadataSys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
|
||||||
pub async fn set_for_test(&self, bucket: String, bm: Arc<BucketMetadata>) {
|
|
||||||
self.set(bucket, bm).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a bucket's cached metadata from the in-memory map.
|
/// Remove a bucket's cached metadata from the in-memory map.
|
||||||
///
|
///
|
||||||
/// Returns `true` if an entry was present. Reserved meta buckets are ignored.
|
/// Returns `true` if an entry was present. Reserved meta buckets are ignored.
|
||||||
@@ -2676,16 +2651,12 @@ impl BucketMetadataSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_bucket_incarnation_id(&self, bucket: &str) -> Result<Uuid> {
|
async fn get_bucket_incarnation_id(&self, bucket: &str) -> Result<Uuid> {
|
||||||
Ok(self.get_bucket_incarnation_metadata(bucket).await?.bucket_incarnation_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_bucket_incarnation_metadata(&self, bucket: &str) -> Result<Arc<BucketMetadata>> {
|
|
||||||
let authority = self.get_metadata_authority(bucket).await?;
|
let authority = self.get_metadata_authority(bucket).await?;
|
||||||
if let BucketMetadataAuthority::Authoritative(metadata) = &authority
|
if let BucketMetadataAuthority::Authoritative(metadata) = &authority
|
||||||
&& metadata.bucket_incarnation_sidecar
|
&& metadata.bucket_incarnation_sidecar
|
||||||
&& !metadata.bucket_incarnation_id.is_nil()
|
&& !metadata.bucket_incarnation_id.is_nil()
|
||||||
{
|
{
|
||||||
return Ok(Arc::clone(metadata));
|
return Ok(metadata.bucket_incarnation_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
match authority {
|
match authority {
|
||||||
@@ -2694,7 +2665,7 @@ impl BucketMetadataSys {
|
|||||||
BucketMetadataAuthority::Authoritative(metadata)
|
BucketMetadataAuthority::Authoritative(metadata)
|
||||||
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
|
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
|
||||||
{
|
{
|
||||||
Ok(metadata)
|
Ok(metadata.bucket_incarnation_id)
|
||||||
}
|
}
|
||||||
BucketMetadataAuthority::Authoritative(_) | BucketMetadataAuthority::Fabricated => {
|
BucketMetadataAuthority::Authoritative(_) | BucketMetadataAuthority::Fabricated => {
|
||||||
Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
|
Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
|
||||||
@@ -3022,8 +2993,7 @@ impl BucketMetadataSys {
|
|||||||
load_bucket_metadata_parse_with_presence(self.object_store(), bucket, true),
|
load_bucket_metadata_parse_with_presence(self.object_store(), bucket, true),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if persisted && (metadata.lock_enabled || metadata.artifact_base_prefix.is_some()) && !metadata.bucket_creation_committed
|
if persisted && metadata.lock_enabled && !metadata.bucket_creation_committed {
|
||||||
{
|
|
||||||
return Err(Error::ErasureWriteQuorum);
|
return Err(Error::ErasureWriteQuorum);
|
||||||
}
|
}
|
||||||
if persisted {
|
if persisted {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
// #730: bucket subsystems still contain staged ECStore migration code.
|
// #730: bucket subsystems still contain staged ECStore migration code.
|
||||||
|
|
||||||
pub mod artifact;
|
|
||||||
pub mod bandwidth;
|
pub mod bandwidth;
|
||||||
pub mod bucket_target_sys;
|
pub mod bucket_target_sys;
|
||||||
pub mod config_parse_mode;
|
pub mod config_parse_mode;
|
||||||
|
|||||||
@@ -895,7 +895,7 @@ mod tests {
|
|||||||
.update_bucket_metadata_config(&bucket, QUOTA_CONFIG_FILE, legacy_quota_json(8192))
|
.update_bucket_metadata_config(&bucket, QUOTA_CONFIG_FILE, legacy_quota_json(8192))
|
||||||
.await
|
.await
|
||||||
.expect("newer quota save");
|
.expect("newer quota save");
|
||||||
sys.read().await.set_for_test(bucket.clone(), stale).await;
|
sys.read().await.set(bucket.clone(), stale).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
checker
|
checker
|
||||||
.migrate_reservation_protocol(&bucket)
|
.migrate_reservation_protocol(&bucket)
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ async fn quota_statistics_remain_available_without_a_complete_scanner_snapshot()
|
|||||||
// assertion: reporting must work before a protocol upgrade.
|
// assertion: reporting must work before a protocol upgrade.
|
||||||
let metadata_sys = BucketMetadataSys::new(store.clone());
|
let metadata_sys = BucketMetadataSys::new(store.clone());
|
||||||
let (metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
|
let (metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
|
||||||
metadata_sys.set_for_test(bucket.clone(), metadata).await;
|
metadata_sys.set(bucket.clone(), metadata).await;
|
||||||
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
|
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
|
||||||
let (snapshot_source, size) = if source == "empty" { ("complete", 0) } else { (source, 1000) };
|
let (snapshot_source, size) = if source == "empty" { ("complete", 0) } else { (source, 1000) };
|
||||||
install_reporting_snapshot(store.clone(), &bucket, snapshot_source, size).await;
|
install_reporting_snapshot(store.clone(), &bucket, snapshot_source, size).await;
|
||||||
@@ -317,7 +317,7 @@ async fn quota_statistics_preserve_configuration_errors() {
|
|||||||
let metadata_sys = BucketMetadataSys::new(store.clone());
|
let metadata_sys = BucketMetadataSys::new(store.clone());
|
||||||
let (mut metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
|
let (mut metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
|
||||||
Arc::make_mut(&mut metadata).quota_config_json = b"{not-json".to_vec();
|
Arc::make_mut(&mut metadata).quota_config_json = b"{not-json".to_vec();
|
||||||
metadata_sys.set_for_test(bucket.clone(), metadata).await;
|
metadata_sys.set(bucket.clone(), metadata).await;
|
||||||
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
|
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
|
||||||
|
|
||||||
let statistics = checker.get_quota_stats(&bucket).await;
|
let statistics = checker.get_quota_stats(&bucket).await;
|
||||||
|
|||||||
@@ -707,13 +707,6 @@ struct ShardedReservationData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReservationLedgerData {
|
impl ReservationLedgerData {
|
||||||
fn store(&self) -> &Arc<ECStore> {
|
|
||||||
match self {
|
|
||||||
Self::Single(data) => &data.store,
|
|
||||||
Self::Sharded(data) => &data.store,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bucket(&self) -> &str {
|
fn bucket(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Single(data) => &data.bucket,
|
Self::Single(data) => &data.bucket,
|
||||||
@@ -765,7 +758,7 @@ impl QuotaReservation {
|
|||||||
let Some(ledger) = self.ledger.as_ref() else {
|
let Some(ledger) = self.ledger.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
crate::store::list_objects::observe_list_objects_mutation(ledger.store(), ledger.bucket()).await;
|
crate::store::list_objects::observe_scanner_namespace_mutations(ledger.bucket(), 1);
|
||||||
match settle(ledger, true).await {
|
match settle(ledger, true).await {
|
||||||
Ok(()) => self.ledger = None,
|
Ok(()) => self.ledger = None,
|
||||||
Err(err) => log_deferred_settlement(ledger, "commit_deferred", &err),
|
Err(err) => log_deferred_settlement(ledger, "commit_deferred", &err),
|
||||||
@@ -1290,7 +1283,7 @@ async fn reconcile_exact(store: &Arc<ECStore>, bucket: &str, ledger: &mut QuotaL
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn exact_bucket_usage(store: &Arc<ECStore>, bucket: &str) -> Result<u64> {
|
async fn exact_bucket_usage(store: &Arc<ECStore>, bucket: &str) -> Result<u64> {
|
||||||
crate::store::list_objects::observe_list_objects_mutation(store, bucket).await;
|
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
|
||||||
Ok(compute_bucket_usage(Arc::clone(store), bucket).await?.size)
|
Ok(compute_bucket_usage(Arc::clone(store), bucket).await?.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,11 +43,6 @@ const EVENT_METACACHE_LISTING: &str = "metacache_listing";
|
|||||||
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = Result<(), DiskError>> + Send>> + Send + 'static>;
|
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = Result<(), DiskError>> + Send>> + Send + 'static>;
|
||||||
pub type PartialFn =
|
pub type PartialFn =
|
||||||
Box<dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
Box<dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
||||||
pub(crate) type PartialResultFn = Box<
|
|
||||||
dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = disk::error::Result<()>> + Send>>
|
|
||||||
+ Send
|
|
||||||
+ 'static,
|
|
||||||
>;
|
|
||||||
type FinishedFn = Box<dyn Fn(&[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
type FinishedFn = Box<dyn Fn(&[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@@ -60,6 +55,7 @@ impl FallbackClaimTracker {
|
|||||||
self.claimed.lock().await.insert(disk.endpoint().to_string());
|
self.claimed.lock().await.insert(disk.endpoint().to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) async fn claimed_keys(&self) -> HashSet<String> {
|
pub(crate) async fn claimed_keys(&self) -> HashSet<String> {
|
||||||
self.claimed.lock().await.clone()
|
self.claimed.lock().await.clone()
|
||||||
}
|
}
|
||||||
@@ -317,7 +313,17 @@ pub struct ListPathRawOutcome {
|
|||||||
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
|
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
|
||||||
let rx = rx.child_token();
|
let rx = rx.child_token();
|
||||||
let _cancel_guard = rx.clone().drop_guard();
|
let _cancel_guard = rx.clone().drop_guard();
|
||||||
list_path_raw_inner(rx, opts, None, None).await.map(|_| ())
|
list_path_raw_inner(rx, opts, None).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`list_path_raw`], but reports how the merged stream ended.
|
||||||
|
pub(crate) async fn list_path_raw_with_outcome(
|
||||||
|
rx: CancellationToken,
|
||||||
|
opts: ListPathRawOptions,
|
||||||
|
) -> disk::error::Result<ListPathRawOutcome> {
|
||||||
|
let rx = rx.child_token();
|
||||||
|
let _cancel_guard = rx.clone().drop_guard();
|
||||||
|
list_path_raw_inner(rx, opts, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_path_raw_with_claim_tracker(
|
pub(crate) async fn list_path_raw_with_claim_tracker(
|
||||||
@@ -327,25 +333,13 @@ pub(crate) async fn list_path_raw_with_claim_tracker(
|
|||||||
) -> disk::error::Result<ListPathRawOutcome> {
|
) -> disk::error::Result<ListPathRawOutcome> {
|
||||||
let rx = rx.child_token();
|
let rx = rx.child_token();
|
||||||
let _cancel_guard = rx.clone().drop_guard();
|
let _cancel_guard = rx.clone().drop_guard();
|
||||||
list_path_raw_inner(rx, opts, Some(claim_tracker), None).await
|
list_path_raw_inner(rx, opts, Some(claim_tracker)).await
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn list_path_raw_with_partial_result(
|
|
||||||
rx: CancellationToken,
|
|
||||||
opts: ListPathRawOptions,
|
|
||||||
claim_tracker: FallbackClaimTracker,
|
|
||||||
partial_result: PartialResultFn,
|
|
||||||
) -> disk::error::Result<ListPathRawOutcome> {
|
|
||||||
let rx = rx.child_token();
|
|
||||||
let _cancel_guard = rx.clone().drop_guard();
|
|
||||||
list_path_raw_inner(rx, opts, Some(claim_tracker), Some(partial_result)).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_path_raw_inner(
|
async fn list_path_raw_inner(
|
||||||
rx: CancellationToken,
|
rx: CancellationToken,
|
||||||
opts: ListPathRawOptions,
|
opts: ListPathRawOptions,
|
||||||
fallback_claim_tracker: Option<FallbackClaimTracker>,
|
fallback_claim_tracker: Option<FallbackClaimTracker>,
|
||||||
partial_result: Option<PartialResultFn>,
|
|
||||||
) -> disk::error::Result<ListPathRawOutcome> {
|
) -> disk::error::Result<ListPathRawOutcome> {
|
||||||
if opts.disks.is_empty() {
|
if opts.disks.is_empty() {
|
||||||
return Err(DiskError::ErasureReadQuorum);
|
return Err(DiskError::ErasureReadQuorum);
|
||||||
@@ -1020,13 +1014,7 @@ async fn list_path_raw_inner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(partial_fn) = partial_result.as_ref() {
|
if let Some(partial_fn) = opts.partial.as_ref() {
|
||||||
tokio::select! {
|
|
||||||
biased;
|
|
||||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
|
||||||
result = partial_fn(MetaCacheEntries(top_entries), &errs) => result?,
|
|
||||||
}
|
|
||||||
} else if let Some(partial_fn) = opts.partial.as_ref() {
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||||
@@ -1308,7 +1296,6 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("listing should complete");
|
.expect("listing should complete");
|
||||||
@@ -1333,7 +1320,6 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("listing should complete");
|
.expect("listing should complete");
|
||||||
@@ -1827,39 +1813,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_path_raw_propagates_partial_callback_failure() {
|
|
||||||
let first = MetaCacheEntry {
|
|
||||||
name: "bucket/object-a".to_string(),
|
|
||||||
metadata: vec![1],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let second = MetaCacheEntry {
|
|
||||||
name: "bucket/object-b".to_string(),
|
|
||||||
metadata: vec![2],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let err = list_path_raw_with_partial_result(
|
|
||||||
CancellationToken::new(),
|
|
||||||
ListPathRawOptions {
|
|
||||||
disks: vec![None, None],
|
|
||||||
min_disks: 2,
|
|
||||||
test_reader_behaviors: vec![
|
|
||||||
TestReaderBehavior::Entries(vec![first]),
|
|
||||||
TestReaderBehavior::Entries(vec![second]),
|
|
||||||
],
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
FallbackClaimTracker::default(),
|
|
||||||
Box::new(move |_, _| Box::pin(async { Err(DiskError::ErasureReadQuorum) })),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("a resolution failure after reader advancement must fail the listing");
|
|
||||||
|
|
||||||
assert_eq!(err, DiskError::ErasureReadQuorum);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_path_raw_cancels_a_blocked_partial_callback() {
|
async fn list_path_raw_cancels_a_blocked_partial_callback() {
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ pub async fn tier_mutation_control_time_out_client(
|
|||||||
|
|
||||||
/// Build a `NodeServiceClient` bound to the [`ChannelClass`]-appropriate channel for `addr`.
|
/// Build a `NodeServiceClient` bound to the [`ChannelClass`]-appropriate channel for `addr`.
|
||||||
///
|
///
|
||||||
/// Bulk `bytes`-carrying RPCs (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion) pass
|
/// Bulk `bytes`-carrying RPCs, including native pool metadata recovery inventories, pass
|
||||||
/// [`ChannelClass::Bulk`] so, when channel isolation is enabled, they are physically isolated
|
/// [`ChannelClass::Bulk`] so, when channel isolation is enabled, they are physically isolated
|
||||||
/// from lock/health RPCs; everything else uses [`ChannelClass::Control`]. When isolation is
|
/// from lock/health RPCs; everything else uses [`ChannelClass::Control`]. When isolation is
|
||||||
/// disabled the two classes resolve to the same cached channel, i.e. legacy behavior.
|
/// disabled the two classes resolve to the same cached channel, i.e. legacy behavior.
|
||||||
@@ -357,6 +357,7 @@ fn rename_data_grpc_stage(path: &str) -> bool {
|
|||||||
matches!(
|
matches!(
|
||||||
path,
|
path,
|
||||||
"/node_service.NodeService/RenameData"
|
"/node_service.NodeService/RenameData"
|
||||||
|
| "/node_service.NodeService/RenameMultipartData"
|
||||||
| "/node_service.NodeService/RenameDataAtIncarnation"
|
| "/node_service.NodeService/RenameDataAtIncarnation"
|
||||||
| "/node_service.NodeService/RenameDataWithQuotaFence"
|
| "/node_service.NodeService/RenameDataWithQuotaFence"
|
||||||
)
|
)
|
||||||
@@ -379,6 +380,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn call(&mut self, mut request: HttpRequest<ReqBody>) -> Self::Future {
|
fn call(&mut self, mut request: HttpRequest<ReqBody>) -> Self::Future {
|
||||||
|
let rpc_method = request.extensions().get::<tonic::GrpcMethod<'_>>().cloned();
|
||||||
let observe_rename_data = rustfs_io_metrics::put_stage_metrics_enabled() && rename_data_grpc_stage(request.uri().path());
|
let observe_rename_data = rustfs_io_metrics::put_stage_metrics_enabled() && rename_data_grpc_stage(request.uri().path());
|
||||||
let request_scope_started = if observe_rename_data {
|
let request_scope_started = if observe_rename_data {
|
||||||
rustfs_io_metrics::put_stage_timer()
|
rustfs_io_metrics::put_stage_timer()
|
||||||
@@ -447,13 +449,21 @@ where
|
|||||||
let response_state = verify_tonic_peer_replay_capabilities_response(&audience, challenge, response.headers());
|
let response_state = verify_tonic_peer_replay_capabilities_response(&audience, challenge, response.headers());
|
||||||
if let Err(error) = &response_state
|
if let Err(error) = &response_state
|
||||||
&& (response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER)
|
&& (response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER)
|
||||||
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER))
|
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER)
|
||||||
|
|| matches!(sent_state.cache_capability, Some(PeerReplayCapability::Capable { .. })))
|
||||||
{
|
{
|
||||||
debug!(
|
debug!(
|
||||||
event = "internode_rpc_capability_proof_rejected",
|
event = "internode_rpc_capability_proof_rejected",
|
||||||
component = "ecstore",
|
component = "ecstore",
|
||||||
subsystem = "rpc_client",
|
subsystem = "rpc_client",
|
||||||
result = "rejected",
|
result = "rejected",
|
||||||
|
audience = audience.rsplit('@').next().unwrap_or("unknown"),
|
||||||
|
rpc_service = rpc_method.as_ref().map(|method| method.service()).unwrap_or("unknown"),
|
||||||
|
rpc_method = rpc_method.as_ref().map(|method| method.method()).unwrap_or("unknown"),
|
||||||
|
http_status = response.status().as_u16(),
|
||||||
|
grpc_status = response.headers().get("grpc-status").and_then(|value| value.to_str().ok()).unwrap_or("trailers"),
|
||||||
|
capability_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER),
|
||||||
|
capability_proof_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER),
|
||||||
error = %error,
|
error = %error,
|
||||||
"internode RPC capability proof rejected"
|
"internode RPC capability proof rejected"
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,10 @@ use tonic::service::interceptor::InterceptedService;
|
|||||||
use tonic::{Request, Response};
|
use tonic::{Request, Response};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
const EVENT_NAMESPACE_LOCK_RELEASE: &str = "namespace_lock_release";
|
||||||
|
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||||
|
const LOG_SUBSYSTEM_REMOTE_LOCK: &str = "remote_lock";
|
||||||
|
|
||||||
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
||||||
set_tonic_rolling_mutation_body_digest(request)
|
set_tonic_rolling_mutation_body_digest(request)
|
||||||
}
|
}
|
||||||
@@ -233,6 +237,20 @@ pub struct RemoteClient {
|
|||||||
impl RemoteClient {
|
impl RemoteClient {
|
||||||
const ONLINE_CHECK_RESOURCE: &'static str = "health-lock-online";
|
const ONLINE_CHECK_RESOURCE: &'static str = "health-lock-online";
|
||||||
|
|
||||||
|
fn record_release_ack(&self, lock_id: &LockId) {
|
||||||
|
tracing::trace!(
|
||||||
|
target: "rustfs_ecstore::object_lock_diag",
|
||||||
|
event = EVENT_NAMESPACE_LOCK_RELEASE,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_REMOTE_LOCK,
|
||||||
|
state = "acknowledged",
|
||||||
|
peer_kind = "remote",
|
||||||
|
lock_id = %lock_id,
|
||||||
|
peer = %self.addr,
|
||||||
|
"Remote namespace lock release acknowledged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(endpoint: String) -> Self {
|
pub fn new(endpoint: String) -> Self {
|
||||||
Self { addr: endpoint }
|
Self { addr: endpoint }
|
||||||
}
|
}
|
||||||
@@ -1204,8 +1222,6 @@ impl LockClient for RemoteClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn release(&self, lock_id: &LockId) -> Result<bool> {
|
async fn release(&self, lock_id: &LockId) -> Result<bool> {
|
||||||
info!("remote release for {}", lock_id);
|
|
||||||
|
|
||||||
let unlock_request = Self::create_unlock_request(lock_id);
|
let unlock_request = Self::create_unlock_request(lock_id);
|
||||||
let request_string = serde_json::to_string(&unlock_request)
|
let request_string = serde_json::to_string(&unlock_request)
|
||||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
|
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
|
||||||
@@ -1226,6 +1242,9 @@ impl LockClient for RemoteClient {
|
|||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
return Err(LockError::internal(error_info));
|
return Err(LockError::internal(error_info));
|
||||||
}
|
}
|
||||||
|
if resp.success {
|
||||||
|
self.record_release_ack(lock_id);
|
||||||
|
}
|
||||||
Ok(resp.success)
|
Ok(resp.success)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1261,7 +1280,13 @@ impl LockClient for RemoteClient {
|
|||||||
Ok(lock_ids
|
Ok(lock_ids
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, _)| resp.results.get(idx).map(|result| result.success).unwrap_or(false))
|
.map(|(idx, lock_id)| {
|
||||||
|
let result = resp.results.get(idx);
|
||||||
|
if result.is_some_and(|result| result.success && result.error_info.is_none()) {
|
||||||
|
self.record_release_ack(lock_id);
|
||||||
|
}
|
||||||
|
result.map(|result| result.success).unwrap_or(false)
|
||||||
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -438,64 +438,6 @@ where
|
|||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn read_config_observation<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<(Vec<u8>, ObjectInfo)>
|
|
||||||
where
|
|
||||||
S: EcstoreObjectIO,
|
|
||||||
{
|
|
||||||
let opts = ObjectOptions {
|
|
||||||
suppress_read_repair: true,
|
|
||||||
no_lock: false,
|
|
||||||
skip_verify_bitrot: false,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
crate::object_api::without_get_object_body_cache_hook(read_config_limited_preserve_empty_with_metadata_opts(
|
|
||||||
api, file, &opts, max_bytes,
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn read_config_observation_no_lock<S>(
|
|
||||||
api: Arc<S>,
|
|
||||||
file: &str,
|
|
||||||
max_bytes: usize,
|
|
||||||
guard: &crate::object_api::WriteCommitGuard,
|
|
||||||
) -> Result<(Vec<u8>, ObjectInfo)>
|
|
||||||
where
|
|
||||||
S: EcstoreObjectIO,
|
|
||||||
{
|
|
||||||
if !guard.matches(RUSTFS_META_BUCKET, file) {
|
|
||||||
return Err(Error::InvalidArgument(
|
|
||||||
RUSTFS_META_BUCKET.to_owned(),
|
|
||||||
file.to_owned(),
|
|
||||||
"locked observation requires a matching write owner".to_owned(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let lock_lost_error = || Error::NamespaceLockQuorumUnavailable {
|
|
||||||
mode: "locked_config_observation",
|
|
||||||
bucket: RUSTFS_META_BUCKET.to_owned(),
|
|
||||||
object: file.to_owned(),
|
|
||||||
required: 1,
|
|
||||||
achieved: 0,
|
|
||||||
};
|
|
||||||
if guard.is_lock_lost() {
|
|
||||||
return Err(lock_lost_error());
|
|
||||||
}
|
|
||||||
let opts = ObjectOptions {
|
|
||||||
suppress_read_repair: true,
|
|
||||||
no_lock: true,
|
|
||||||
skip_verify_bitrot: false,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let result = crate::object_api::without_get_object_body_cache_hook(read_config_limited_preserve_empty_with_metadata_opts(
|
|
||||||
api, file, &opts, max_bytes,
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
if guard.is_lock_lost() {
|
|
||||||
return Err(lock_lost_error());
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn read_config_limited_preserve_empty<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
|
pub(crate) async fn read_config_limited_preserve_empty<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
|
||||||
where
|
where
|
||||||
S: EcstoreObjectIO,
|
S: EcstoreObjectIO,
|
||||||
@@ -542,11 +484,11 @@ pub(crate) async fn read_config_preserve_empty<S>(api: Arc<S>, file: &str) -> Re
|
|||||||
where
|
where
|
||||||
S: EcstoreObjectIO,
|
S: EcstoreObjectIO,
|
||||||
{
|
{
|
||||||
let (data, _obj) = read_config_preserve_empty_with_metadata_opts(api, file, &ObjectOptions::default()).await?;
|
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true, None).await?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preserve an empty config payload, its metadata, and caller-supplied read options.
|
/// Read config bytes and metadata while preserving an existing empty payload.
|
||||||
pub(crate) async fn read_config_preserve_empty_with_metadata_opts<S>(
|
pub(crate) async fn read_config_preserve_empty_with_metadata_opts<S>(
|
||||||
api: Arc<S>,
|
api: Arc<S>,
|
||||||
file: &str,
|
file: &str,
|
||||||
@@ -586,13 +528,15 @@ pub(crate) async fn read_config_no_lock_preserve_empty_with_metadata<S>(api: Arc
|
|||||||
where
|
where
|
||||||
S: EcstoreObjectIO,
|
S: EcstoreObjectIO,
|
||||||
{
|
{
|
||||||
read_config_preserve_empty_with_metadata_opts(
|
read_config_with_metadata_inner(
|
||||||
api,
|
api,
|
||||||
file,
|
file,
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
no_lock: true,
|
no_lock: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
true,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -2987,9 +2931,9 @@ mod tests {
|
|||||||
config_task_join_error, configs_semantically_equal, decode_server_config_blob, encode_server_config_blob,
|
config_task_join_error, configs_semantically_equal, decode_server_config_blob, encode_server_config_blob,
|
||||||
heal_config_descriptor, is_standard_object_server_config, lookup_configs, map_system_metadata_write_error,
|
heal_config_descriptor, is_standard_object_server_config, lookup_configs, map_system_metadata_write_error,
|
||||||
new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty,
|
new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty,
|
||||||
read_config_preserve_empty_with_metadata_opts, read_config_with_metadata, read_config_without_migrate,
|
read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot, save_config_with_opts_inner,
|
||||||
read_server_config_snapshot, save_config_with_opts_inner, save_server_config, save_server_config_snapshot,
|
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
|
||||||
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, storage_class_kvs_mut,
|
server_config_transaction_lock_path, storage_class_kvs_mut,
|
||||||
};
|
};
|
||||||
use crate::config::{audit, heal, notify, oidc, scanner};
|
use crate::config::{audit, heal, notify, oidc, scanner};
|
||||||
use crate::disk::{RUSTFS_META_BUCKET, endpoint::Endpoint};
|
use crate::disk::{RUSTFS_META_BUCKET, endpoint::Endpoint};
|
||||||
@@ -5464,7 +5408,6 @@ mod tests {
|
|||||||
heal_replacement: Option<Vec<u8>>,
|
heal_replacement: Option<Vec<u8>>,
|
||||||
heal_calls: AtomicUsize,
|
heal_calls: AtomicUsize,
|
||||||
write_calls: AtomicUsize,
|
write_calls: AtomicUsize,
|
||||||
last_get_no_lock: AtomicBool,
|
|
||||||
last_put_no_lock: AtomicBool,
|
last_put_no_lock: AtomicBool,
|
||||||
last_put_preconditions: Mutex<Option<HTTPPreconditions>>,
|
last_put_preconditions: Mutex<Option<HTTPPreconditions>>,
|
||||||
revision: AtomicUsize,
|
revision: AtomicUsize,
|
||||||
@@ -5480,7 +5423,6 @@ mod tests {
|
|||||||
heal_replacement,
|
heal_replacement,
|
||||||
heal_calls: AtomicUsize::new(0),
|
heal_calls: AtomicUsize::new(0),
|
||||||
write_calls: AtomicUsize::new(0),
|
write_calls: AtomicUsize::new(0),
|
||||||
last_get_no_lock: AtomicBool::new(false),
|
|
||||||
last_put_no_lock: AtomicBool::new(false),
|
last_put_no_lock: AtomicBool::new(false),
|
||||||
last_put_preconditions: Mutex::new(None),
|
last_put_preconditions: Mutex::new(None),
|
||||||
revision: AtomicUsize::new(1),
|
revision: AtomicUsize::new(1),
|
||||||
@@ -5536,9 +5478,8 @@ mod tests {
|
|||||||
_object: &str,
|
_object: &str,
|
||||||
_range: Option<HTTPRangeSpec>,
|
_range: Option<HTTPRangeSpec>,
|
||||||
_h: HeaderMap,
|
_h: HeaderMap,
|
||||||
opts: &ObjectOptions,
|
_opts: &ObjectOptions,
|
||||||
) -> Result<GetObjectReader> {
|
) -> Result<GetObjectReader> {
|
||||||
self.last_get_no_lock.store(opts.no_lock, Ordering::SeqCst);
|
|
||||||
let data = match &*self.state.lock().expect("state lock poisoned") {
|
let data = match &*self.state.lock().expect("state lock poisoned") {
|
||||||
RecoveryReadState::Missing => return Err(Error::ConfigNotFound),
|
RecoveryReadState::Missing => return Err(Error::ConfigNotFound),
|
||||||
RecoveryReadState::Blob(data) => data.clone(),
|
RecoveryReadState::Blob(data) => data.clone(),
|
||||||
@@ -5850,325 +5791,6 @@ mod tests {
|
|||||||
assert_eq!(store.write_calls.load(Ordering::SeqCst), 0);
|
assert_eq!(store.write_calls.load(Ordering::SeqCst), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ObservationEofGate = (Option<tokio::sync::oneshot::Sender<()>>, tokio::sync::oneshot::Receiver<()>);
|
|
||||||
|
|
||||||
struct ObservationStream {
|
|
||||||
inner: Cursor<Vec<u8>>,
|
|
||||||
consumed: Arc<AtomicUsize>,
|
|
||||||
fail: bool,
|
|
||||||
eof_gate: Option<ObservationEofGate>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsyncRead for ObservationStream {
|
|
||||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<std::io::Result<()>> {
|
|
||||||
if self.fail {
|
|
||||||
return Poll::Ready(Err(std::io::Error::other("observation stream failed")));
|
|
||||||
}
|
|
||||||
if self.inner.position() == u64::try_from(self.inner.get_ref().len()).expect("fixture length")
|
|
||||||
&& let Some((ready, release)) = self.eof_gate.as_mut()
|
|
||||||
{
|
|
||||||
if let Some(ready) = ready.take() {
|
|
||||||
ready.send(()).expect("EOF observer remains active");
|
|
||||||
}
|
|
||||||
match std::future::Future::poll(Pin::new(release), cx) {
|
|
||||||
Poll::Pending => return Poll::Pending,
|
|
||||||
Poll::Ready(result) => result.expect("EOF gate released"),
|
|
||||||
}
|
|
||||||
self.eof_gate = None;
|
|
||||||
}
|
|
||||||
let before = buf.filled().len();
|
|
||||||
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
|
|
||||||
self.consumed.fetch_add(buf.filled().len() - before, Ordering::SeqCst);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ObservationStore {
|
|
||||||
body: Vec<u8>,
|
|
||||||
declared_size: i64,
|
|
||||||
error: Option<Error>,
|
|
||||||
stream_error: bool,
|
|
||||||
gets: AtomicUsize,
|
|
||||||
eof_gate: std::sync::Mutex<Option<ObservationEofGate>>,
|
|
||||||
consumed: Arc<AtomicUsize>,
|
|
||||||
options: std::sync::Mutex<Option<(bool, bool, bool)>>,
|
|
||||||
writes: AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Debug for ObservationStore {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("ObservationStore").finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ObservationStore {
|
|
||||||
fn new(body: Vec<u8>, declared_size: i64) -> Self {
|
|
||||||
Self {
|
|
||||||
body,
|
|
||||||
declared_size,
|
|
||||||
error: None,
|
|
||||||
stream_error: false,
|
|
||||||
gets: AtomicUsize::new(0),
|
|
||||||
eof_gate: std::sync::Mutex::new(None),
|
|
||||||
consumed: Arc::new(AtomicUsize::new(0)),
|
|
||||||
options: std::sync::Mutex::new(None),
|
|
||||||
writes: AtomicUsize::new(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl crate::storage_api_contracts::object::ObjectIO for ObservationStore {
|
|
||||||
type Error = Error;
|
|
||||||
type RangeSpec = HTTPRangeSpec;
|
|
||||||
type HeaderMap = HeaderMap;
|
|
||||||
type ObjectOptions = ObjectOptions;
|
|
||||||
type ObjectInfo = ObjectInfo;
|
|
||||||
type GetObjectReader = GetObjectReader;
|
|
||||||
type PutObjectReader = PutObjReader;
|
|
||||||
|
|
||||||
async fn get_object_reader(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
object: &str,
|
|
||||||
range: Option<HTTPRangeSpec>,
|
|
||||||
_headers: HeaderMap,
|
|
||||||
opts: &ObjectOptions,
|
|
||||||
) -> Result<GetObjectReader> {
|
|
||||||
assert_eq!(bucket, RUSTFS_META_BUCKET);
|
|
||||||
assert_eq!(object, "config/observation.bin");
|
|
||||||
self.gets.fetch_add(1, Ordering::SeqCst);
|
|
||||||
assert!(range.is_none());
|
|
||||||
*self.options.lock().expect("observation options") =
|
|
||||||
Some((opts.suppress_read_repair, opts.no_lock, opts.skip_verify_bitrot));
|
|
||||||
if let Some(error) = &self.error {
|
|
||||||
return Err(error.clone());
|
|
||||||
}
|
|
||||||
Ok(GetObjectReader {
|
|
||||||
stream: Box::new(ObservationStream {
|
|
||||||
inner: Cursor::new(self.body.clone()),
|
|
||||||
consumed: self.consumed.clone(),
|
|
||||||
fail: self.stream_error,
|
|
||||||
eof_gate: self.eof_gate.lock().expect("EOF gate").take(),
|
|
||||||
}),
|
|
||||||
object_info: ObjectInfo {
|
|
||||||
size: self.declared_size,
|
|
||||||
etag: Some("observation-etag".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
buffered_body: None,
|
|
||||||
body_source: Default::default(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn put_object(
|
|
||||||
&self,
|
|
||||||
_bucket: &str,
|
|
||||||
_object: &str,
|
|
||||||
_data: &mut PutObjReader,
|
|
||||||
_opts: &ObjectOptions,
|
|
||||||
) -> Result<ObjectInfo> {
|
|
||||||
self.writes.fetch_add(1, Ordering::SeqCst);
|
|
||||||
Err(Error::other("observation must not write"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn locked_observation_preserves_options_and_typed_errors() {
|
|
||||||
let object = "config/observation.bin";
|
|
||||||
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
|
|
||||||
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
|
|
||||||
rustfs_lock::NamespaceLock::with_local_manager("locked-observation-options".to_owned(), manager.clone()),
|
|
||||||
rustfs_lock::ObjectKey::new(RUSTFS_META_BUCKET, object),
|
|
||||||
"locked-observation-options".to_owned(),
|
|
||||||
);
|
|
||||||
let guard = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
|
|
||||||
.await
|
|
||||||
.expect("matching real owner");
|
|
||||||
let store = Arc::new(ObservationStore::new(b"raw".to_vec(), 3));
|
|
||||||
let result = super::read_config_observation_no_lock(store.clone(), object, 3, &guard).await;
|
|
||||||
assert!(result.is_ok(), "I06L_READ_FLAGS: {result:?}");
|
|
||||||
assert!(*store.options.lock().expect("options") == Some((true, true, false)), "I06L_READ_FLAGS");
|
|
||||||
assert_eq!(result.expect("raw observation").0, b"raw");
|
|
||||||
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
|
|
||||||
for (bucket, key) in [(RUSTFS_META_BUCKET, "config/other.bin"), ("wrong-bucket", object)] {
|
|
||||||
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
|
|
||||||
rustfs_lock::NamespaceLock::with_local_manager("locked-observation-target".to_owned(), manager.clone()),
|
|
||||||
rustfs_lock::ObjectKey::new(bucket, key),
|
|
||||||
"locked-observation-target".to_owned(),
|
|
||||||
);
|
|
||||||
let wrong = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
|
|
||||||
.await
|
|
||||||
.expect("wrong-target real owner");
|
|
||||||
let store = Arc::new(ObservationStore::new(Vec::new(), 0));
|
|
||||||
let result = super::read_config_observation_no_lock(store.clone(), object, 8, &wrong).await;
|
|
||||||
assert!(matches!(result, Err(Error::InvalidArgument(..))), "I06L_TARGET_REJECTED");
|
|
||||||
assert!(store.gets.load(Ordering::SeqCst) == 0, "I06L_TARGET_REJECTED");
|
|
||||||
}
|
|
||||||
for error in [
|
|
||||||
Error::FileNotFound,
|
|
||||||
Error::ObjectNotFound(RUSTFS_META_BUCKET.to_owned(), object.to_owned()),
|
|
||||||
Error::ErasureReadQuorum,
|
|
||||||
] {
|
|
||||||
let expected = if error == Error::FileNotFound || matches!(error, Error::ObjectNotFound(..)) {
|
|
||||||
Error::ConfigNotFound
|
|
||||||
} else {
|
|
||||||
error.clone()
|
|
||||||
};
|
|
||||||
let mut fixture = ObservationStore::new(Vec::new(), 0);
|
|
||||||
fixture.error = Some(error);
|
|
||||||
let store = Arc::new(fixture);
|
|
||||||
assert_eq!(
|
|
||||||
super::read_config_observation_no_lock(store.clone(), object, 8, &guard)
|
|
||||||
.await
|
|
||||||
.expect_err("typed error"),
|
|
||||||
expected
|
|
||||||
);
|
|
||||||
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
|
|
||||||
}
|
|
||||||
let mut fixture = ObservationStore::new(Vec::new(), 0);
|
|
||||||
fixture.stream_error = true;
|
|
||||||
let store = Arc::new(fixture);
|
|
||||||
let err = super::read_config_observation_no_lock(store.clone(), object, 8, &guard)
|
|
||||||
.await
|
|
||||||
.expect_err("stream failure");
|
|
||||||
assert!(err.to_string().contains("observation stream failed"), "I06L_TYPED_STREAM_ERROR");
|
|
||||||
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
|
|
||||||
let store = Arc::new(ObservationStore::new(vec![7; 64], 0));
|
|
||||||
let result = super::read_config_observation_no_lock(store.clone(), object, 8, &guard).await;
|
|
||||||
assert!(matches!(result, Err(Error::CorruptedFormat)), "I06L_STREAM_BOUND");
|
|
||||||
assert!(store.consumed.load(Ordering::SeqCst) == 9, "I06L_STREAM_BOUND");
|
|
||||||
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn locked_observation_rejects_actual_owner_loss_before_and_after_read() {
|
|
||||||
let object = "config/observation.bin";
|
|
||||||
for after_read in [false, true] {
|
|
||||||
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
|
|
||||||
let client = Arc::new(RefreshControlledClient::new(manager));
|
|
||||||
let wrapper = rustfs_lock::NamespaceLockWrapper::new(
|
|
||||||
rustfs_lock::NamespaceLock::new("locked-observation-loss".to_owned(), client.clone()),
|
|
||||||
rustfs_lock::ObjectKey::new(RUSTFS_META_BUCKET, object),
|
|
||||||
"locked-observation-loss".to_owned(),
|
|
||||||
);
|
|
||||||
let guard = crate::object_api::WriteCommitGuard::acquire(&wrapper, std::time::Duration::from_secs(5))
|
|
||||||
.await
|
|
||||||
.expect("real distributed owner");
|
|
||||||
let signal = guard.lock_lost_signal().expect("acquired distributed owner signal");
|
|
||||||
let store = Arc::new(ObservationStore::new(b"raw".to_vec(), 3));
|
|
||||||
if after_read {
|
|
||||||
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
|
|
||||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
|
||||||
*store.eof_gate.lock().expect("EOF gate") = Some((Some(ready_tx), release_rx));
|
|
||||||
let mut read = Box::pin(super::read_config_observation_no_lock(store.clone(), object, 3, &guard));
|
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(60), async {
|
|
||||||
tokio::select! {
|
|
||||||
ready = ready_rx => ready.expect("read reached EOF"),
|
|
||||||
result = &mut read => assert_eq!(false, true, "I06L_LOSS_AFTER_READ: returned before EOF gate: {result:?}"),
|
|
||||||
}
|
|
||||||
}).await.expect("EOF watchdog");
|
|
||||||
client.reject_refreshes();
|
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(60), signal.notified())
|
|
||||||
.await
|
|
||||||
.expect("actual owner loss watchdog");
|
|
||||||
assert!(guard.is_lock_lost(), "I06L_LOSS_AFTER_READ");
|
|
||||||
release_tx.send(()).expect("release EOF");
|
|
||||||
let result = tokio::time::timeout(std::time::Duration::from_secs(60), read)
|
|
||||||
.await
|
|
||||||
.expect("read watchdog");
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
result,
|
|
||||||
Err(Error::NamespaceLockQuorumUnavailable {
|
|
||||||
mode: "locked_config_observation",
|
|
||||||
..
|
|
||||||
})
|
|
||||||
),
|
|
||||||
"I06L_LOSS_AFTER_READ"
|
|
||||||
);
|
|
||||||
assert_eq!(store.gets.load(Ordering::SeqCst), 1);
|
|
||||||
} else {
|
|
||||||
client.reject_refreshes();
|
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(60), signal.notified())
|
|
||||||
.await
|
|
||||||
.expect("actual owner loss watchdog");
|
|
||||||
assert!(guard.is_lock_lost(), "I06L_LOSS_BEFORE_GET");
|
|
||||||
let result = super::read_config_observation_no_lock(store.clone(), object, 3, &guard).await;
|
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
result,
|
|
||||||
Err(Error::NamespaceLockQuorumUnavailable {
|
|
||||||
mode: "locked_config_observation",
|
|
||||||
..
|
|
||||||
})
|
|
||||||
),
|
|
||||||
"I06L_LOSS_BEFORE_GET"
|
|
||||||
);
|
|
||||||
assert!(store.gets.load(Ordering::SeqCst) == 0, "I06L_LOSS_BEFORE_GET");
|
|
||||||
}
|
|
||||||
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn observation_preserves_options_and_errors() {
|
|
||||||
for body in [Vec::new(), b"raw config".to_vec()] {
|
|
||||||
let store = Arc::new(ObservationStore::new(body.clone(), i64::try_from(body.len()).expect("fixture size")));
|
|
||||||
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
|
|
||||||
assert!(result.is_ok(), "observation must preserve empty and nonempty objects: {result:?}");
|
|
||||||
let (observed, info) = result.expect("successful observation");
|
|
||||||
assert_eq!(observed, body);
|
|
||||||
assert_eq!(info.etag.as_deref(), Some("observation-etag"));
|
|
||||||
assert_eq!(
|
|
||||||
*store.options.lock().expect("options"),
|
|
||||||
Some((true, false, false)),
|
|
||||||
"I05_REPAIR_SUPPRESSED"
|
|
||||||
);
|
|
||||||
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
|
|
||||||
}
|
|
||||||
for (source, expected) in [
|
|
||||||
(Error::FileNotFound, Error::ConfigNotFound),
|
|
||||||
(
|
|
||||||
Error::ObjectNotFound(RUSTFS_META_BUCKET.to_string(), "config/observation.bin".to_string()),
|
|
||||||
Error::ConfigNotFound,
|
|
||||||
),
|
|
||||||
(Error::ErasureReadQuorum, Error::ErasureReadQuorum),
|
|
||||||
] {
|
|
||||||
let mut fixture = ObservationStore::new(Vec::new(), 0);
|
|
||||||
fixture.error = Some(source);
|
|
||||||
let store = Arc::new(fixture);
|
|
||||||
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
|
|
||||||
assert_eq!(result.expect_err("read error must propagate"), expected);
|
|
||||||
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
|
|
||||||
}
|
|
||||||
let mut fixture = ObservationStore::new(Vec::new(), 0);
|
|
||||||
fixture.stream_error = true;
|
|
||||||
let store = Arc::new(fixture);
|
|
||||||
let result = super::read_config_observation(store.clone(), "config/observation.bin", 32).await;
|
|
||||||
let error = result.expect_err("stream error must propagate");
|
|
||||||
assert!(error.to_string().contains("observation stream failed"));
|
|
||||||
assert_eq!(store.writes.load(Ordering::SeqCst), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn observation_enforces_byte_limit() {
|
|
||||||
let store = Arc::new(ObservationStore::new(vec![7; 64], 0));
|
|
||||||
let result = super::read_config_observation(store.clone(), "config/observation.bin", 8).await;
|
|
||||||
assert!(result.is_err(), "I05_BOUND_ENFORCED");
|
|
||||||
assert_eq!(
|
|
||||||
result.expect_err("bounded observation error"),
|
|
||||||
Error::CorruptedFormat,
|
|
||||||
"I05_BOUND_ENFORCED"
|
|
||||||
);
|
|
||||||
assert_eq!(store.consumed.load(Ordering::SeqCst), 9, "I05_BOUND_ENFORCED: drain exactly limit + 1");
|
|
||||||
let store = Arc::new(ObservationStore::new(vec![7; 8], 8));
|
|
||||||
let result = super::read_config_observation(store.clone(), "config/observation.bin", 8).await;
|
|
||||||
assert!(result.is_ok(), "exact byte limit must succeed: {result:?}");
|
|
||||||
assert_eq!(result.expect("exact limit").0, vec![7; 8]);
|
|
||||||
assert_eq!(store.consumed.load(Ordering::SeqCst), 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_config_preserve_empty_distinguishes_empty_object() {
|
async fn read_config_preserve_empty_distinguishes_empty_object() {
|
||||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
|
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
|
||||||
@@ -6189,60 +5811,6 @@ mod tests {
|
|||||||
assert!(data.is_empty());
|
assert!(data.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_config_preserve_empty_metadata_keeps_empty_and_options() {
|
|
||||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(Vec::new()), None));
|
|
||||||
for no_lock in [false, true] {
|
|
||||||
let opts = ObjectOptions {
|
|
||||||
no_lock,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let (data, info) = read_config_preserve_empty_with_metadata_opts(store.clone(), "config/empty.json", &opts)
|
|
||||||
.await
|
|
||||||
.expect("an existing empty config must retain its payload and metadata");
|
|
||||||
assert!(data.is_empty());
|
|
||||||
assert_eq!(info.size, 0);
|
|
||||||
assert_eq!(info.actual_size, 0);
|
|
||||||
assert_eq!(info.etag.as_deref(), Some("config-1"));
|
|
||||||
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
|
|
||||||
assert_eq!(store.last_get_no_lock.load(Ordering::SeqCst), no_lock);
|
|
||||||
}
|
|
||||||
let err = read_config(store, "config/empty.json")
|
|
||||||
.await
|
|
||||||
.expect_err("the existing reader must still treat empty configs as missing");
|
|
||||||
assert!(matches!(err, Error::ConfigNotFound));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_config_preserve_empty_metadata_keeps_payload_and_metadata() {
|
|
||||||
let payload = b"original config bytes".to_vec();
|
|
||||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(payload.clone()), None));
|
|
||||||
let (data, info) =
|
|
||||||
read_config_preserve_empty_with_metadata_opts(store, "config/nonempty.json", &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("a nonempty config must retain its payload and metadata");
|
|
||||||
assert_eq!(data, payload);
|
|
||||||
let size = i64::try_from(payload.len()).expect("test payload size fits i64");
|
|
||||||
assert_eq!(info.size, size);
|
|
||||||
assert_eq!(info.actual_size, size);
|
|
||||||
assert_eq!(info.etag.as_deref(), Some("config-1"));
|
|
||||||
assert_eq!(info.data_dir, Some(uuid::Uuid::from_u128(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_config_preserve_empty_metadata_preserves_read_errors() {
|
|
||||||
let missing = Arc::new(RecoveryMockStore::new(RecoveryReadState::Missing, None));
|
|
||||||
let err = read_config_preserve_empty_with_metadata_opts(missing, "config/missing.json", &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect_err("physical absence must remain ConfigNotFound");
|
|
||||||
assert!(matches!(err, Error::ConfigNotFound));
|
|
||||||
let quorum = Arc::new(RecoveryMockStore::new(RecoveryReadState::QuorumError, None));
|
|
||||||
let err = read_config_preserve_empty_with_metadata_opts(quorum, "config/quorum.json", &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect_err("a quorum failure must retain its typed read error");
|
|
||||||
assert!(matches!(err, Error::ErasureReadQuorum));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl StorageAdminApi for RecoveryMockStore {
|
impl StorageAdminApi for RecoveryMockStore {
|
||||||
type BackendInfo = rustfs_madmin::BackendInfo;
|
type BackendInfo = rustfs_madmin::BackendInfo;
|
||||||
|
|||||||
@@ -0,0 +1,630 @@
|
|||||||
|
// Copyright 2026 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 super::*;
|
||||||
|
use crate::disk::error::DiskError;
|
||||||
|
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||||
|
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
struct PhysicalFixture {
|
||||||
|
_directories: Vec<tempfile::TempDir>,
|
||||||
|
store: Arc<ECStore>,
|
||||||
|
canonical: Vec<u8>,
|
||||||
|
pending: Vec<u8>,
|
||||||
|
identity: Vec<u8>,
|
||||||
|
revision: PoolMetaRevision,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object_path(disk: &DiskStore) -> PathBuf {
|
||||||
|
disk.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||||
|
.expect("physical fixture must use local disks")
|
||||||
|
.expect("pool metadata path should resolve")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disks(store: &ECStore) -> Vec<Vec<DiskStore>> {
|
||||||
|
let mut all = Vec::with_capacity(store.pools.len());
|
||||||
|
for pool in &store.pools {
|
||||||
|
let set = pool.get_disks_by_key(POOL_META_NAME);
|
||||||
|
let disks = set
|
||||||
|
.disks
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|disk| disk.expect("physical fixture must keep every disk online"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(disks.len(), 4, "fixture requires an EC 2+2 set");
|
||||||
|
all.push(disks);
|
||||||
|
}
|
||||||
|
all
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn owner(store: &ECStore) -> Arc<rustfs_lock::NamespaceLockGuard> {
|
||||||
|
let lock = store.pools[0]
|
||||||
|
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||||
|
.await
|
||||||
|
.expect("real namespace lock should be created");
|
||||||
|
Arc::new(
|
||||||
|
lock.get_write_lock(get_lock_acquire_timeout())
|
||||||
|
.await
|
||||||
|
.expect("physical recovery must hold the namespace writer"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_state(fixture: &PhysicalFixture) -> PoolMetaWriteState {
|
||||||
|
PoolMetaWriteState::for_startup(fixture.revision.cluster_id.expect("fixture V3 revision requires identity"), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn snapshots(store: &ECStore) -> Vec<Vec<PoolMetaNativeInspection>> {
|
||||||
|
let mut all = Vec::new();
|
||||||
|
for pool in disks(store).await {
|
||||||
|
let mut current = Vec::new();
|
||||||
|
for disk in pool {
|
||||||
|
match disk
|
||||||
|
.pool_meta_native_recovery(Request::Inspect {
|
||||||
|
object: PoolMetaNativeObject::PoolBin,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("physical native inspection should succeed")
|
||||||
|
{
|
||||||
|
Response::Inspected(inspection) => current.push(inspection),
|
||||||
|
_ => panic!("inspection must return a disk snapshot"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all.push(current);
|
||||||
|
}
|
||||||
|
all
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fixture(split_pools: usize) -> PhysicalFixture {
|
||||||
|
let (directories, store, _other_store) =
|
||||||
|
crate::services::rebalance::test_three_pool_stores_with_four_disk_sets_with_isolated_node_contexts(None).await;
|
||||||
|
persist_v3_pool_meta_for_test(&store).await;
|
||||||
|
let previous = load_pool_meta_replicas(store.pools.clone(), true)
|
||||||
|
.await
|
||||||
|
.expect("baseline V3 pool metadata must be ordinary-readable");
|
||||||
|
let revision = PoolMetaRevision {
|
||||||
|
generation: previous
|
||||||
|
.revision
|
||||||
|
.generation
|
||||||
|
.checked_add(1)
|
||||||
|
.expect("test generation should increment"),
|
||||||
|
transaction_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..previous.revision
|
||||||
|
};
|
||||||
|
let candidate = PoolMetaCommittedCandidate {
|
||||||
|
canonical: previous.canonical.expect("baseline must have a canonical V3 COMMIT"),
|
||||||
|
meta: previous.meta.clone(),
|
||||||
|
revision: previous.revision,
|
||||||
|
};
|
||||||
|
let canonical = encode_pool_meta_v3_envelope(&previous.meta, revision, true, None).expect("next COMMIT should encode");
|
||||||
|
let pending =
|
||||||
|
encode_pool_meta_v3_envelope(&previous.meta, revision, false, Some(&candidate)).expect("next PREPARE should encode");
|
||||||
|
let identity = load_pool_meta_identity(
|
||||||
|
store.pools.clone(),
|
||||||
|
revision
|
||||||
|
.cluster_id
|
||||||
|
.expect("baseline V3 metadata should have a cluster identity"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("real initialized identities must agree")
|
||||||
|
.identity
|
||||||
|
.expect("real fixture identity must be present");
|
||||||
|
let identity = encode_pool_meta_identity(identity).expect("real fixture identity should encode canonically");
|
||||||
|
let modified = OffsetDateTime::now_utc();
|
||||||
|
let split = split(&pending, &canonical, modified).await;
|
||||||
|
let commit = replicas(&canonical, modified + Duration::seconds(1)).await;
|
||||||
|
let prepared = replicas(&pending, modified).await;
|
||||||
|
let directory = uuid::Uuid::new_v4();
|
||||||
|
for (pool_index, pool) in disks(&store).await.into_iter().enumerate() {
|
||||||
|
let heads = if pool_index < split_pools { &split } else { &commit };
|
||||||
|
for (disk_index, disk) in pool.into_iter().enumerate() {
|
||||||
|
let object = object_path(&disk);
|
||||||
|
fs::remove_dir_all(&object)
|
||||||
|
.await
|
||||||
|
.expect("fault injection should remove only its own pool.bin fixture");
|
||||||
|
fs::create_dir_all(object.join(directory.to_string()))
|
||||||
|
.await
|
||||||
|
.expect("fixture rollback directory should be created");
|
||||||
|
fs::write(object.join("xl.meta"), &heads[disk_index])
|
||||||
|
.await
|
||||||
|
.expect("physical split head should be installed");
|
||||||
|
fs::write(object.join(directory.to_string()).join("xl.meta.bkp"), &prepared[disk_index])
|
||||||
|
.await
|
||||||
|
.expect("physical PREPARE backup should be installed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhysicalFixture {
|
||||||
|
_directories: directories,
|
||||||
|
store,
|
||||||
|
canonical,
|
||||||
|
pending,
|
||||||
|
identity,
|
||||||
|
revision,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_committed(fixture: &PhysicalFixture) {
|
||||||
|
let confirmed = load_pool_meta_replicas(fixture.store.pools.clone(), true)
|
||||||
|
.await
|
||||||
|
.expect("ordinary full-pool read must succeed after physical recovery");
|
||||||
|
assert_eq!(confirmed.canonical.as_deref(), Some(fixture.canonical.as_slice()));
|
||||||
|
assert_eq!(confirmed.revision, fixture.revision);
|
||||||
|
assert!(!confirmed.replica_state.needs_repair);
|
||||||
|
assert!(confirmed.replica_state.repair_write_safe);
|
||||||
|
for pool in snapshots(&fixture.store).await {
|
||||||
|
let raw = pool
|
||||||
|
.iter()
|
||||||
|
.map(|disk| disk.current.clone().expect("every recovered physical head must exist"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
codec::decode_inline_pool_meta_replicas(&raw)
|
||||||
|
.await
|
||||||
|
.expect("every recovered shard must verify"),
|
||||||
|
vec![Bytes::copy_from_slice(&fixture.canonical)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(pool_meta_version_env)]
|
||||||
|
async fn pool_meta_native_physical_split_repair_completes_normal_cas_and_quorum_readback() {
|
||||||
|
let fixture = fixture(1).await;
|
||||||
|
let before = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
|
||||||
|
assert!(matches!(before[0].replica, PoolMetaReplica::Unreadable(_)));
|
||||||
|
assert!(matches!(before[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
|
||||||
|
let mut state = clean_state(&fixture);
|
||||||
|
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||||
|
assert!(
|
||||||
|
repair(&fixture.store, &mut state, &fence)
|
||||||
|
.await
|
||||||
|
.expect("healthy ordinary COMMIT must authorize physical repair")
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.ensure_write_safe("physical recovery readback")
|
||||||
|
.expect("completed recovery must disarm its transaction");
|
||||||
|
assert_committed(&fixture).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(pool_meta_version_env)]
|
||||||
|
async fn pool_meta_native_physical_split_without_ordinary_commit_performs_no_mutation() {
|
||||||
|
let fixture = fixture(3).await;
|
||||||
|
let before = snapshots(&fixture.store).await;
|
||||||
|
let mut state = clean_state(&fixture);
|
||||||
|
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||||
|
assert!(
|
||||||
|
repair(&fixture.store, &mut state, &fence).await.is_err(),
|
||||||
|
"reconstruction alone must never authorize a COMMIT"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
snapshots(&fixture.store).await,
|
||||||
|
before,
|
||||||
|
"all current heads, identities and complete backups must remain unchanged"
|
||||||
|
);
|
||||||
|
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IdentityEpochFork {
|
||||||
|
path: PathBuf,
|
||||||
|
original: Bytes,
|
||||||
|
replacement: Bytes,
|
||||||
|
original_identity: PersistedPoolMetaIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn identity_epoch_fork(fixture: &PhysicalFixture) -> IdentityEpochFork {
|
||||||
|
let identity_set = fixture.store.pools[0].get_disks_by_key(POOL_META_IDENTITY_NAME);
|
||||||
|
assert!(
|
||||||
|
!Arc::ptr_eq(&identity_set, &fixture.store.pools[0].get_disks_by_key(POOL_META_NAME)),
|
||||||
|
"the identity evidence must come from its independently placed physical set"
|
||||||
|
);
|
||||||
|
let identity_disks = identity_set
|
||||||
|
.disks
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|disk| disk.expect("actual identity set must retain all four disks"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut raw = Vec::with_capacity(identity_disks.len());
|
||||||
|
for disk in &identity_disks {
|
||||||
|
match disk
|
||||||
|
.pool_meta_native_recovery(Request::InspectIdentity {})
|
||||||
|
.await
|
||||||
|
.expect("actual identity set must support native inspection")
|
||||||
|
{
|
||||||
|
Response::IdentityInspected { disk_id, raw: current } => {
|
||||||
|
assert!(!disk_id.is_nil());
|
||||||
|
raw.push(current);
|
||||||
|
}
|
||||||
|
_ => panic!("identity inspection must return its physical raw shard"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(raw.len(), 4);
|
||||||
|
let original_identity = match decode_pool_meta_identity(&fixture.identity) {
|
||||||
|
PoolMetaIdentityReplica::Valid(identity) => identity,
|
||||||
|
_ => panic!("fixture must provide a valid initialized identity"),
|
||||||
|
};
|
||||||
|
let forked_identity = PersistedPoolMetaIdentity {
|
||||||
|
epoch: original_identity
|
||||||
|
.epoch
|
||||||
|
.checked_add(1)
|
||||||
|
.expect("test identity epoch should increment"),
|
||||||
|
..original_identity
|
||||||
|
};
|
||||||
|
let forked = encode_pool_meta_identity(forked_identity).expect("foreign epoch identity should remain validly encoded");
|
||||||
|
assert!(matches!(decode_pool_meta_identity(&forked), PoolMetaIdentityReplica::Valid(_)));
|
||||||
|
let modified = raw
|
||||||
|
.iter()
|
||||||
|
.map(|raw| {
|
||||||
|
codec::parse_inline_info(raw)
|
||||||
|
.expect("actual identity shard layout should parse")
|
||||||
|
.mod_time
|
||||||
|
})
|
||||||
|
.collect::<Option<Vec<_>>>()
|
||||||
|
.expect("every actual identity shard should have a modification time")
|
||||||
|
.into_iter()
|
||||||
|
.max()
|
||||||
|
.expect("actual identity set should contain four shards")
|
||||||
|
+ Duration::nanoseconds(1);
|
||||||
|
let replacements = codec::encode_inline_pool_meta_replicas(&raw, &forked, modified)
|
||||||
|
.await
|
||||||
|
.expect("epoch fork must retain the actual set's valid erasure layout");
|
||||||
|
assert_eq!(
|
||||||
|
codec::parse_inline_info(&raw[0]).expect("original layout").erasure,
|
||||||
|
codec::parse_inline_info(&replacements[0])
|
||||||
|
.expect("replacement layout")
|
||||||
|
.erasure
|
||||||
|
);
|
||||||
|
codec::validate_inline_pool_meta_shard(&replacements[0], 0, &forked)
|
||||||
|
.await
|
||||||
|
.expect("forked shard must pass the physical integrity checks");
|
||||||
|
let identity_path = identity_disks[0]
|
||||||
|
.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_IDENTITY_NAME)
|
||||||
|
.expect("identity fixture must use local storage")
|
||||||
|
.expect("actual identity metadata path should resolve");
|
||||||
|
IdentityEpochFork {
|
||||||
|
path: identity_path.join("xl.meta"),
|
||||||
|
original: raw[0].clone(),
|
||||||
|
replacement: replacements[0].clone(),
|
||||||
|
original_identity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(pool_meta_version_env)]
|
||||||
|
async fn pool_meta_native_physical_identity_actual_set_minority_epoch_fork_performs_no_apply() {
|
||||||
|
let fixture = fixture(1).await;
|
||||||
|
let fork = identity_epoch_fork(&fixture).await;
|
||||||
|
let heads = |pools: Vec<Vec<PoolMetaNativeInspection>>| {
|
||||||
|
pools
|
||||||
|
.into_iter()
|
||||||
|
.map(|pool| pool.into_iter().map(|disk| (disk.current, disk.backups)).collect::<Vec<_>>())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let before = heads(snapshots(&fixture.store).await);
|
||||||
|
fs::write(&fork.path, &fork.replacement)
|
||||||
|
.await
|
||||||
|
.expect("only one actual identity-set disk should receive the epoch fork");
|
||||||
|
let selected = load_pool_meta_identity(fixture.store.pools.clone(), fork.original_identity.cluster_id)
|
||||||
|
.await
|
||||||
|
.expect("three matching identity shards must remain ordinary-readable");
|
||||||
|
assert_eq!(selected.identity, Some(fork.original_identity));
|
||||||
|
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
|
||||||
|
assert!(matches!(reads[0].replica, PoolMetaReplica::Unreadable(_)));
|
||||||
|
assert!(matches!(reads[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
|
||||||
|
let mut state = clean_state(&fixture);
|
||||||
|
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||||
|
let error = repair(&fixture.store, &mut state, &fence)
|
||||||
|
.await
|
||||||
|
.expect_err("ordinary identity quorum must not hide a physical minority epoch fork");
|
||||||
|
assert_eq!(
|
||||||
|
error
|
||||||
|
.pool_metadata_failure()
|
||||||
|
.expect("known identity fork must retain typed failure")
|
||||||
|
.kind,
|
||||||
|
crate::error::PoolMetadataFailure::RecoveryRequired
|
||||||
|
);
|
||||||
|
assert!(state.write_blocked, "known minority fork must latch the write gate");
|
||||||
|
assert_eq!(
|
||||||
|
heads(snapshots(&fixture.store).await),
|
||||||
|
before,
|
||||||
|
"identity rejection must precede every pool.bin Apply"
|
||||||
|
);
|
||||||
|
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
|
||||||
|
fs::write(&fork.path, &fork.original)
|
||||||
|
.await
|
||||||
|
.expect("restoring the input identity must not clear the observed fork latch");
|
||||||
|
let error = repair(&fixture.store, &mut state, &fence)
|
||||||
|
.await
|
||||||
|
.expect_err("the same state must retain operator recovery after identity becomes consistent");
|
||||||
|
assert_eq!(
|
||||||
|
error
|
||||||
|
.pool_metadata_failure()
|
||||||
|
.expect("sticky failure must retain its classification")
|
||||||
|
.kind,
|
||||||
|
crate::error::PoolMetadataFailure::RecoveryRequired
|
||||||
|
);
|
||||||
|
assert!(state.write_blocked);
|
||||||
|
assert_eq!(heads(snapshots(&fixture.store).await), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(pool_meta_version_env)]
|
||||||
|
async fn pool_meta_native_physical_runtime_fork_after_apply_latches_original_gate() {
|
||||||
|
let fixture = fixture(1).await;
|
||||||
|
let fork = identity_epoch_fork(&fixture).await;
|
||||||
|
let since = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed original failure timestamp should be valid");
|
||||||
|
{
|
||||||
|
let mut state = fixture.store.pool_meta_save_gate.lock().await;
|
||||||
|
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
|
||||||
|
state.write_blocked = false;
|
||||||
|
state.block_context = None;
|
||||||
|
state.aborted_transaction.store(true, Ordering::SeqCst);
|
||||||
|
let mut failure = pool_metadata_error(crate::error::PoolMetadataFailure::TransactionUnknown, "commit_cas", None);
|
||||||
|
failure.since = since;
|
||||||
|
*state
|
||||||
|
.transaction_failure
|
||||||
|
.lock()
|
||||||
|
.expect("original transaction failure mutex should lock") = Some(failure);
|
||||||
|
*state
|
||||||
|
.block_started_at
|
||||||
|
.lock()
|
||||||
|
.expect("original block timestamp mutex should lock") = Some(since);
|
||||||
|
}
|
||||||
|
let pause = Arc::new(NativeApplyPause {
|
||||||
|
reached: tokio::sync::Notify::new(),
|
||||||
|
resume: tokio::sync::Notify::new(),
|
||||||
|
});
|
||||||
|
let reached = pause.reached.notified();
|
||||||
|
tokio::pin!(reached);
|
||||||
|
reached.as_mut().enable();
|
||||||
|
let recovery = tokio::spawn({
|
||||||
|
let store = Arc::clone(&fixture.store);
|
||||||
|
let pause = Arc::clone(&pause);
|
||||||
|
async move {
|
||||||
|
AFTER_NATIVE_APPLY
|
||||||
|
.scope(pause, async move { store.recover_pool_meta_transaction().await })
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(10), reached)
|
||||||
|
.await
|
||||||
|
.expect("real runtime recovery should pause after completing physical Apply");
|
||||||
|
let readable = load_pool_meta_replicas(fixture.store.pools.clone(), true)
|
||||||
|
.await
|
||||||
|
.expect("physical Apply must already make every pool ordinary-readable");
|
||||||
|
assert_eq!(readable.canonical.as_deref(), Some(fixture.canonical.as_slice()));
|
||||||
|
fs::write(&fork.path, &fork.replacement)
|
||||||
|
.await
|
||||||
|
.expect("a valid minority identity fork should arrive after physical repair");
|
||||||
|
pause.resume.notify_one();
|
||||||
|
let error = tokio::time::timeout(std::time::Duration::from_secs(10), recovery)
|
||||||
|
.await
|
||||||
|
.expect("resumed runtime recovery should finish its validation")
|
||||||
|
.expect("runtime recovery task should not panic")
|
||||||
|
.expect_err("post-Apply identity fork must reject runtime recovery");
|
||||||
|
assert_eq!(
|
||||||
|
error
|
||||||
|
.pool_metadata_failure()
|
||||||
|
.expect("runtime fork failure must remain typed")
|
||||||
|
.kind,
|
||||||
|
crate::error::PoolMetadataFailure::RecoveryRequired
|
||||||
|
);
|
||||||
|
assert_committed(&fixture).await;
|
||||||
|
{
|
||||||
|
let state = fixture.store.pool_meta_save_gate.lock().await;
|
||||||
|
assert!(
|
||||||
|
state.write_blocked,
|
||||||
|
"candidate validation must propagate its permanent latch to the original state"
|
||||||
|
);
|
||||||
|
let context = state
|
||||||
|
.block_context
|
||||||
|
.as_ref()
|
||||||
|
.expect("original state must retain the known fork context");
|
||||||
|
assert_eq!(context.kind, crate::error::PoolMetadataFailure::RecoveryRequired);
|
||||||
|
assert_eq!(context.since, since, "candidate failure must preserve the original outage timestamp");
|
||||||
|
assert_eq!(*state.block_started_at.lock().expect("original block timestamp should lock"), Some(since));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!fixture
|
||||||
|
.store
|
||||||
|
.recover_pool_meta_transaction()
|
||||||
|
.await
|
||||||
|
.expect("permanently blocked recovery should return without mutation"),
|
||||||
|
"ordinary-readable repaired heads must not clear the original known-fork latch"
|
||||||
|
);
|
||||||
|
let state = fixture.store.pool_meta_save_gate.lock().await;
|
||||||
|
assert!(state.write_blocked);
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.block_context
|
||||||
|
.as_ref()
|
||||||
|
.expect("sticky fork context must remain present")
|
||||||
|
.since,
|
||||||
|
since
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(pool_meta_version_env)]
|
||||||
|
async fn pool_meta_native_physical_partial_apply_resumes_after_conditional_rejection() {
|
||||||
|
let fixture = fixture(1).await;
|
||||||
|
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||||
|
let inspected = inspect(&fixture.store.pools, &fence)
|
||||||
|
.await
|
||||||
|
.expect("real native snapshots should be obtained");
|
||||||
|
let replacements = plan(&inspected, &fixture.canonical, fixture.revision, &fixture.identity)
|
||||||
|
.await
|
||||||
|
.expect("physical recovery plan should validate");
|
||||||
|
let snapshot = &inspected[0].snapshots[0];
|
||||||
|
assert_eq!(
|
||||||
|
inspected[0].disks[0]
|
||||||
|
.pool_meta_native_recovery(Request::Apply {
|
||||||
|
object: PoolMetaNativeObject::PoolBin,
|
||||||
|
expected_disk_id: snapshot.disk_id,
|
||||||
|
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
|
||||||
|
expected_backup_inventory_sha256: snapshot.inventory_sha256().expect("snapshot inventory should hash"),
|
||||||
|
replacement: replacements[0][0].clone(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("first real Apply must atomically publish and sync"),
|
||||||
|
Response::Applied
|
||||||
|
);
|
||||||
|
let before_rejected = snapshots(&fixture.store).await;
|
||||||
|
let snapshot = &inspected[0].snapshots[1];
|
||||||
|
let mut stale_inventory = snapshot.inventory_sha256().expect("snapshot inventory should hash");
|
||||||
|
stale_inventory[0] ^= 1;
|
||||||
|
assert!(matches!(
|
||||||
|
inspected[0].disks[1]
|
||||||
|
.pool_meta_native_recovery(Request::Apply {
|
||||||
|
object: PoolMetaNativeObject::PoolBin,
|
||||||
|
expected_disk_id: snapshot.disk_id,
|
||||||
|
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
|
||||||
|
expected_backup_inventory_sha256: stale_inventory,
|
||||||
|
replacement: replacements[0][1].clone(),
|
||||||
|
})
|
||||||
|
.await,
|
||||||
|
Err(DiskError::ConditionalDeleteConflict)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
snapshots(&fixture.store).await,
|
||||||
|
before_rejected,
|
||||||
|
"rejected Apply must preserve the partial repair evidence"
|
||||||
|
);
|
||||||
|
drop(inspected);
|
||||||
|
drop(fence);
|
||||||
|
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||||
|
let mut state = clean_state(&fixture);
|
||||||
|
assert!(
|
||||||
|
repair(&fixture.store, &mut state, &fence)
|
||||||
|
.await
|
||||||
|
.expect("a fresh owner should resume the partial physical repair")
|
||||||
|
);
|
||||||
|
assert_committed(&fixture).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(pool_meta_version_env)]
|
||||||
|
async fn pool_meta_native_physical_noninline_conditional_io_round_trip() {
|
||||||
|
let fixture = fixture(0).await;
|
||||||
|
let pool = Arc::clone(&fixture.store.pools[0]);
|
||||||
|
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||||
|
let (original, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
|
||||||
|
.await
|
||||||
|
.expect("baseline conditional object must be readable");
|
||||||
|
let token = pool_meta_cas_token_from_info(info);
|
||||||
|
// This smoke test targets the physical conditional I/O boundary. The large
|
||||||
|
// opaque body deliberately exceeds the inline writer's one-block limit.
|
||||||
|
let payload = vec![0x5a; rustfs_filemeta::BLOCK_SIZE_V2 + 137];
|
||||||
|
let state = clean_state(&fixture);
|
||||||
|
let mut arm = state.arm_transaction();
|
||||||
|
save_pool_meta_object_cas(
|
||||||
|
Arc::clone(&pool),
|
||||||
|
POOL_META_NAME,
|
||||||
|
payload.clone(),
|
||||||
|
&token,
|
||||||
|
&fence,
|
||||||
|
"noninline_smoke",
|
||||||
|
&mut arm,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("normal native CAS must support noninline pool metadata storage");
|
||||||
|
let (readback, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
|
||||||
|
.await
|
||||||
|
.expect("noninline conditional write must be ordinary-readable");
|
||||||
|
assert_eq!(readback, payload);
|
||||||
|
for disk in &snapshots(&fixture.store).await[0] {
|
||||||
|
let raw = disk
|
||||||
|
.current
|
||||||
|
.as_ref()
|
||||||
|
.expect("noninline head should be installed on every disk");
|
||||||
|
let info = rustfs_filemeta::get_file_info(
|
||||||
|
raw,
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
POOL_META_NAME,
|
||||||
|
"",
|
||||||
|
rustfs_filemeta::FileInfoOpts {
|
||||||
|
data: true,
|
||||||
|
include_free_versions: false,
|
||||||
|
include_part_checksums: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("physical noninline metadata should decode");
|
||||||
|
assert!(!info.inline_data());
|
||||||
|
assert!(info.data.is_none());
|
||||||
|
}
|
||||||
|
save_pool_meta_object_cas(
|
||||||
|
pool,
|
||||||
|
POOL_META_NAME,
|
||||||
|
original.clone(),
|
||||||
|
&pool_meta_cas_token_from_info(info),
|
||||||
|
&fence,
|
||||||
|
"noninline_to_inline_smoke",
|
||||||
|
&mut arm,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("normal CAS must also replace a noninline parent with inline metadata");
|
||||||
|
arm.disarm();
|
||||||
|
assert_committed(&fixture).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(pool_meta_version_env)]
|
||||||
|
async fn pool_meta_native_physical_three_plus_one_checkpoint_converges_all_shards() {
|
||||||
|
let fixture = fixture(0).await;
|
||||||
|
let pending = replicas(&fixture.pending, OffsetDateTime::now_utc() - Duration::seconds(1)).await;
|
||||||
|
let all_disks = disks(&fixture.store).await;
|
||||||
|
fs::write(object_path(&all_disks[0][0]).join("xl.meta"), &pending[0])
|
||||||
|
.await
|
||||||
|
.expect("one stale physical PREPARE head should be installed");
|
||||||
|
let selection = load_pool_meta_replicas(fixture.store.pools.clone(), true)
|
||||||
|
.await
|
||||||
|
.expect("three matching COMMIT shards must remain ordinary-readable");
|
||||||
|
assert_eq!(selection.canonical.as_deref(), Some(fixture.canonical.as_slice()));
|
||||||
|
let guard = owner(&fixture.store).await;
|
||||||
|
let fence = PoolMetaPersistenceFence::Owned(Arc::clone(&guard));
|
||||||
|
let mut state = clean_state(&fixture);
|
||||||
|
assert!(
|
||||||
|
!repair(&fixture.store, &mut state, &fence)
|
||||||
|
.await
|
||||||
|
.expect("ordinary-readable pools should use normal checkpoint recovery")
|
||||||
|
);
|
||||||
|
selection
|
||||||
|
.meta
|
||||||
|
.save_for_startup_guarded(fixture.store.pools.clone(), &mut state, guard)
|
||||||
|
.await
|
||||||
|
.expect("normal checkpoint must converge a readable 3+1 pool without an unsafe native overwrite");
|
||||||
|
let checkpoint = load_pool_meta_replicas(fixture.store.pools.clone(), true)
|
||||||
|
.await
|
||||||
|
.expect("checkpoint must remain ordinary-readable across all pools");
|
||||||
|
assert!(!checkpoint.replica_state.needs_repair);
|
||||||
|
assert!(checkpoint.replica_state.repair_write_safe);
|
||||||
|
assert!(checkpoint.revision.generation > fixture.revision.generation);
|
||||||
|
let canonical = checkpoint.canonical.expect("checkpoint should expose a canonical COMMIT");
|
||||||
|
for pool in snapshots(&fixture.store).await {
|
||||||
|
let raw = pool
|
||||||
|
.into_iter()
|
||||||
|
.map(|disk| disk.current.expect("checkpoint head must exist"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
codec::decode_inline_pool_meta_replicas(&raw)
|
||||||
|
.await
|
||||||
|
.expect("all four checkpoint shards must verify"),
|
||||||
|
vec![Bytes::copy_from_slice(&canonical)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
// Copyright 2026 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 super::pool_meta_recovery_codec as codec;
|
||||||
|
use super::*;
|
||||||
|
use crate::disk::{
|
||||||
|
DiskAPI, DiskStore, PoolMetaNativeInspection, PoolMetaNativeObject, PoolMetaNativeRecoveryRequest as Request,
|
||||||
|
PoolMetaNativeRecoveryResponse as Response,
|
||||||
|
};
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct NativeApplyPause {
|
||||||
|
pub(super) reached: tokio::sync::Notify,
|
||||||
|
pub(super) resume: tokio::sync::Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
tokio::task_local! {
|
||||||
|
pub(super) static AFTER_NATIVE_APPLY: Arc<NativeApplyPause>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InspectedPool {
|
||||||
|
disks: Vec<DiskStore>,
|
||||||
|
snapshots: Vec<PoolMetaNativeInspection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid() -> Error {
|
||||||
|
Error::other("native pool metadata recovery requires one proven V3 transaction and complete matching disk evidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known_validation(source: Error, phase: &'static str) -> Error {
|
||||||
|
Error::other(pool_metadata_error(
|
||||||
|
crate::error::PoolMetadataFailure::RecoveryRequired,
|
||||||
|
phase,
|
||||||
|
Some(Arc::new(source)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_validation(source: codec::PoolMetaCodecError, phase: &'static str) -> Error {
|
||||||
|
// A supported transport may carry a valid layout outside this narrow
|
||||||
|
// recovery codec. That is not evidence of corruption or a transaction fork.
|
||||||
|
if matches!(source, codec::PoolMetaCodecError::UnsupportedLayout) {
|
||||||
|
return Error::other(source);
|
||||||
|
}
|
||||||
|
known_validation(Error::other(source), phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latch_validation(state: &mut PoolMetaWriteState, error: Error) -> Error {
|
||||||
|
if let Some(context) = error.pool_metadata_failure()
|
||||||
|
&& context.kind == crate::error::PoolMetadataFailure::RecoveryRequired
|
||||||
|
{
|
||||||
|
state.block_with_context(context.clone());
|
||||||
|
}
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current(pool: &InspectedPool) -> Result<Vec<Bytes>> {
|
||||||
|
pool.snapshots
|
||||||
|
.iter()
|
||||||
|
.map(|snapshot| snapshot.current.clone().ok_or_else(invalid))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inspect(pools: &[Arc<Sets>], fence: &PoolMetaPersistenceFence<'_>) -> Result<Vec<InspectedPool>> {
|
||||||
|
let mut inspected = Vec::with_capacity(pools.len());
|
||||||
|
// Pool -> disk order is stable; all capability probes precede every mutation.
|
||||||
|
for pool in pools {
|
||||||
|
fence.ensure_held()?;
|
||||||
|
let set = pool.get_disks_by_key(POOL_META_NAME);
|
||||||
|
let disks = set
|
||||||
|
.disks
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|disk| disk.ok_or_else(invalid))
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
let probes = futures::stream::iter(disks.iter().cloned().map(|disk| async move {
|
||||||
|
match disk.pool_meta_native_recovery(Request::Probe {}).await? {
|
||||||
|
Response::Probed => Ok(()),
|
||||||
|
_ => Err(invalid()),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffered(4)
|
||||||
|
.collect::<Vec<Result<()>>>()
|
||||||
|
.await;
|
||||||
|
for result in probes {
|
||||||
|
result?;
|
||||||
|
}
|
||||||
|
inspected.push(InspectedPool {
|
||||||
|
disks,
|
||||||
|
snapshots: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for pool in &mut inspected {
|
||||||
|
fence.ensure_held()?;
|
||||||
|
let results = futures::stream::iter(pool.disks.iter().cloned().map(|disk| async move {
|
||||||
|
match disk
|
||||||
|
.pool_meta_native_recovery(Request::Inspect {
|
||||||
|
object: PoolMetaNativeObject::PoolBin,
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Response::Inspected(snapshot) if !snapshot.disk_id.is_nil() => Ok(snapshot),
|
||||||
|
_ => Err(invalid()),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffered(4)
|
||||||
|
.collect::<Vec<Result<PoolMetaNativeInspection>>>()
|
||||||
|
.await;
|
||||||
|
for result in results {
|
||||||
|
pool.snapshots.push(result?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fence.ensure_held()?;
|
||||||
|
Ok(inspected)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_identities(pools: &[Arc<Sets>], canonical: &[u8], fence: &PoolMetaPersistenceFence<'_>) -> Result<()> {
|
||||||
|
for pool in pools {
|
||||||
|
fence.ensure_held()?;
|
||||||
|
// Identity and pool.bin have independent placement hashes. Read every
|
||||||
|
// member of the identity's actual set instead of assuming co-location.
|
||||||
|
let set = pool.get_disks_by_key(POOL_META_IDENTITY_NAME);
|
||||||
|
let disks = set
|
||||||
|
.disks
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|disk| disk.ok_or_else(invalid))
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
let results = futures::stream::iter(disks.into_iter().enumerate().map(|(index, disk)| async move {
|
||||||
|
match disk.pool_meta_native_recovery(Request::InspectIdentity {}).await? {
|
||||||
|
Response::IdentityInspected { disk_id, raw } if !disk_id.is_nil() => {
|
||||||
|
codec::validate_inline_pool_meta_shard(&raw, index, canonical)
|
||||||
|
.await
|
||||||
|
.map_err(|error| codec_validation(error, "native_identity_validation"))
|
||||||
|
}
|
||||||
|
_ => Err(invalid()),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffered(4)
|
||||||
|
.collect::<Vec<Result<()>>>()
|
||||||
|
.await;
|
||||||
|
for result in results {
|
||||||
|
result?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fence.ensure_held()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn plan(
|
||||||
|
inspected: &[InspectedPool],
|
||||||
|
canonical: &[u8],
|
||||||
|
revision: PoolMetaRevision,
|
||||||
|
identity: &[u8],
|
||||||
|
) -> Result<Vec<Vec<Bytes>>> {
|
||||||
|
let mut pending = Vec::<Vec<u8>>::new();
|
||||||
|
let mut predecessors = Vec::<Vec<u8>>::new();
|
||||||
|
let mut decoded = Vec::<Vec<u8>>::new();
|
||||||
|
let mut newest = OffsetDateTime::now_utc();
|
||||||
|
for pool in inspected {
|
||||||
|
let current = current(pool)?;
|
||||||
|
for (index, snapshot) in pool.snapshots.iter().enumerate() {
|
||||||
|
if let Some(local_identity) = snapshot.identity.as_ref() {
|
||||||
|
codec::validate_inline_pool_meta_shard(local_identity, index, identity)
|
||||||
|
.await
|
||||||
|
.map_err(|error| codec_validation(error, "native_metadata_validation"))?;
|
||||||
|
}
|
||||||
|
for raw in std::iter::once(current[index].clone()).chain(snapshot.backups.iter().map(|backup| backup.data.clone())) {
|
||||||
|
let info =
|
||||||
|
codec::parse_inline_info(&raw).map_err(|error| codec_validation(error, "native_metadata_validation"))?;
|
||||||
|
newest = newest.max(info.mod_time.ok_or_else(invalid)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut inventories = vec![current.clone()];
|
||||||
|
let directories = pool
|
||||||
|
.snapshots
|
||||||
|
.iter()
|
||||||
|
.flat_map(|snapshot| snapshot.backups.iter().map(|backup| backup.directory))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
for directory in directories {
|
||||||
|
// A missing backup contributes its actual current shard only to
|
||||||
|
// integrity reconstruction. It never invents backup evidence.
|
||||||
|
inventories.push(
|
||||||
|
pool.snapshots
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, snapshot)| {
|
||||||
|
snapshot
|
||||||
|
.backups
|
||||||
|
.iter()
|
||||||
|
.find(|backup| backup.directory == directory)
|
||||||
|
.map(|backup| backup.data.clone())
|
||||||
|
.unwrap_or_else(|| current[index].clone())
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for raw in inventories {
|
||||||
|
for generation in codec::decode_inline_pool_meta_replicas(&raw)
|
||||||
|
.await
|
||||||
|
.map_err(|error| codec_validation(error, "native_metadata_validation"))?
|
||||||
|
{
|
||||||
|
let payload = generation.to_vec();
|
||||||
|
if !decoded.contains(&payload) {
|
||||||
|
decoded.push(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut obsolete = Vec::<Vec<u8>>::new();
|
||||||
|
for payload in &decoded {
|
||||||
|
match decode_pool_meta_replica(payload.clone()) {
|
||||||
|
PoolMetaReplica::Valid {
|
||||||
|
canonical: candidate,
|
||||||
|
revision: candidate_revision,
|
||||||
|
committed: false,
|
||||||
|
previous: Some(previous),
|
||||||
|
..
|
||||||
|
} if candidate_revision == revision && candidate == canonical => {
|
||||||
|
if !pending.contains(payload) {
|
||||||
|
pending.push(payload.clone());
|
||||||
|
}
|
||||||
|
if !predecessors.contains(&previous.canonical) {
|
||||||
|
predecessors.push(previous.canonical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PoolMetaReplica::Valid { revision: older, .. }
|
||||||
|
if older.is_generation_protocol()
|
||||||
|
&& older.cluster_id == revision.cluster_id
|
||||||
|
&& older.epoch == revision.epoch
|
||||||
|
&& older.generation < revision.generation =>
|
||||||
|
{
|
||||||
|
// Retained evidence from an earlier successful recovery is
|
||||||
|
// obsolete, not a competing commit authority. It remains
|
||||||
|
// fully reconstructed and bound into the native CAS.
|
||||||
|
obsolete.push(payload.clone());
|
||||||
|
}
|
||||||
|
PoolMetaReplica::Valid {
|
||||||
|
canonical: candidate,
|
||||||
|
revision: candidate_revision,
|
||||||
|
committed: true,
|
||||||
|
..
|
||||||
|
} if candidate_revision == revision && candidate == canonical => {}
|
||||||
|
PoolMetaReplica::Valid { revision: conflict, .. } if conflict.is_generation_protocol() => {
|
||||||
|
return Err(known_validation(
|
||||||
|
Error::other("native V3 transaction conflicts with committed authority"),
|
||||||
|
"native_transaction_validation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_) => {
|
||||||
|
return Err(known_validation(
|
||||||
|
Error::other("native V3 payload is corrupt or incompatible"),
|
||||||
|
"native_transaction_validation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending.len() > 1 {
|
||||||
|
return Err(known_validation(
|
||||||
|
Error::other("native V3 prepare records disagree on the predecessor"),
|
||||||
|
"native_transaction_validation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if pending.is_empty() {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
for payload in &decoded {
|
||||||
|
if payload.as_slice() != canonical
|
||||||
|
&& !pending.contains(payload)
|
||||||
|
&& !predecessors.contains(payload)
|
||||||
|
&& !obsolete.contains(payload)
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pool in inspected {
|
||||||
|
for (index, snapshot) in pool.snapshots.iter().enumerate() {
|
||||||
|
let raw = snapshot.current.as_ref().ok_or_else(invalid)?;
|
||||||
|
let mut matched = false;
|
||||||
|
for payload in std::iter::once(canonical).chain(pending.iter().map(Vec::as_slice)) {
|
||||||
|
matched |= codec::validate_inline_pool_meta_shard(raw, index, payload).await.is_ok();
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
for backup in &snapshot.backups {
|
||||||
|
let mut matched = false;
|
||||||
|
for payload in std::iter::once(canonical).chain(
|
||||||
|
pending
|
||||||
|
.iter()
|
||||||
|
.chain(predecessors.iter())
|
||||||
|
.chain(obsolete.iter())
|
||||||
|
.map(Vec::as_slice),
|
||||||
|
) {
|
||||||
|
matched |= codec::validate_inline_pool_meta_shard(&backup.data, index, payload)
|
||||||
|
.await
|
||||||
|
.is_ok();
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let modified = newest.checked_add(Duration::nanoseconds(1)).ok_or_else(invalid)?;
|
||||||
|
let mut replacements = Vec::with_capacity(inspected.len());
|
||||||
|
for pool in inspected {
|
||||||
|
replacements.push(
|
||||||
|
codec::encode_inline_pool_meta_replicas(¤t(pool)?, canonical, modified)
|
||||||
|
.await
|
||||||
|
.map_err(|error| codec_validation(error, "native_metadata_validation"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(replacements)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn repair(
|
||||||
|
store: &ECStore,
|
||||||
|
state: &mut PoolMetaWriteState,
|
||||||
|
fence: &PoolMetaPersistenceFence<'_>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
fence.ensure_held()?;
|
||||||
|
let reads = read_pool_meta_replicas(store.pools.clone(), true).await;
|
||||||
|
if !reads
|
||||||
|
.iter()
|
||||||
|
.any(|read| matches!(read.replica, PoolMetaReplica::Unreadable(_)))
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
state.ensure_write_safe("native pool metadata recovery")?;
|
||||||
|
if reads
|
||||||
|
.iter()
|
||||||
|
.any(|read| matches!(read.replica, PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_)))
|
||||||
|
{
|
||||||
|
return Err(latch_validation(
|
||||||
|
state,
|
||||||
|
known_validation(Error::other("pool metadata is corrupt or incompatible"), "native_metadata_validation"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if reads
|
||||||
|
.iter()
|
||||||
|
.any(|read| !matches!(read.replica, PoolMetaReplica::Valid { .. } | PoolMetaReplica::Unreadable(_)))
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
// Only an ordinary, successfully read COMMIT grants authority. Rebuilding
|
||||||
|
// two erasure shards below proves integrity, never a commit decision.
|
||||||
|
let ordinary_commits = reads
|
||||||
|
.iter()
|
||||||
|
.filter_map(|read| match &read.replica {
|
||||||
|
PoolMetaReplica::Valid {
|
||||||
|
raw,
|
||||||
|
canonical,
|
||||||
|
revision,
|
||||||
|
committed: true,
|
||||||
|
..
|
||||||
|
} if revision.is_generation_protocol() && raw == canonical => Some((canonical.clone(), *revision)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if ordinary_commits.is_empty() {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let selection = select_pool_meta_replica_reads(reads)
|
||||||
|
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
|
||||||
|
state
|
||||||
|
.validate_selection(&selection)
|
||||||
|
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
|
||||||
|
if selection.meta.pools.len() != store.pools.len()
|
||||||
|
|| selection
|
||||||
|
.meta
|
||||||
|
.pools
|
||||||
|
.iter()
|
||||||
|
.zip(&store.pools)
|
||||||
|
.enumerate()
|
||||||
|
.any(|(index, (meta, pool))| meta.id != index || meta.cmd_line != pool.endpoints.cmd_line)
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let canonical = selection.canonical.as_ref().ok_or_else(invalid)?;
|
||||||
|
if !selection.revision.is_generation_protocol()
|
||||||
|
|| !ordinary_commits
|
||||||
|
.iter()
|
||||||
|
.any(|(raw, revision)| raw == canonical && *revision == selection.revision)
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let cluster_id = selection.revision.cluster_id.ok_or_else(invalid)?;
|
||||||
|
if state.expected_cluster_id != Some(cluster_id) {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
|
||||||
|
if identities.iter().any(|read| {
|
||||||
|
matches!(
|
||||||
|
read.replica,
|
||||||
|
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return Err(latch_validation(
|
||||||
|
state,
|
||||||
|
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if identities
|
||||||
|
.iter()
|
||||||
|
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let identity = select_pool_meta_identity(identities, cluster_id)
|
||||||
|
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
|
||||||
|
state
|
||||||
|
.validate_identity(&identity)
|
||||||
|
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
|
||||||
|
let identity = identity.identity.ok_or_else(invalid)?;
|
||||||
|
if !identity.initialized {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
if identity.epoch != selection.revision.epoch {
|
||||||
|
return Err(latch_validation(
|
||||||
|
state,
|
||||||
|
known_validation(Error::other("pool identity epoch conflicts with metadata"), "native_identity_validation"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let identity_bytes = encode_pool_meta_identity(identity)?;
|
||||||
|
let repair_started = std::time::Instant::now();
|
||||||
|
validate_identities(&store.pools, &identity_bytes, fence)
|
||||||
|
.await
|
||||||
|
.map_err(|error| latch_validation(state, error))?;
|
||||||
|
let inspected = inspect(&store.pools, fence).await?;
|
||||||
|
let replacements = plan(&inspected, canonical, selection.revision, &identity_bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|error| latch_validation(state, error))?;
|
||||||
|
fence.ensure_held()?;
|
||||||
|
let mut arm = state.arm_transaction();
|
||||||
|
arm.phase = Some("native_repair_apply");
|
||||||
|
for (pool, replacements) in inspected.iter().zip(replacements) {
|
||||||
|
for ((disk, snapshot), replacement) in pool.disks.iter().zip(&pool.snapshots).zip(replacements) {
|
||||||
|
fence.ensure_held()?;
|
||||||
|
let request = Request::Apply {
|
||||||
|
object: PoolMetaNativeObject::PoolBin,
|
||||||
|
expected_disk_id: snapshot.disk_id,
|
||||||
|
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
|
||||||
|
expected_backup_inventory_sha256: snapshot.inventory_sha256()?,
|
||||||
|
replacement,
|
||||||
|
};
|
||||||
|
match disk.pool_meta_native_recovery(request).await? {
|
||||||
|
Response::Applied => {}
|
||||||
|
_ => return Err(invalid()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Ok(pause) = AFTER_NATIVE_APPLY.try_with(Arc::clone) {
|
||||||
|
pause.reached.notify_one();
|
||||||
|
pause.resume.notified().await;
|
||||||
|
}
|
||||||
|
fence.ensure_held()?;
|
||||||
|
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
|
||||||
|
if confirmed.canonical.as_ref() != Some(canonical)
|
||||||
|
|| confirmed.revision != selection.revision
|
||||||
|
|| !confirmed.replica_state.repair_write_safe
|
||||||
|
|| confirmed.replica_state.needs_repair
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
// Re-enter the normal V3 conditional-write path, including native CAS,
|
||||||
|
// full tail completion, and the final ordinary quorum readback.
|
||||||
|
for (pool, token) in store.pools.iter().cloned().zip(&confirmed.cas_tokens) {
|
||||||
|
save_pool_meta_object_cas(pool, POOL_META_NAME, canonical.clone(), token, fence, "native_repair_confirm", &mut arm)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
|
||||||
|
if confirmed.canonical.as_ref() != Some(canonical)
|
||||||
|
|| confirmed.revision != selection.revision
|
||||||
|
|| confirmed.replica_state.needs_repair
|
||||||
|
|| !confirmed.replica_state.repair_write_safe
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
validate_identities(&store.pools, &identity_bytes, fence)
|
||||||
|
.await
|
||||||
|
.map_err(|error| latch_validation(state, error))?;
|
||||||
|
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
|
||||||
|
if identities.iter().any(|read| {
|
||||||
|
matches!(
|
||||||
|
read.replica,
|
||||||
|
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
return Err(latch_validation(
|
||||||
|
state,
|
||||||
|
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if identities
|
||||||
|
.iter()
|
||||||
|
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
|
||||||
|
{
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let identity = select_pool_meta_identity(identities, cluster_id)
|
||||||
|
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
|
||||||
|
state
|
||||||
|
.validate_identity(&identity)
|
||||||
|
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
|
||||||
|
if identity.identity.map(encode_pool_meta_identity).transpose()?.as_deref() != Some(identity_bytes.as_slice()) {
|
||||||
|
return Err(latch_validation(
|
||||||
|
state,
|
||||||
|
known_validation(Error::other("native identity changed after repair"), "native_identity_validation"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
fence.ensure_held()?;
|
||||||
|
arm.disarm();
|
||||||
|
info!(
|
||||||
|
event = EVENT_DECOMMISSION_STATE,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_POOLS,
|
||||||
|
state = "pool_metadata_native_repaired",
|
||||||
|
generation = selection.revision.generation,
|
||||||
|
pools = inspected.len(),
|
||||||
|
disks = inspected.iter().map(|pool| pool.disks.len()).sum::<usize>(),
|
||||||
|
elapsed_ms = repair_started.elapsed().as_secs_f64() * 1000.0,
|
||||||
|
"Pool metadata native replicas repaired"
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
pools: Vec<InspectedPool>,
|
||||||
|
commit: Vec<u8>,
|
||||||
|
pending: Vec<u8>,
|
||||||
|
identity: Vec<u8>,
|
||||||
|
revision: PoolMetaRevision,
|
||||||
|
modified: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fixture() -> Fixture {
|
||||||
|
let cluster_id = uuid::Uuid::new_v4();
|
||||||
|
let revision = PoolMetaRevision {
|
||||||
|
version: POOL_META_GENERATION_VERSION,
|
||||||
|
cluster_id: Some(cluster_id),
|
||||||
|
epoch: 1,
|
||||||
|
generation: 1,
|
||||||
|
transaction_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
};
|
||||||
|
let meta = PoolMeta {
|
||||||
|
version: POOL_META_GENERATION_VERSION,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let previous_meta = PoolMeta {
|
||||||
|
version: POOL_META_V1_VERSION,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let previous = PoolMetaCommittedCandidate {
|
||||||
|
canonical: previous_meta.encode_config_data_for_v2_gate(true).unwrap(),
|
||||||
|
meta: previous_meta,
|
||||||
|
revision: PoolMetaRevision::legacy(POOL_META_V1_VERSION),
|
||||||
|
};
|
||||||
|
let pending = encode_pool_meta_v3_envelope(&meta, revision, false, Some(&previous)).unwrap();
|
||||||
|
let commit = encode_pool_meta_v3_envelope(&meta, revision, true, None).unwrap();
|
||||||
|
let identity = encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
||||||
|
version: POOL_META_IDENTITY_VERSION,
|
||||||
|
cluster_id,
|
||||||
|
epoch: 1,
|
||||||
|
initialized: true,
|
||||||
|
fresh_bootstrap_nonce: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let modified = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
|
||||||
|
let current_split = split(&pending, &commit, modified).await;
|
||||||
|
let current_commit = replicas(&commit, modified + Duration::seconds(1)).await;
|
||||||
|
let backups = replicas(&pending, modified).await;
|
||||||
|
let identities = replicas(&identity, modified).await;
|
||||||
|
let directory = uuid::Uuid::new_v4();
|
||||||
|
let pools = [current_split.clone(), current_split, current_commit.clone(), current_commit]
|
||||||
|
.into_iter()
|
||||||
|
.map(|current| InspectedPool {
|
||||||
|
disks: Vec::new(),
|
||||||
|
snapshots: current
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, current)| PoolMetaNativeInspection {
|
||||||
|
disk_id: uuid::Uuid::new_v4(),
|
||||||
|
identity: Some(identities[index].clone()),
|
||||||
|
current: Some(current),
|
||||||
|
backups: vec![crate::disk::PoolMetaNativeBackup {
|
||||||
|
directory,
|
||||||
|
data: backups[index].clone(),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Fixture {
|
||||||
|
pools,
|
||||||
|
commit,
|
||||||
|
pending,
|
||||||
|
identity,
|
||||||
|
revision,
|
||||||
|
modified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pool_meta_native_plan_restores_physical_split_with_fresh_common_identity() {
|
||||||
|
let f = fixture().await;
|
||||||
|
let repaired = plan(&f.pools, &f.commit, f.revision, &f.identity).await.unwrap();
|
||||||
|
assert_eq!(repaired.len(), 4);
|
||||||
|
for pool in repaired {
|
||||||
|
assert_eq!(
|
||||||
|
codec::decode_inline_pool_meta_replicas(&pool).await.unwrap(),
|
||||||
|
vec![Bytes::copy_from_slice(&f.commit)]
|
||||||
|
);
|
||||||
|
let times = pool
|
||||||
|
.iter()
|
||||||
|
.map(|raw| codec::parse_inline_info(raw).unwrap().mod_time.unwrap())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
assert_eq!(times.len(), 1);
|
||||||
|
assert!(*times.iter().next().unwrap() > f.modified + Duration::seconds(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pool_meta_native_plan_rejects_single_hidden_identity_fork() {
|
||||||
|
let mut f = fixture().await;
|
||||||
|
let wrong = replicas(b"different identity", f.modified).await;
|
||||||
|
f.pools[0].snapshots[0].identity = Some(wrong[0].clone());
|
||||||
|
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pool_meta_native_plan_rejects_hidden_higher_generation_and_unknown_backup() {
|
||||||
|
let mut f = fixture().await;
|
||||||
|
let higher_revision = PoolMetaRevision {
|
||||||
|
generation: 2,
|
||||||
|
transaction_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..f.revision
|
||||||
|
};
|
||||||
|
let higher = encode_pool_meta_v3_envelope(
|
||||||
|
&PoolMeta {
|
||||||
|
version: POOL_META_GENERATION_VERSION,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
higher_revision,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let wrong = replicas(&higher, f.modified + Duration::seconds(2)).await;
|
||||||
|
f.pools[0].snapshots[0].current = Some(wrong[0].clone());
|
||||||
|
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||||
|
f.pools[0].snapshots[0].current = Some(replicas(&f.pending, f.modified).await[0].clone());
|
||||||
|
f.pools[0].snapshots[0].backups[0].data = wrong[0].clone();
|
||||||
|
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pool_meta_native_plan_accepts_retained_older_recovery_backups() {
|
||||||
|
let mut f = fixture().await;
|
||||||
|
let older_commit = f.commit.clone();
|
||||||
|
let older_pending = f.pending.clone();
|
||||||
|
let next = PoolMetaRevision {
|
||||||
|
generation: 2,
|
||||||
|
transaction_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..f.revision
|
||||||
|
};
|
||||||
|
let PoolMetaReplica::Valid { meta, .. } = decode_pool_meta_replica(older_commit.clone()) else {
|
||||||
|
panic!("valid fixture")
|
||||||
|
};
|
||||||
|
let previous = PoolMetaCommittedCandidate {
|
||||||
|
canonical: older_commit.clone(),
|
||||||
|
meta: meta.clone(),
|
||||||
|
revision: f.revision,
|
||||||
|
};
|
||||||
|
f.commit = encode_pool_meta_v3_envelope(&meta, next, true, None).unwrap();
|
||||||
|
f.pending = encode_pool_meta_v3_envelope(&meta, next, false, Some(&previous)).unwrap();
|
||||||
|
f.revision = next;
|
||||||
|
let current = split(&f.pending, &f.commit, f.modified + Duration::seconds(2)).await;
|
||||||
|
let old_commit = replicas(&older_commit, f.modified + Duration::seconds(1)).await;
|
||||||
|
let current_pending = replicas(&f.pending, f.modified + Duration::seconds(2)).await;
|
||||||
|
let new_directory = uuid::Uuid::new_v4();
|
||||||
|
let old_directory = uuid::Uuid::new_v4();
|
||||||
|
for pool in &mut f.pools {
|
||||||
|
for (index, snapshot) in pool.snapshots.iter_mut().enumerate() {
|
||||||
|
snapshot.current = Some(current[index].clone());
|
||||||
|
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
|
||||||
|
directory: new_directory,
|
||||||
|
data: current_pending[index].clone(),
|
||||||
|
});
|
||||||
|
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
|
||||||
|
directory: old_directory,
|
||||||
|
data: old_commit[index].clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(older_pending != f.pending);
|
||||||
|
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pool_meta_native_plan_rejects_missing_or_corrupt_evidence() {
|
||||||
|
let mut f = fixture().await;
|
||||||
|
let before = f.pools[1].snapshots[2].current.take();
|
||||||
|
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||||
|
f.pools[1].snapshots[2].current = before;
|
||||||
|
f.pools[1].snapshots[2].backups[0].data = Bytes::from_static(b"invalid metadata");
|
||||||
|
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
#[path = "pool_meta_native_integration_tests.rs"]
|
||||||
|
mod integration_tests;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user