mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-09 05:36:24 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab7d3f9a7d | |||
| c123ae2123 | |||
| 40baedba4f | |||
| 0c3fd48c22 | |||
| 3e8b0e7c83 | |||
| dd2af9ff88 | |||
| 5db0e14d04 | |||
| 9429225cf4 | |||
| 857584b3c2 | |||
| cfda9b451c | |||
| 354e49de4c | |||
| 8e987ce0a6 | |||
| 8462b3492b | |||
| 929f836e40 | |||
| c7dec044eb | |||
| ac44f8968e | |||
| 14a59f7770 | |||
| 084e9c4b82 | |||
| 46907c05cf | |||
| 921cc7ad94 | |||
| bfa8df00e7 | |||
| 33a8469e27 | |||
| 128080ceb9 | |||
| ae9fe62fb1 | |||
| 929a9f27c4 | |||
| 15376a7fa6 | |||
| 5d789006dd | |||
| f4bd53a63c | |||
| 21fee7f7ff | |||
| 23ed00fc85 | |||
| f49ffa2fec | |||
| ee5f287324 | |||
| 158c613d0e | |||
| a950f914ca | |||
| 1de9b40a30 | |||
| 831468b2e9 | |||
| 600d037b25 | |||
| 3fa2b334be | |||
| 1b549d5907 | |||
| 11c4ce96eb | |||
| 4508a0985d | |||
| ed1b9f25d6 | |||
| 274c2bf402 |
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=f0d15f2d1183be319d9d977d20b48bb05a29a424c266eed910c67aaa6f1ff955
|
||||
sha256-linux=340aa702576ebed5266b7c47e591f267a11178fa04bf3828d64f53fe92eb0907
|
||||
sha256-darwin=f0c78fdb93471575d9a64c5c46eae6c806bdd0bc10a6e33d7fb574aabd8db5a3
|
||||
sha256-linux=03ed7016cab672de9320e31375a0358eceacb4408b0e79cf063614fa7c878b87
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=12d30fff5ed48fe95bbfb310dd507048f81782b2954e00eb72435708b1133f9c
|
||||
sha256-linux=e917b2fdb303d01e6008ac4f1836c7b268698afe8383bd2aa4e697061df85cf6
|
||||
sha256-darwin=364f2329a7b72eb9f1608dbe1a3af37af4095354014f3cbe23ca448492d89961
|
||||
sha256-linux=60983f1ebe7068cf660d473c5f76c76a650410ccc99d71934ddca7fd67607987
|
||||
|
||||
@@ -188,7 +188,11 @@
|
||||
"lane": "mrf-responsibility",
|
||||
"status": "pending",
|
||||
"description": "Durable MRF responsibility at every commit boundary",
|
||||
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"]
|
||||
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"],
|
||||
"evidence_fields": [
|
||||
"mrf_responsibility_oracle",
|
||||
"commit_boundary_crash_matrix"
|
||||
]
|
||||
},
|
||||
{
|
||||
"gate": "G08",
|
||||
@@ -196,7 +200,12 @@
|
||||
"lane": "mrf-responsibility",
|
||||
"status": "pending",
|
||||
"description": "MRF capacity, disk-full and replica-loss matrix",
|
||||
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"]
|
||||
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"],
|
||||
"evidence_fields": [
|
||||
"mrf_capacity_evidence",
|
||||
"disk_full_matrix",
|
||||
"replica_loss_matrix"
|
||||
]
|
||||
},
|
||||
{
|
||||
"gate": "G09",
|
||||
@@ -225,7 +234,16 @@
|
||||
"lane": "maintenance-producers",
|
||||
"status": "pending",
|
||||
"description": "Maintenance and complete producer coverage",
|
||||
"requires": ["maintenance producer matrix", "complete producer inventory"]
|
||||
"requires": [
|
||||
"maintenance producer matrix",
|
||||
"complete producer inventory",
|
||||
"segment activation preflight"
|
||||
],
|
||||
"evidence_fields": [
|
||||
"maintenance_producer_matrix",
|
||||
"complete_producer_inventory",
|
||||
"segment_activation_preflight"
|
||||
]
|
||||
},
|
||||
{
|
||||
"gate": "G12",
|
||||
@@ -249,7 +267,20 @@
|
||||
"lane": "ec8-4-multiset",
|
||||
"status": "pending",
|
||||
"description": "Same-window field evidence with 3x4 EC8+4 and multi-set/pool coverage",
|
||||
"requires": ["same-window field evidence", "3x4 EC8+4 evidence", "multi-set evidence", "multi-pool evidence"]
|
||||
"requires": [
|
||||
"same-window field evidence",
|
||||
"3x4 EC8+4 evidence",
|
||||
"multi-set evidence",
|
||||
"multi-pool evidence",
|
||||
"distributed segment invalidation evidence"
|
||||
],
|
||||
"evidence_fields": [
|
||||
"same_window_field_evidence",
|
||||
"ec8_4_evidence",
|
||||
"multi_set_evidence",
|
||||
"multi_pool_evidence",
|
||||
"distributed_segment_invalidation_evidence"
|
||||
]
|
||||
},
|
||||
{
|
||||
"gate": "P1",
|
||||
@@ -265,7 +296,11 @@
|
||||
"lane": "scheduler-pressure",
|
||||
"status": "pending",
|
||||
"description": "Measured post-stop convergence and cold segment reuse",
|
||||
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"]
|
||||
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"],
|
||||
"evidence_fields": [
|
||||
"post_stop_convergence_measurement",
|
||||
"cold_segment_reuse_measurement"
|
||||
]
|
||||
},
|
||||
{
|
||||
"gate": "P3",
|
||||
@@ -281,7 +316,10 @@
|
||||
"lane": "mrf-responsibility",
|
||||
"status": "pending",
|
||||
"description": "Measured MRF scale and replay cost with retained responsibility",
|
||||
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence"]
|
||||
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence", "cleanup/GC soak evidence"],
|
||||
"evidence_fields": [
|
||||
"mrf_cleanup_gc_soak_evidence"
|
||||
]
|
||||
},
|
||||
{
|
||||
"gate": "R-E",
|
||||
|
||||
@@ -16,7 +16,7 @@ name: Security Audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, release ]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**/Cargo.toml'
|
||||
- '**/Cargo.lock'
|
||||
@@ -32,7 +32,7 @@ on:
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened, closed ]
|
||||
branches: [ main, release ]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '**/Cargo.toml'
|
||||
- '**/Cargo.lock'
|
||||
|
||||
@@ -1002,22 +1002,11 @@ jobs:
|
||||
# debug binary; each test spawns its own rustfs server on a random port.
|
||||
- name: Run e2e full suite
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-full-logs
|
||||
RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG: rustfs::heal=debug,rustfs_heal=debug,rustfs_ecstore::disk=debug,rustfs_lock=debug,rustfs=error
|
||||
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
||||
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
||||
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
|
||||
run: cargo nextest run --profile e2e-full -p e2e_test
|
||||
|
||||
- name: Upload e2e full diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: e2e-full-diagnostics-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-e2e-full-logs/
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload junit
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
|
||||
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
|
||||
# nightly runs, and manual dispatches all execute the same fail-closed suite.
|
||||
# Upgrade cases use an independent 1.0.0-rc.2 pin defined below.
|
||||
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
|
||||
#
|
||||
# Isolated pool filesystems: expand/decommission/rebalance cases require
|
||||
# independent `statfs` capacity. This job runs on GitHub-hosted
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
HTTP_PROXY: ""
|
||||
HTTPS_PROXY: ""
|
||||
# Independent 1.0.0-rc.2 source pin for distributed::upgrade_test.
|
||||
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
|
||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||
|
||||
@@ -129,11 +129,22 @@ jobs:
|
||||
: > target/debug/rustfs.features
|
||||
|
||||
- name: Run upgrade compatibility test
|
||||
env:
|
||||
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||
run: |
|
||||
cargo test --locked -p e2e_test \
|
||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||
-- --ignored --exact --nocapture
|
||||
|
||||
- name: Upload scanner/heal G09 evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload server logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
|
||||
@@ -82,6 +82,12 @@ jobs:
|
||||
performance-test:
|
||||
runs-on: pf-testing
|
||||
timeout-minutes: 900
|
||||
env:
|
||||
RUSTFS_BENCH_SCRIPT: ${{ github.workspace }}/auto-testing/rustfs_performance_testing.sh
|
||||
RUSTFS_WARP_METHODS: ${{ inputs.test_method }}
|
||||
RUSTFS_WARP_SIZES: ${{ inputs.object_size }}
|
||||
RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}
|
||||
RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
|
||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||
# Skipped when nightly failed.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
@@ -158,19 +164,15 @@ jobs:
|
||||
- name: Run benchmark (GET/PUT/MIXED)
|
||||
id: benchmark
|
||||
run: |
|
||||
# Empty on automatic (workflow_run) runs -> full 30 rounds.
|
||||
# Manual dispatch can restrict method(s)/size(s).
|
||||
export WARP_METHODS="${{ inputs.test_method }}"
|
||||
export WARP_SIZES="${{ inputs.object_size }}"
|
||||
./auto-testing/rustfs_performance_test.sh \
|
||||
--step 5 -y \
|
||||
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
||||
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
||||
--log-file "${LOG_FILE}"
|
||||
|
||||
- name: Analyze results
|
||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||
run: |
|
||||
export WARP_METHODS="${RUSTFS_WARP_METHODS}" WARP_SIZES="${RUSTFS_WARP_SIZES}"
|
||||
export WARP_DURATION="${RUSTFS_WARP_DURATION}" WARP_CONCURRENCY="${RUSTFS_WARP_CONCURRENCY}"
|
||||
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||
|
||||
- name: Collect RustFS version info
|
||||
|
||||
@@ -52,6 +52,7 @@ docs
|
||||
__pycache__/
|
||||
!docs/
|
||||
docs/*
|
||||
!docs/README.md
|
||||
!docs/architecture/
|
||||
!docs/architecture/**
|
||||
!docs/operations/
|
||||
|
||||
@@ -7,12 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Replication
|
||||
|
||||
- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
|
||||
- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
|
||||
- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
|
||||
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
|
||||
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
|
||||
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
|
||||
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
|
||||
|
||||
### Security
|
||||
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||
|
||||
### Fixed
|
||||
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
|
||||
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
|
||||
- **KMS failures on the S3 data path carry an actionable status**: only "key not found" and a backend outage were classified; every other KMS failure — a disabled or pending-deletion key, a denied KMS grant, an encryption-context mismatch, an unsupported algorithm, a credential or timeout failure, a capability the backend does not have — collapsed onto `500 InternalError`. SDKs therefore applied exponential backoff to configuration errors that no retry can fix, and monitoring filed every one of them as a server fault. Unusable-key and request-side failures now return `400`, a denied grant `403`, transient backend failures `503` — including a key store the backend could not read, so an outage stays distinguishable from a missing key all the way to the client — and a missing backend capability `501`. Damaged or unreadable key material still returns `500`, which is what it is.
|
||||
- **SSE-C on buckets with default encryption**: a `PutObject` carrying a valid SSE-C header triple on a bucket that has default encryption configured no longer fails with `400 InvalidArgument` ("The SSE-C and managed server-side encryption headers cannot be used together"). PUT and the POST-object/extract path resolved the bucket default with a hard-coded "no explicit SSE-C" flag, so the default was layered onto the request and then tripped the request's own mutual-exclusion check; an SSE-C request now suppresses the bucket default on all three write paths, matching COPY and AWS S3. Every bucket with default encryption previously refused SSE-C single PUTs outright, while `CreateMultipartUpload` on the same bucket succeeded.
|
||||
- **Explicit SSE-S3 on SSE-KMS-default buckets**: `x-amz-server-side-encryption: AES256` against a bucket whose default is `aws:kms` no longer fails with `400 InvalidArgument`. The bucket default's KMS key id was inherited independently of the effective algorithm, producing a self-contradictory `AES256` + key-id pair; the key id is now inherited only when the effective algorithm is `aws:kms`. `PutBucketEncryption` fills in a default key id automatically, so this affected nearly every SSE-KMS-default bucket.
|
||||
- **Restore of encrypted or compressed multipart objects (silent data corruption)**: restoring a multipart object from a remote tier addressed the tier in *plaintext* coordinates while the copy-back reads the *stored* representation. Every part received a misaligned slice of the remote object whose length still satisfied the range, the hash reader and the completion size check, so the restore reported success and replaced the object's bytes. Restore now accumulates stored part sizes, passes the stored length to the hash reader alongside the plaintext length, and validates against the stored size. Objects restored by an affected release must be re-restored from the tier or recovered from a backup — this release does not detect or repair them retroactively.
|
||||
- **Restore no longer drifts the object ETag**: the copy-back digests stored (encrypted or compressed) bytes, so the recomputed MD5 is not the object's public ETag. Single-part and multipart restores now preserve the original object ETag, and each restored part keeps its own recorded part ETag.
|
||||
- **ILM archive no longer forwards encryption metadata to the tier**: transition requests carried the object's SSE headers and the RustFS-wrapped data key as request headers. Any S3 target rejected an SSE-C archive outright (`400`, no key supplied), an SSE-KMS archive asked the target to encrypt a second time under a key id it does not own, and the wrapped DEK left the cluster. The archive request now strips every SSE header and encryption marker using the same predicate the replication path uses; the local `xl.meta` keeps all of it, so read-through and restore are unaffected.
|
||||
- **KMS reload is no longer a no-op on a node whose KMS failed to start**: `POST /rustfs/admin/v3/kms/reload` short-circuited whenever the persisted configuration matched the in-memory one byte for byte. A node whose KMS failed to start (for example Vault briefly unreachable during a rolling restart) keeps that configuration and sits in `Error`, so the documented recovery call returned "reloaded successfully" while leaving the node down — and did the same on every peer through the reload broadcast. Reload now short-circuits only for a service that is actually running, and otherwise reconfigures, which starts the service.
|
||||
- **AWS KMS capability reporting**: the AWS backend no longer advertises `versioning` support through `GET /rustfs/admin/v3/kms/status`. AWS KMS key versions are not enumerable through this backend, as the backend documentation already stated.
|
||||
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
||||
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||
@@ -54,6 +72,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
|
||||
|
||||
### Changed
|
||||
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
|
||||
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
|
||||
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
|
||||
- **Per-key SSE-KMS authorization** (`RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY`, default `false`). With it on, anonymous callers hold no KMS grants, so **a public bucket serving SSE-KMS objects is an incompatible combination** and those reads return `AccessDenied`.
|
||||
- Envelope context binding as KMS AAD (`ENV_KMS_ENVELOPE_AAD`, off by default; a node that predates the field cannot open bound envelopes).
|
||||
- Vault custom CA and mutual TLS; object-level DEK rewrap plus a batch rekey admin API; a backend-locality runtime signal on `kms/status`.
|
||||
- Single-pass decryption for encrypted GET, and encrypted single-part closed-range seek — the latter is now **on by default** (`RUSTFS_ENCRYPTED_RANGE_SEEK`, default `true`; the switch remains as a kill switch).
|
||||
- **Vault static tokens are now tracked and renewed**: with `Token` authentication RustFS hard-coded "this token has no lease", so the renewal task never started and no remaining-TTL gauge was published. `vault token create` grants a 768-hour TTL by default, which turned a healthy-looking cluster into one where every KMS call returned 403 about a month later, with no self-healing short of a restart or reconfigure. RustFS now calls `auth/token/lookup-self` at login and adopts what Vault reports: a non-expiring token behaves exactly as before, an expiring renewable one is renewed at half TTL like the other auth methods, and an expiring non-renewable one logs `vault_static_token_not_renewable` and publishes its remaining TTL. The probe never fails the login: a token whose policy omits `lookup-self` (Vault's `default` policy grants it), or a Vault that is unreachable at that moment, logs `vault_static_token_lookup_failed` and falls back to the previous no-lease behaviour, so no deployment that works today stops working.
|
||||
- **SSE-C over a plaintext transport is reported**: AWS S3 and MinIO refuse an SSE-C request that did not arrive over TLS, because the customer key travels in a request header. RustFS accepted them on any transport and still does by default — flipping to a rejection inside a release window would break plaintext staging and test deployments. Each such request now increments `rustfs_ssec_plaintext_requests_total` and logs one `ssec_request_without_tls` warning per process, and `RUSTFS_SSE_C_REQUIRE_TLS=true` opts into the AWS `400` now. The default is expected to flip in a later release; confirm the counter reads zero first. The verdict is per connection: a TLS listener satisfies it, and so does an `https` protocol forwarded by a proxy the trusted-proxy configuration accepts.
|
||||
- **Local KMS backend on a distributed deployment says what actually breaks**: the backend keeps key material and its Argon2id salt on each node's own disk, so two nodes derive different keys from the same `master_key` and an object encrypted on one node cannot be decrypted on another — intermittent 500s behind a load balancer. Configuring it while the deployment is distributed now logs `kms_node_local_backend_in_distributed_deployment` and appends that consequence to the `kms/configure` response, instead of only the generic "development only" positioning warning. It remains a warning, not a gate.
|
||||
- **SSE-KMS is refused when no KMS is running (breaking)**: a write requesting `x-amz-server-side-encryption: aws:kms` on a node with no KMS service no longer succeeds. Earlier releases wrapped the data key with the node-local `RUSTFS_SSE_S3_MASTER_KEY` while still writing `aws:kms` and the requested key id into the object metadata — metadata that claimed a KMS protection the object never had, under a key that was never consulted. Such a request now returns `400 InvalidRequest` when KMS was never configured and `503` when a configured service is not running; the refusal is evaluated after the per-key authorization gate, so an unauthorized caller still receives `403 AccessDenied`. **Upgrade note:** a deployment that relied on this write succeeding will start receiving 4xx/503. Either configure a KMS, or request `AES256` and keep the documented SSE-S3 local-master-key fallback, which is unchanged. Objects already written this way remain readable.
|
||||
- **Legacy ciphertext nonce layouts are now locked per segment**: while decrypting a v1 segment, the reader locks onto whichever of the three historical nonce layouts decoded the segment's first non-zero-index frame and rejects any later frame that needs a different one. Because a frame encrypted at block index zero authenticates under the pre-`1.0.0-alpha.91` reused-part-nonce layout at any position, an attacker able to rewrite the underlying shards could previously replay it and have the forged plaintext returned with `200`. New `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK` (default `true`) drops that third layout entirely when set to `false`, which closes the residual case of a stream built purely from repeats of frame zero. Turn it off only after migrating pre-alpha.91 encrypted objects (rewrite in place with CopyObject); see [KMS backend security properties](docs/operations/kms-backend-security.md) for what the v1 frame layout does and does not authenticate.
|
||||
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
|
||||
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
|
||||
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
|
||||
|
||||
Generated
+1
@@ -10578,6 +10578,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"temp-env",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
|
||||
+25
-8
@@ -46,14 +46,31 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
|
||||
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
|
||||
- **简单易用**:设计简洁,易于部署和管理。
|
||||
|
||||
| 功能 | 状态 | 功能 | 状态 |
|
||||
| :----------------- | :------ | :---------------------- | :-------- |
|
||||
| **S3 核心功能** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
||||
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
||||
| **版本控制** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
|
||||
| **日志功能** | ✅ 可用 | **生命周期管理** | 🚧 测试中 |
|
||||
| **事件通知** | ✅ 可用 | **分布式模式** | 🚧 测试中 |
|
||||
| **K8s Helm Chart** | ✅ 可用 | **OPA (策略引擎)** | 🚧 测试中 |
|
||||
状态说明:✅ 可用 —— 已发布并有 CI 门禁覆盖;🧪 预览 —— 已发布但需显式开关,或兼容性承诺有边界。
|
||||
|
||||
| 功能 | 状态 | 功能 | 状态 |
|
||||
| :-------------------------- | :------ | :----------------------- | :------ |
|
||||
| **S3 核心功能** | ✅ 可用 | **分布式模式** | ✅ 可用 |
|
||||
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
||||
| **版本控制** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
||||
| **对象锁定 (WORM)** | ✅ 可用 | **修复与扫描器** | ✅ 可用 |
|
||||
| **服务端加密 (SSE)** | ✅ 可用 | **存储池扩容 / 下线** | ✅ 可用 |
|
||||
| **RustFS KMS** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
|
||||
| **生命周期管理 (ILM)** | ✅ 可用 | **站点复制** | ✅ 可用 |
|
||||
| **ILM 分层 (远端 S3)** | ✅ 可用 | **存储桶配额** | ✅ 可用 |
|
||||
| **S3 Select** | ✅ 可用 | **事件通知** | ✅ 可用 |
|
||||
| **S3 Tables (Iceberg REST)**| 🧪 预览 | **审计日志** | ✅ 可用 |
|
||||
| **IAM / 策略** | ✅ 可用 | **日志与可观测性** | ✅ 可用 |
|
||||
| **OIDC / SSO** | ✅ 可用 | **Web 控制台** | ✅ 可用 |
|
||||
| **Keystone 认证** | ✅ 可用 | **K8s Helm Chart** | ✅ 可用 |
|
||||
| **Swift API** | ✅ 可用 | **FTPS / WebDAV** | ✅ 可用 |
|
||||
| **多租户** | ✅ 可用 | **SFTP** | ✅ 可用 |
|
||||
| **MinIO 磁盘格式兼容** | 🧪 预览 | | |
|
||||
|
||||
说明:
|
||||
|
||||
- **服务端加密**:支持 SSE-C、SSE-S3 与 SSE-KMS。SSE-KMS 必须先配置 KMS 服务;未配置 KMS 时请求 `aws:kms` 会被拒绝,不会降级到本地主密钥。
|
||||
- **RustFS KMS**:生产环境支持 Vault(KV2 / Transit)与 AWS KMS 后端;`Local` 与 `Static` 后端仅供开发与测试使用,详见 [KMS 后端安全属性](docs/operations/kms-backend-security.md)。
|
||||
|
||||
## RustFS vs MinIO 性能对比
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ fn main() {
|
||||
}
|
||||
}
|
||||
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
|
||||
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=normal"]).is_none_or(|status| !status.is_empty());
|
||||
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=no"]).is_none_or(|status| !status.is_empty());
|
||||
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
|
||||
let mut features = std::env::vars()
|
||||
.filter_map(|(key, _)| {
|
||||
|
||||
@@ -44,7 +44,6 @@ use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::error::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::time::{Duration, Instant, sleep};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
@@ -375,33 +374,6 @@ pub(crate) fn census_object_version_on_disk(
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait for the background PUT tail to commit every physical part on one disk.
|
||||
/// Invalid metadata remains an immediate error instead of a retryable absence.
|
||||
pub(crate) async fn wait_for_complete_physical_shard_on_disk(
|
||||
disk: &Path,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: Option<&str>,
|
||||
timeout: Duration,
|
||||
) -> ChaosResult<VersionShardCensus> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let census = census_object_version_on_disk(disk, bucket, key, version_id)?;
|
||||
if census.is_complete() && !census.expected_part_numbers.is_empty() {
|
||||
return Ok(census);
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(format!(
|
||||
"physical shard for {bucket}/{key}@{version_id:?} on {} did not become complete within {timeout:?}: {census:?}",
|
||||
disk.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(remaining.min(Duration::from_millis(50))).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST` a signed (SigV4, service `s3`) admin request without relying on the
|
||||
/// external `awscurl` binary. Mirrors the admin heal calls used by the heal
|
||||
/// regression suite.
|
||||
@@ -479,30 +451,4 @@ mod tests {
|
||||
assert!(expected.matches_manifest(&expected));
|
||||
assert!(!changed.matches_manifest(&expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn physical_shard_readiness_fails_closed_with_last_census() {
|
||||
let disk = tempfile::tempdir().expect("temporary disk");
|
||||
let error = wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "missing", None, Duration::ZERO)
|
||||
.await
|
||||
.expect_err("missing physical shards must fail the baseline gate");
|
||||
assert!(error.to_string().contains("has_xl_meta: false"));
|
||||
assert!(error.to_string().contains("bucket/missing"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn physical_shard_readiness_does_not_retry_invalid_metadata() {
|
||||
let disk = tempfile::tempdir().expect("temporary disk");
|
||||
let object = disk.path().join("bucket").join("corrupt");
|
||||
std::fs::create_dir_all(&object).expect("object directory");
|
||||
std::fs::write(object.join("xl.meta"), b"invalid metadata").expect("corrupt metadata fixture");
|
||||
let error = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
wait_for_complete_physical_shard_on_disk(disk.path(), "bucket", "corrupt", None, Duration::from_secs(30)),
|
||||
)
|
||||
.await
|
||||
.expect("corrupt metadata must fail immediately")
|
||||
.expect_err("invalid metadata must not be accepted as a complete baseline");
|
||||
assert!(!error.to_string().contains("did not become complete"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ use serde_json;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs as stdfs;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::{Ipv6Addr, SocketAddr};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Once;
|
||||
@@ -188,6 +188,15 @@ fn write_next_test_port(port: u16) -> Result<(), Box<dyn std::error::Error + Sen
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn no_available_test_port_error(config: TestPortAllocatorConfig, attempts: u16, last_error: Option<&std::io::Error>) -> String {
|
||||
let max_inclusive = config.max_exclusive() - 1;
|
||||
let detail = last_error.map(|err| format!("; last bind error: {err}")).unwrap_or_default();
|
||||
format!(
|
||||
"no available E2E test port found in {}..={} after {} attempts{}",
|
||||
config.min, max_inclusive, attempts, detail
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn capture_command_logs(
|
||||
command: &mut Command,
|
||||
log_path: Option<&str>,
|
||||
@@ -694,19 +703,23 @@ impl RustFSTestEnvironment {
|
||||
let _guard = PortAllocatorGuard::acquire().await?;
|
||||
let config = test_port_allocator_config()?;
|
||||
let mut next_port = read_next_test_port(config);
|
||||
let mut last_error = None;
|
||||
|
||||
for _ in 0..config.range {
|
||||
let port = next_port;
|
||||
next_port = advance_test_port(next_port, config);
|
||||
write_next_test_port(next_port)?;
|
||||
|
||||
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
|
||||
drop(listener);
|
||||
return Ok(port);
|
||||
match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(listener) => {
|
||||
drop(listener);
|
||||
return Ok(port);
|
||||
}
|
||||
Err(err) => last_error = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err("no available E2E test port found".into())
|
||||
Err(no_available_test_port_error(config, config.range, last_error.as_ref()).into())
|
||||
}
|
||||
|
||||
/// Kill any existing RustFS processes
|
||||
@@ -1468,9 +1481,7 @@ impl RustFSTestClusterEnvironment {
|
||||
return Err(format!("a volume proxy is already configured for node {node_idx}").into());
|
||||
}
|
||||
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
|
||||
// Endpoint locality requires the server port to match. IPv6 loopback
|
||||
// keeps that port while leaving the direct IPv4 S3 listener available.
|
||||
let proxy = crate::fault_proxy::FaultProxy::start_on((Ipv6Addr::LOCALHOST, target.port()).into(), target).await?;
|
||||
let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
|
||||
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
|
||||
Ok(proxy)
|
||||
}
|
||||
@@ -2203,6 +2214,19 @@ mod tests {
|
||||
assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_port_allocator_reports_attempt_window_and_last_bind_error() {
|
||||
let config = TestPortAllocatorConfig { min: 41000, range: 3 };
|
||||
let error = std::io::Error::from(ErrorKind::PermissionDenied);
|
||||
|
||||
let message = no_available_test_port_error(config, config.range, Some(&error));
|
||||
|
||||
assert!(message.contains("41000..=41002"));
|
||||
assert!(message.contains("after 3 attempts"));
|
||||
assert!(message.contains("last bind error"));
|
||||
assert!(message.contains("permission denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
|
||||
let workspace = Path::new("workspace");
|
||||
@@ -2375,7 +2399,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn volume_proxy_rewrites_cluster_volume_endpoint() {
|
||||
let mut env = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2))
|
||||
let mut env = RustFSTestClusterEnvironment::new(1)
|
||||
.await
|
||||
.expect("cluster environment should allocate a node");
|
||||
let direct = env.nodes[0].address.clone();
|
||||
@@ -2390,24 +2414,6 @@ mod tests {
|
||||
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
|
||||
|
||||
proxy.shutdown().await;
|
||||
|
||||
for node in &env.nodes {
|
||||
let local_port = node.address.parse::<SocketAddr>().expect("node address").port();
|
||||
let local_paths = volumes
|
||||
.split_whitespace()
|
||||
.filter_map(|volume| {
|
||||
let endpoint = reqwest::Url::parse(volume).expect("volume endpoint");
|
||||
rustfs_utils::is_local_host(
|
||||
endpoint.host().expect("volume endpoint host"),
|
||||
endpoint.port().expect("volume endpoint port"),
|
||||
local_port,
|
||||
)
|
||||
.expect("endpoint locality")
|
||||
.then(|| endpoint.path().to_string())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(local_paths, node.data_dirs, "the proxy must preserve local disk ownership");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,15 +12,18 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, assert_inventory, payload_for, put_object, unique_bucket, wait_until};
|
||||
use crate::chaos::{
|
||||
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post, wait_for_complete_physical_shard_on_disk,
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, assert_inventory, get_object_bytes, payload_for, put_object, sha256_hex, unique_bucket,
|
||||
wait_until,
|
||||
};
|
||||
use crate::common::{init_logging, rustfs_binary_path};
|
||||
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
|
||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
|
||||
use crate::common::init_logging;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -28,6 +31,8 @@ const EC84_NODE_COUNT: usize = 3;
|
||||
const EC84_DRIVES_PER_NODE: usize = 4;
|
||||
const EC84_DATA_BLOCKS: usize = 8;
|
||||
const EC84_PARITY_BLOCKS: usize = 4;
|
||||
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
|
||||
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExpectedShard {
|
||||
@@ -36,6 +41,91 @@ struct ExpectedShard {
|
||||
baseline: VersionShardCensus,
|
||||
}
|
||||
|
||||
struct ScannerHealEvidenceContext {
|
||||
directory: PathBuf,
|
||||
run: Value,
|
||||
}
|
||||
|
||||
struct ScannerHealEvidencePayload<'a> {
|
||||
dist: &'a DistCluster,
|
||||
bucket: &'a str,
|
||||
expected: &'a [ExpectedShard],
|
||||
outage_key: &'a str,
|
||||
outage_body: &'a [u8],
|
||||
replaced_drive: &'a Path,
|
||||
pid_before: u32,
|
||||
pid_after: u32,
|
||||
node_listings: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
fn file_sha256(path: &Path) -> TestResult<String> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..read]);
|
||||
}
|
||||
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
fn compiled_test_identity() -> Value {
|
||||
serde_json::json!({
|
||||
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
||||
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
|
||||
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
|
||||
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
|
||||
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
|
||||
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
|
||||
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
|
||||
})
|
||||
}
|
||||
|
||||
fn string_field<'a>(value: &'a Value, path: &str) -> TestResult<&'a str> {
|
||||
let mut current = value;
|
||||
for segment in path.split('.') {
|
||||
current = current
|
||||
.get(segment)
|
||||
.ok_or_else(|| format!("scanner/heal run receipt missing {path}"))?;
|
||||
}
|
||||
current
|
||||
.as_str()
|
||||
.filter(|text| !text.is_empty())
|
||||
.ok_or_else(|| format!("scanner/heal run receipt has invalid {path}").into())
|
||||
}
|
||||
|
||||
fn scanner_heal_evidence_context() -> TestResult<Option<ScannerHealEvidenceContext>> {
|
||||
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let directory = PathBuf::from(directory);
|
||||
let receipt = directory.join("run.json");
|
||||
if receipt.metadata()?.len() > 1024 * 1024 {
|
||||
return Err("oversized scanner/heal execution receipt".into());
|
||||
}
|
||||
let run: Value = serde_json::from_slice(&std::fs::read(receipt)?)?;
|
||||
let built = compiled_test_identity();
|
||||
for key in ["source_revision", "dirty", "lock_blob", "features"] {
|
||||
if built[key] != run["test_build"][key] {
|
||||
return Err(format!("compiled test identity differs for {key}").into());
|
||||
}
|
||||
}
|
||||
let binary_path = PathBuf::from(string_field(&run, "binary.path")?);
|
||||
if file_sha256(&binary_path)? != string_field(&run, "binary.sha256")? {
|
||||
return Err("server binary must match the run receipt".into());
|
||||
}
|
||||
if file_sha256(&std::env::current_exe()?)? != string_field(&run, "test_binary.sha256")? {
|
||||
return Err("test executable must match the run receipt".into());
|
||||
}
|
||||
if directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE).exists() {
|
||||
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
||||
}
|
||||
Ok(Some(ScannerHealEvidenceContext { directory, run }))
|
||||
}
|
||||
|
||||
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
|
||||
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
|
||||
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
|
||||
@@ -52,6 +142,65 @@ fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_scanner_heal_evidence(context: ScannerHealEvidenceContext, payload: ScannerHealEvidencePayload<'_>) -> TestResult {
|
||||
let verifier = payload.dist.client(0)?;
|
||||
let mut objects = Vec::new();
|
||||
for item in payload.expected {
|
||||
let actual = get_object_bytes(&verifier, payload.bucket, &item.key).await?;
|
||||
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, &item.key, None)?;
|
||||
objects.push(serde_json::json!({
|
||||
"key": item.key,
|
||||
"version_id": null,
|
||||
"expected_bytes": item.body.len(),
|
||||
"actual_bytes": actual.len(),
|
||||
"expected_sha256": sha256_hex(&item.body),
|
||||
"actual_sha256": sha256_hex(&actual),
|
||||
"expected_physical": item.baseline,
|
||||
"physical": physical,
|
||||
}));
|
||||
}
|
||||
let actual = get_object_bytes(&verifier, payload.bucket, payload.outage_key).await?;
|
||||
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, payload.outage_key, None)?;
|
||||
objects.push(serde_json::json!({
|
||||
"key": payload.outage_key,
|
||||
"version_id": null,
|
||||
"expected_bytes": payload.outage_body.len(),
|
||||
"actual_bytes": actual.len(),
|
||||
"expected_sha256": sha256_hex(payload.outage_body),
|
||||
"actual_sha256": sha256_hex(&actual),
|
||||
"expected_physical": null,
|
||||
"physical": physical,
|
||||
}));
|
||||
|
||||
let evidence = serde_json::json!({
|
||||
"schema": 1,
|
||||
"case": EC84_TARGET_DRIVE_RESTART_CASE,
|
||||
"evidence": "process-restart",
|
||||
"run_id": string_field(&context.run, "run_id")?,
|
||||
"source_revision": string_field(&context.run, "source_revision")?,
|
||||
"test_build": compiled_test_identity(),
|
||||
"binary_sha256": string_field(&context.run, "binary.sha256")?,
|
||||
"test_binary_sha256": string_field(&context.run, "test_binary.sha256")?,
|
||||
"topology": {"nodes": EC84_NODE_COUNT, "drives_per_node": EC84_DRIVES_PER_NODE},
|
||||
"pid_before": payload.pid_before,
|
||||
"pid_after": payload.pid_after,
|
||||
"unclean_shutdown_marker": false,
|
||||
"objects": objects,
|
||||
"node_listings": payload.node_listings,
|
||||
});
|
||||
let data = serde_json::to_vec(&evidence)?;
|
||||
if data.len() > 1024 * 1024 {
|
||||
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
|
||||
}
|
||||
let mut output = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(context.directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE))?;
|
||||
output.write_all(&data)?;
|
||||
output.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
|
||||
for key in keys {
|
||||
let census = census_object_version_on_disk(drive, bucket, key, None)?;
|
||||
@@ -90,19 +239,7 @@ async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<Ex
|
||||
#[tokio::test]
|
||||
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
|
||||
init_logging();
|
||||
let server_binary = rustfs_binary_path();
|
||||
let evidence_run = restart_evidence_run(
|
||||
&server_binary,
|
||||
ScannerHealEvidenceCase {
|
||||
id: "ec84-target-drive-restart",
|
||||
oracle: "ec84-target-drive-restart.json",
|
||||
evidence: "process-restart",
|
||||
unclean_shutdown_marker: false,
|
||||
topology: EvidenceTopology::new(3, 4),
|
||||
storage_class_standard: Some("EC:4"),
|
||||
erasure_set_drive_count: Some("12"),
|
||||
},
|
||||
)?;
|
||||
let evidence_context = scanner_heal_evidence_context()?;
|
||||
let mut dist = DistCluster::start_with_env(
|
||||
DistLayout::ThreeByFourEc84,
|
||||
&[
|
||||
@@ -126,24 +263,18 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
|
||||
let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]);
|
||||
|
||||
for item in &mut expected {
|
||||
item.baseline =
|
||||
wait_for_complete_physical_shard_on_disk(&replaced_drive, &bucket, &item.key, None, Duration::from_secs(10)).await?;
|
||||
item.baseline = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
|
||||
assert_ec84_geometry(&item.baseline, &item.key)?;
|
||||
}
|
||||
|
||||
let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
|
||||
let format_json = std::fs::read(&format_path)?;
|
||||
let pid_before = dist.cluster.nodes[replaced_node]
|
||||
let target_pid_before = dist.cluster.nodes[replaced_node]
|
||||
.process
|
||||
.as_ref()
|
||||
.ok_or("target process is absent")?
|
||||
.ok_or("target process is absent before graceful restart")?
|
||||
.id();
|
||||
dist.cluster.stop_node_gracefully(replaced_node).await?;
|
||||
let unclean_shutdown_marker = Path::new(&dist.cluster.nodes[replaced_node].data_dir)
|
||||
.join(".rustfs.sys")
|
||||
.join("unclean-shutdown")
|
||||
.is_file();
|
||||
assert!(!unclean_shutdown_marker, "graceful target shutdown must remove its unclean marker");
|
||||
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
|
||||
std::fs::rename(&replaced_drive, &retired_drive)?;
|
||||
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path has no parent")?)?;
|
||||
@@ -165,6 +296,11 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
|
||||
.await?;
|
||||
|
||||
dist.cluster.start_node(replaced_node).await?;
|
||||
let target_pid_after = dist.cluster.nodes[replaced_node]
|
||||
.process
|
||||
.as_ref()
|
||||
.ok_or("target process is absent after restart")?
|
||||
.id();
|
||||
let heal_body =
|
||||
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url);
|
||||
@@ -205,45 +341,26 @@ async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_res
|
||||
.filter_map(|object| object.key().map(str::to_owned))
|
||||
.collect::<HashSet<_>>();
|
||||
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
|
||||
let mut keys = observed.into_iter().collect::<Vec<_>>();
|
||||
keys.sort();
|
||||
node_listings.push(keys);
|
||||
let mut observed = observed.into_iter().collect::<Vec<_>>();
|
||||
observed.sort();
|
||||
node_listings.push(observed);
|
||||
}
|
||||
|
||||
if let Some(evidence_run) = evidence_run {
|
||||
let target_client = dist.client(replaced_node)?;
|
||||
let mut objects = Vec::with_capacity(inventory.len());
|
||||
for (key, body) in &inventory {
|
||||
let response = target_client.get_object().bucket(&bucket).key(key).send().await?;
|
||||
let actual = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(actual.as_ref(), body.as_slice(), "object body changed for {key}");
|
||||
let physical = census_object_version_on_disk(&replaced_drive, &bucket, key, None)?;
|
||||
assert_ec84_geometry(&physical, key)?;
|
||||
let baseline = expected.iter().find(|item| item.key == *key).map(|item| &item.baseline);
|
||||
objects.push(serde_json::json!({
|
||||
"key": key, "version_id": null,
|
||||
"expected_bytes": body.len(), "actual_bytes": actual.len(),
|
||||
"expected_sha256": sha256_hex(body), "actual_sha256": sha256_hex(&actual),
|
||||
"expected_physical": baseline, "physical": physical,
|
||||
}));
|
||||
}
|
||||
let pid_after = dist.cluster.nodes[replaced_node]
|
||||
.process
|
||||
.as_ref()
|
||||
.ok_or("restarted target is absent")?
|
||||
.id();
|
||||
evidence_run.write(
|
||||
&server_binary,
|
||||
RestartObservation {
|
||||
nodes: dist.cluster.nodes.len(),
|
||||
drives_per_node: dist.cluster.topology.drives_per_node,
|
||||
pid_before,
|
||||
pid_after,
|
||||
unclean_shutdown_marker,
|
||||
objects,
|
||||
if let Some(context) = evidence_context {
|
||||
write_scanner_heal_evidence(
|
||||
context,
|
||||
ScannerHealEvidencePayload {
|
||||
dist: &dist,
|
||||
bucket: &bucket,
|
||||
expected: &expected,
|
||||
outage_key,
|
||||
outage_body: &outage_body,
|
||||
replaced_drive: &replaced_drive,
|
||||
pid_before: target_pid_before,
|
||||
pid_after: target_pid_after,
|
||||
node_listings,
|
||||
},
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -498,9 +498,7 @@ mod tests {
|
||||
.stderr(log)
|
||||
.spawn()?,
|
||||
);
|
||||
// macOS evaluates each fresh binary copy before its capability hook can run.
|
||||
let probe_timeout = if cfg!(target_os = "macos") { 60 } else { 10 };
|
||||
let status = tokio::time::timeout(Duration::from_secs(probe_timeout), async {
|
||||
let status = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Some(status) = child.0.try_wait()? {
|
||||
return Ok::<_, std::io::Error>(status);
|
||||
|
||||
@@ -114,12 +114,7 @@ impl FaultProxy {
|
||||
/// Bind a listener on `127.0.0.1:0` and start forwarding accepted
|
||||
/// connections to `target`. Starts in [`FaultMode::Pass`].
|
||||
pub async fn start(target: SocketAddr) -> io::Result<Self> {
|
||||
Self::start_on((Ipv4Addr::LOCALHOST, 0).into(), target).await
|
||||
}
|
||||
|
||||
/// Bind the selected address and forward accepted connections to `target`.
|
||||
pub(crate) async fn start_on(address: SocketAddr, target: SocketAddr) -> io::Result<Self> {
|
||||
let listener = TcpListener::bind(address).await?;
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let listen_addr = listener.local_addr()?;
|
||||
|
||||
let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass);
|
||||
|
||||
@@ -401,11 +401,11 @@ mod tests {
|
||||
let mp_view = get_full(&client, multipart_key).await?;
|
||||
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded baseline multipart body mismatch");
|
||||
baseline_degraded.insert(multipart_key.to_string(), mp_view);
|
||||
// Stop disk writers before restoring the complete layout reused by Phase B.
|
||||
harness.kill_server();
|
||||
// Restore the disk so Phase B restarts from a clean, complete disk set.
|
||||
harness.bring_disk_online(0)?;
|
||||
|
||||
// ---- Phase B: codec streaming (gates opened) ----
|
||||
harness.kill_server();
|
||||
for (k, v) in codec_env() {
|
||||
harness.set_env(k, v);
|
||||
}
|
||||
@@ -497,8 +497,6 @@ mod tests {
|
||||
let mp_view = get_full(&client, multipart_key).await?;
|
||||
assert_eq!(mp_view.sha256, sha256_hex(&multipart_body), "degraded codec multipart body mismatch");
|
||||
codec_degraded.insert(multipart_key.to_string(), mp_view);
|
||||
// All server reads are complete; stop disk writers before restoring disk0.
|
||||
harness.kill_server();
|
||||
harness.bring_disk_online(0)?;
|
||||
|
||||
// A/B under parity reconstruction: codec == legacy, byte-for-byte and
|
||||
|
||||
@@ -16,20 +16,18 @@
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::chaos::{
|
||||
VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post,
|
||||
wait_for_complete_physical_shard_on_disk,
|
||||
};
|
||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
|
||||
use crate::common::{
|
||||
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
|
||||
rustfs_binary_path,
|
||||
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
|
||||
init_logging, rustfs_binary_path,
|
||||
};
|
||||
use crate::scanner_heal_evidence::{EvidenceTopology, RestartObservation, ScannerHealEvidenceCase, restart_evidence_run};
|
||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use http::Method;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::error::Error;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -41,6 +39,52 @@ mod tests {
|
||||
|
||||
const POOL_METADATA_OBJECT: &str = "pool.bin";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct EvidenceBuild {
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RestartEvidenceRun {
|
||||
schema: u32,
|
||||
run_id: String,
|
||||
source_revision: String,
|
||||
test_build: serde_json::Value,
|
||||
binary: EvidenceBuild,
|
||||
test_binary: EvidenceBuild,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ScannerHealEvidenceCase {
|
||||
id: &'static str,
|
||||
oracle: &'static str,
|
||||
evidence: &'static str,
|
||||
unclean_shutdown_marker: bool,
|
||||
topology: EvidenceTopology,
|
||||
storage_class_standard: Option<&'static str>,
|
||||
erasure_set_drive_count: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct EvidenceTopology {
|
||||
nodes: usize,
|
||||
drives_per_node: usize,
|
||||
}
|
||||
|
||||
impl EvidenceTopology {
|
||||
const fn new(nodes: usize, drives_per_node: usize) -> Self {
|
||||
Self { nodes, drives_per_node }
|
||||
}
|
||||
|
||||
fn total_drives(self) -> usize {
|
||||
self.nodes * self.drives_per_node
|
||||
}
|
||||
|
||||
fn cluster_topology(self) -> ClusterTopology {
|
||||
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
|
||||
}
|
||||
}
|
||||
|
||||
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||
id: "background-target-restart",
|
||||
oracle: "background-target-restart.json",
|
||||
@@ -81,6 +125,81 @@ mod tests {
|
||||
erasure_set_drive_count: Some("12"),
|
||||
};
|
||||
|
||||
struct RestartEvidenceContext {
|
||||
directory: PathBuf,
|
||||
run: RestartEvidenceRun,
|
||||
case: ScannerHealEvidenceCase,
|
||||
}
|
||||
|
||||
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..read]);
|
||||
}
|
||||
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
fn restart_evidence_run(
|
||||
binary: &Path,
|
||||
case: ScannerHealEvidenceCase,
|
||||
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
|
||||
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
||||
return Ok(None);
|
||||
};
|
||||
if case.id.is_empty()
|
||||
|| case.oracle.is_empty()
|
||||
|| !case.oracle.ends_with(".json")
|
||||
|| case.oracle.contains('/')
|
||||
|| case.oracle.contains('\\')
|
||||
|| case.oracle.contains("..")
|
||||
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|
||||
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
|
||||
{
|
||||
return Err("invalid scanner/heal evidence case".into());
|
||||
}
|
||||
let directory = PathBuf::from(directory);
|
||||
let receipt = directory.join("run.json");
|
||||
if receipt.metadata()?.len() > 1024 * 1024 {
|
||||
return Err("oversized scanner/heal execution receipt".into());
|
||||
}
|
||||
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
|
||||
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
|
||||
return Err("invalid scanner/heal execution identity".into());
|
||||
}
|
||||
let built = compiled_test_identity();
|
||||
for key in ["source_revision", "dirty", "lock_blob", "features"] {
|
||||
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
|
||||
}
|
||||
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
|
||||
assert_eq!(
|
||||
file_sha256(&std::env::current_exe()?)?,
|
||||
run.test_binary.sha256,
|
||||
"test executable must match the run receipt"
|
||||
);
|
||||
if directory.join(case.oracle).exists() {
|
||||
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
||||
}
|
||||
Ok(Some(RestartEvidenceContext { directory, run, case }))
|
||||
}
|
||||
|
||||
fn compiled_test_identity() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
||||
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
|
||||
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
|
||||
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
|
||||
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
|
||||
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
|
||||
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
|
||||
})
|
||||
}
|
||||
|
||||
struct TcpPortBlackhole {
|
||||
port: u16,
|
||||
comment: String,
|
||||
@@ -385,79 +504,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
async fn wait_for_admin_cluster_start_log(
|
||||
log_path: &Path,
|
||||
client_token: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
loop {
|
||||
let coordinator_log = std::fs::read_to_string(log_path)?;
|
||||
if coordinator_log
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
||||
.any(|event| {
|
||||
event["event"] == "heal_task_state"
|
||||
&& event["task_id"] == client_token
|
||||
&& event["heal_type"] == "cluster"
|
||||
&& event["state"] == "started"
|
||||
})
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(
|
||||
format!("node 0 must have started the exact admin task before interruption: task_id={client_token}").into(),
|
||||
);
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_cluster_start_log_waits_for_exact_delayed_event() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
use std::io::Write;
|
||||
|
||||
let mut log = tempfile::NamedTempFile::new()?;
|
||||
for (task_id, heal_type, state) in [
|
||||
("other-task", "cluster", "started"),
|
||||
("admin-task", "object", "started"),
|
||||
("admin-task", "cluster", "completed"),
|
||||
] {
|
||||
writeln!(
|
||||
log,
|
||||
"{}",
|
||||
serde_json::json!({"event": "heal_task_state", "task_id": task_id, "heal_type": heal_type, "state": state})
|
||||
)?;
|
||||
}
|
||||
let log_path = log.path().to_path_buf();
|
||||
let started = wait_for_admin_cluster_start_log(&log_path, "admin-task", Instant::now() + Duration::from_secs(1));
|
||||
tokio::pin!(started);
|
||||
// Poll the reader before publishing the start event, without depending
|
||||
// on scheduling or a fixed writer delay to reproduce log visibility.
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = &mut started => panic!("unrelated events must leave the exact start pending: {result:?}"),
|
||||
_ = std::future::ready(()) => {}
|
||||
}
|
||||
writeln!(
|
||||
log,
|
||||
"{}",
|
||||
serde_json::json!({"event": "heal_task_state", "task_id": "admin-task", "heal_type": "cluster", "state": "started"})
|
||||
)?;
|
||||
started.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_admin_cluster_start_log_respects_existing_deadline() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let log = tempfile::NamedTempFile::new()?;
|
||||
let error = wait_for_admin_cluster_start_log(log.path(), "admin-task", Instant::now())
|
||||
.await
|
||||
.expect_err("missing exact start must fail at the supplied deadline");
|
||||
assert!(error.to_string().contains("task_id=admin-task"), "{error}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cluster_heal_is_idle(status: &serde_json::Value) -> bool {
|
||||
let operations = &status["healOperations"];
|
||||
status["clusterStatusComplete"] == serde_json::Value::Bool(true)
|
||||
@@ -894,10 +940,7 @@ mod tests {
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
previous_cycle_end: u64,
|
||||
) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
||||
let started = Instant::now();
|
||||
let mut deadline = started + Duration::from_secs(60);
|
||||
let catch_up_deadline = deadline + Duration::from_secs(300);
|
||||
let mut catch_up_wait_observed = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let mut latest_cycle_end = 0;
|
||||
let mut versions_observed = false;
|
||||
@@ -925,61 +968,24 @@ mod tests {
|
||||
let versions_scanned = metrics["versions_scanned"]
|
||||
.as_u64()
|
||||
.ok_or("scanner status is missing its version-coverage counter")?;
|
||||
let cycle_result = metrics["last_cycle_result"]
|
||||
.as_str()
|
||||
.ok_or("scanner status is missing its cycle result")?;
|
||||
if cycle_result == "success" {
|
||||
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
||||
}
|
||||
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
||||
versions_observed |= versions_scanned > 0;
|
||||
let backlog = &status["pause_backlog"];
|
||||
if !catch_up_wait_observed
|
||||
&& backlog["persistence_state"].as_str() == Some("healthy")
|
||||
&& backlog["durable"].as_bool() == Some(true)
|
||||
&& backlog["phase"].as_str() == Some("catching_up")
|
||||
&& backlog["rate_limited"].as_bool() == Some(true)
|
||||
&& backlog["retry_exhausted"].as_bool() == Some(false)
|
||||
{
|
||||
let next_attempt = backlog["next_attempt_at_unix_secs"]
|
||||
.as_u64()
|
||||
.ok_or("rate-limited scanner backlog is missing its next attempt")?;
|
||||
let interval = backlog["thresholds"]["catch_up_min_interval_seconds"]
|
||||
.as_u64()
|
||||
.ok_or("rate-limited scanner backlog is missing its catch-up interval")?;
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
|
||||
let remaining = next_attempt.saturating_sub(now);
|
||||
if remaining > 0 {
|
||||
if interval > 300 || remaining > interval {
|
||||
return Err(
|
||||
format!("scanner catch-up schedule exceeds the bounded recovery budget: {backlog}").into()
|
||||
);
|
||||
}
|
||||
// The durable catch-up interval overrides SCANNER_CYCLE=1.
|
||||
// Honor one observed retry without restarting the deadline on every poll.
|
||||
deadline = deadline
|
||||
.max(Instant::now() + Duration::from_secs(remaining + 60))
|
||||
.min(catch_up_deadline);
|
||||
catch_up_wait_observed = true;
|
||||
}
|
||||
}
|
||||
observations.push(format!(
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}, backlog={}",
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
|
||||
metrics["current_cycle"],
|
||||
metrics["current_cycle_active"],
|
||||
metrics["leader_lock_state"],
|
||||
metrics["last_cycle_result"],
|
||||
backlog,
|
||||
));
|
||||
}
|
||||
// Only a successful coordinator cycle counts as completion; deferred
|
||||
// and superseded attempts also advance its end timestamp. Remote
|
||||
// workers record version coverage, so the witnesses can span nodes.
|
||||
// The coordinator records cycle completion, but remote workers
|
||||
// record scanned versions. Both witnesses need not share a node.
|
||||
if latest_cycle_end > previous_cycle_end && versions_observed {
|
||||
return Ok(latest_cycle_end);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"enabled scanner did not complete a successful object-scanning cycle after {previous_cycle_end}: {observations:?}"
|
||||
"enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -999,7 +1005,7 @@ mod tests {
|
||||
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
timeout(
|
||||
Duration::from_secs(720),
|
||||
Duration::from_secs(420),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
|
||||
)
|
||||
.await?
|
||||
@@ -1009,7 +1015,7 @@ mod tests {
|
||||
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
timeout(
|
||||
Duration::from_secs(720),
|
||||
Duration::from_secs(420),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash),
|
||||
)
|
||||
.await?
|
||||
@@ -1019,7 +1025,7 @@ mod tests {
|
||||
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
timeout(
|
||||
Duration::from_secs(720),
|
||||
Duration::from_secs(420),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84),
|
||||
)
|
||||
.await?
|
||||
@@ -1029,7 +1035,7 @@ mod tests {
|
||||
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
timeout(
|
||||
Duration::from_secs(720),
|
||||
Duration::from_secs(420),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84),
|
||||
)
|
||||
.await?
|
||||
@@ -1039,7 +1045,7 @@ mod tests {
|
||||
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
timeout(
|
||||
Duration::from_secs(720),
|
||||
Duration::from_secs(420),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
|
||||
)
|
||||
.await?
|
||||
@@ -1146,19 +1152,10 @@ mod tests {
|
||||
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
|
||||
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
|
||||
cluster.set_env("RUST_LOG", server_rust_log);
|
||||
let log_dir = if let Some(directory) = std::env::var_os("RUSTFS_HEAL_CHAOS_LOG_DIR") {
|
||||
PathBuf::from(directory)
|
||||
} else if let Some(directory) = std::env::var_os("RUSTFS_E2E_LOG_DIR") {
|
||||
let cluster_name = Path::new(&cluster.temp_dir)
|
||||
.file_name()
|
||||
.ok_or("cluster directory has no name")?;
|
||||
PathBuf::from(directory).join(cluster_name).join("heal")
|
||||
} else {
|
||||
PathBuf::from(&cluster.temp_dir).join("logs")
|
||||
};
|
||||
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
for node_index in 0..cluster.nodes.len() {
|
||||
cluster.set_node_capture_log_path(node_index, log_dir.join(format!("node{node_index}.log")).to_string_lossy())?;
|
||||
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
|
||||
}
|
||||
cluster.start_with_binary(&server_binary).await?;
|
||||
let clients = cluster.create_all_clients()?;
|
||||
@@ -1213,8 +1210,6 @@ mod tests {
|
||||
attempt_count += 1;
|
||||
continue;
|
||||
}
|
||||
let shard_census =
|
||||
wait_for_complete_physical_shard_on_disk(&replaced_disk, bucket, &key, None, Duration::from_secs(10)).await?;
|
||||
assert!(
|
||||
shard_census.is_complete(),
|
||||
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
|
||||
@@ -1401,9 +1396,6 @@ mod tests {
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
// Task execution and its non-blocking log writer advance independently.
|
||||
// Observe the exact start before taking the partial-rebuild snapshot.
|
||||
wait_for_admin_cluster_start_log(&log_dir.join("node0.log"), client_token, partial_deadline).await?;
|
||||
let (partial_count, partial_manifest) = loop {
|
||||
// Hash one committed shard to prove progress without letting a
|
||||
// full-corpus hash pass consume the interruption window.
|
||||
@@ -1438,6 +1430,19 @@ mod tests {
|
||||
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
|
||||
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
|
||||
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
|
||||
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
|
||||
assert!(
|
||||
coordinator_log
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
||||
.any(|event| {
|
||||
event["event"] == "heal_task_state"
|
||||
&& event["task_id"] == client_token
|
||||
&& event["heal_type"] == "cluster"
|
||||
&& event["state"] == "started"
|
||||
}),
|
||||
"node 0 must have started the exact admin task before interruption"
|
||||
);
|
||||
let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
|
||||
assert_eq!(
|
||||
pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
|
||||
@@ -1562,11 +1567,10 @@ mod tests {
|
||||
"Restored target endpoint forwarding"
|
||||
);
|
||||
} else {
|
||||
let graceful_restart = matches!(
|
||||
if matches!(
|
||||
scenario,
|
||||
InterruptionScenario::BackgroundTargetRestart | InterruptionScenario::BackgroundTargetRestartEc84
|
||||
);
|
||||
if graceful_restart {
|
||||
) {
|
||||
cluster.stop_node_gracefully(interruption_node).await?;
|
||||
} else {
|
||||
cluster.stop_node(interruption_node)?;
|
||||
@@ -1588,7 +1592,10 @@ mod tests {
|
||||
if background_enabled {
|
||||
let marker_exists = unclean_shutdown_marker.is_file();
|
||||
unclean_shutdown_marker_observed = Some(marker_exists);
|
||||
let expected_marker = !graceful_restart;
|
||||
let expected_marker = matches!(
|
||||
scenario,
|
||||
InterruptionScenario::BackgroundTargetCrash | InterruptionScenario::BackgroundTargetCrashEc84
|
||||
);
|
||||
assert!(
|
||||
marker_exists == expected_marker,
|
||||
"background restart/crash lane observed unexpected unclean-shutdown marker state"
|
||||
@@ -1644,19 +1651,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
if Instant::now() >= heal_deadline {
|
||||
let mut mismatched_manifests = Vec::new();
|
||||
for expected in &expected_manifests {
|
||||
let actual = census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?;
|
||||
if !actual.matches_manifest(&expected.shard_census) {
|
||||
mismatched_manifests.push(serde_json::json!({
|
||||
"key": expected.key,
|
||||
"expected": expected.shard_census,
|
||||
"actual": actual,
|
||||
}));
|
||||
}
|
||||
}
|
||||
let matching = expected_manifests.len() - mismatched_manifests.len();
|
||||
let mismatched_manifests = serde_json::Value::Array(mismatched_manifests);
|
||||
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
||||
let pool_metadata =
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||
@@ -1679,7 +1674,7 @@ mod tests {
|
||||
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
|
||||
};
|
||||
return Err(format!(
|
||||
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, mismatched_manifests={mismatched_manifests}, 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={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}",
|
||||
expected_manifests.len()
|
||||
)
|
||||
.into());
|
||||
@@ -1803,18 +1798,32 @@ mod tests {
|
||||
if let Some(evidence_context) = evidence_run {
|
||||
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
|
||||
assert_ne!(target_pid, restarted_pid, "target must be a new process");
|
||||
evidence_context.write(
|
||||
&server_binary,
|
||||
RestartObservation {
|
||||
nodes: cluster.nodes.len(),
|
||||
drives_per_node: cluster.nodes[0].data_dirs.len(),
|
||||
pid_before: target_pid,
|
||||
pid_after: restarted_pid,
|
||||
unclean_shutdown_marker: unclean_shutdown_marker_observed.ok_or("missing shutdown marker observation")?,
|
||||
objects: evidence_objects,
|
||||
node_listings,
|
||||
},
|
||||
)?;
|
||||
assert_eq!(
|
||||
file_sha256(&server_binary)?,
|
||||
evidence_context.run.binary.sha256,
|
||||
"server build changed during restart"
|
||||
);
|
||||
let evidence = serde_json::json!({
|
||||
"schema": 1, "case": evidence_context.case.id, "evidence": evidence_context.case.evidence,
|
||||
"run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
|
||||
"test_build": compiled_test_identity(),
|
||||
"binary_sha256": evidence_context.run.binary.sha256,
|
||||
"test_binary_sha256": evidence_context.run.test_binary.sha256,
|
||||
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
|
||||
"pid_before": target_pid, "pid_after": restarted_pid,
|
||||
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
|
||||
"objects": evidence_objects, "node_listings": node_listings,
|
||||
});
|
||||
let data = serde_json::to_vec(&evidence)?;
|
||||
if data.len() > 1024 * 1024 {
|
||||
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
|
||||
}
|
||||
let mut output = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(evidence_context.directory.join(evidence_context.case.oracle))?;
|
||||
output.write_all(&data)?;
|
||||
output.sync_all()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//! One S3 GET can select readers on multiple EC nodes, so the counter tracks
|
||||
//! distributed reader selection rather than HTTP request count.
|
||||
|
||||
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, signal_process};
|
||||
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
@@ -2207,33 +2207,6 @@ async fn four_node_manual_transition_job_status_survives_node_restart() -> TestR
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SuspendedTransitionTarget<'a> {
|
||||
// Keep the owned child borrowed until it is resumed so its PID cannot be reused.
|
||||
child: &'a std::process::Child,
|
||||
suspended: bool,
|
||||
}
|
||||
|
||||
impl<'a> SuspendedTransitionTarget<'a> {
|
||||
fn suspend(child: &'a std::process::Child) -> TestResult<Self> {
|
||||
signal_process(child.id(), "STOP")?;
|
||||
Ok(Self { child, suspended: true })
|
||||
}
|
||||
|
||||
fn resume(&mut self) -> TestResult {
|
||||
signal_process(self.child.id(), "CONT")?;
|
||||
self.suspended = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SuspendedTransitionTarget<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.suspended {
|
||||
let _ = signal_process(self.child.id(), "CONT");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_manual_transition_distributed_admission_conflict_reports_status_and_backpressure() -> TestResult {
|
||||
init_logging();
|
||||
@@ -2271,21 +2244,7 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
// Lifecycle PUT starts its own backfill. Keep its first page on a separate
|
||||
// node and stop it at queue backpressure before it reaches the tested prefix:
|
||||
// one active worker, one queued item, then the first rejected item.
|
||||
for index in 0u8..3 {
|
||||
hot_client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(format!("transition/automatic-admission/object-{index:02}.bin"))
|
||||
.body(ByteStream::from(payload(KIB, index)))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let mut suspended_cold = SuspendedTransitionTarget::suspend(cold.process.as_ref().ok_or("cold-tier process missing")?)?;
|
||||
let lifecycle_client = hot.create_s3_client(2)?;
|
||||
put_lifecycle_with_transition_retry(&lifecycle_client, &bucket, &tier_name).await?;
|
||||
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
||||
|
||||
let (node0, node1) = tokio::join!(
|
||||
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
||||
@@ -2345,31 +2304,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
||||
assert_eq!(status["job_id"].as_str(), Some(job_id));
|
||||
assert_eq!(status["status_endpoint"].as_str(), Some(status_endpoint));
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let status = read_manual_transition_job_status_endpoint(&hot, accepted.0, status_endpoint).await?;
|
||||
assert_eq!(
|
||||
status["status"].as_str(),
|
||||
Some("running"),
|
||||
"blocked cold tier must keep the admitted job running: {status}"
|
||||
);
|
||||
if status["report"]["skipped_queue_full"].as_u64().is_some_and(|count| count > 0) {
|
||||
assert!(
|
||||
status["report"]["enqueued"].as_u64().is_some_and(|count| count > 0),
|
||||
"the job must own pending transitions while the cold tier is suspended: {status}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"manual transition job did not reach queue backpressure while the cold tier was suspended: {status}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
suspended_cold.resume()?;
|
||||
let terminal = wait_for_manual_transition_job_terminal(&hot, conflict.0, job_id, false).await?;
|
||||
assert_eq!(terminal["job_id"].as_str(), Some(job_id));
|
||||
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
|
||||
|
||||
@@ -23,9 +23,6 @@ pub mod common;
|
||||
#[cfg(test)]
|
||||
pub mod chaos;
|
||||
|
||||
#[cfg(test)]
|
||||
mod scanner_heal_evidence;
|
||||
|
||||
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
|
||||
// and on-demand-migration source scenarios (backlog#2151).
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -79,11 +79,6 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
|
||||
.await
|
||||
.map_err(|e| anyhow!("{}", e))?;
|
||||
|
||||
// Protocol listeners can accept connections before IAM is initialized.
|
||||
// A signed S3 request establishes readiness before the first SFTP login.
|
||||
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
|
||||
wait_for_s3_ready(&s3, 30).await?;
|
||||
|
||||
let (session, sftp) = connect_sftp_to(COMPLIANCE_RW_SFTP_ADDRESS).await?;
|
||||
|
||||
cmptst_01::run_medium_binary_round_trip(&sftp).await?;
|
||||
@@ -106,6 +101,8 @@ pub async fn test_sftp_compliance_suite() -> Result<()> {
|
||||
// reach the finalised object as x-amz-meta-* user metadata
|
||||
// through the CreateMultipartUpload input field. The S3 client
|
||||
// connects to the same rustfs process this suite already drives.
|
||||
let s3 = build_test_s3_client(&format!("http://{COMPLIANCE_RW_S3_ADDRESS}"));
|
||||
wait_for_s3_ready(&s3, 30).await?;
|
||||
cmptst_34::run_open_attrs_round_trip_multipart(&sftp, &s3).await?;
|
||||
|
||||
drop(sftp);
|
||||
|
||||
@@ -168,10 +168,6 @@ pub async fn test_sftp_core_operations() -> Result<()> {
|
||||
.await
|
||||
.map_err(|e| anyhow!("{}", e))?;
|
||||
|
||||
// Protocol listeners can accept connections before IAM is initialized.
|
||||
let s3 = build_test_s3_client(S3_ENDPOINT);
|
||||
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
|
||||
|
||||
let (session, sftp) = connect_sftp().await?;
|
||||
|
||||
// --- 1. Subsystem canary: SFTP session reachable after password auth ---
|
||||
@@ -352,6 +348,16 @@ pub async fn test_sftp_core_operations() -> Result<()> {
|
||||
let _ = bad_session.disconnect(russh::Disconnect::ByApplication, "", "en").await;
|
||||
info!("PASS: bad-password authentication rejected");
|
||||
|
||||
// --- Cross-protocol setup: aws-sdk-s3 client against the same server ---
|
||||
// The rustfs binary spawned for this suite serves both SFTP on port
|
||||
// 9022 and S3 on port 9000. The S3 stack may need a moment to finish
|
||||
// initialising after TCP is listening, so list_buckets is polled
|
||||
// until it succeeds before any cross-protocol assertion runs.
|
||||
info!("Testing SFTP: prepare aws-sdk-s3 client and wait for S3 readiness");
|
||||
let s3 = build_test_s3_client(S3_ENDPOINT);
|
||||
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
|
||||
info!("PASS: S3 endpoint reachable from cross-protocol client");
|
||||
|
||||
// --- SFTP write, S3 read: SHA256 round-trip ---
|
||||
// SFTP creates the object, then assert_cross_protocol_sha_match
|
||||
// fetches it via both S3 GetObject and SFTP READ and compares
|
||||
@@ -516,9 +522,6 @@ pub async fn test_sftp_idle_timeout_disconnects() -> Result<()> {
|
||||
.await
|
||||
.map_err(|e| anyhow!("{}", e))?;
|
||||
|
||||
let s3 = build_test_s3_client(&format!("http://{IDLE_S3_ADDRESS}"));
|
||||
wait_for_s3_ready(&s3, S3_READY_ATTEMPTS).await?;
|
||||
|
||||
let (session, sftp) = connect_sftp_to(IDLE_SFTP_ADDRESS).await?;
|
||||
|
||||
// Confirm the session is live before the wait so a failure in the
|
||||
|
||||
@@ -4235,6 +4235,16 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>matrix-and-tags</ID>
|
||||
<Priority>135</Priority>
|
||||
<Status>Enabled</Status>
|
||||
<Filter><And><Prefix>and-tags/</Prefix><Tag><Key>env</Key><Value>prod</Value></Tag><Tag><Key>tier</Key><Value>gold</Value></Tag></And></Filter>
|
||||
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
|
||||
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>matrix-disabled</ID>
|
||||
<Priority>140</Priority>
|
||||
@@ -4289,6 +4299,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
"matrix-prefix",
|
||||
"matrix-tag",
|
||||
"matrix-disabled",
|
||||
"matrix-and-tags",
|
||||
"matrix-priority-high",
|
||||
"Priority>200",
|
||||
"<Status>Disabled</Status>",
|
||||
@@ -4409,6 +4420,30 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||
|
||||
// S3 and MinIO both read `And.Tags` as AND: an object carrying only one of
|
||||
// the required tags is not admitted. Matching any single tag would push
|
||||
// data to a destination the rule never selected (backlog#2366 P1-1), and
|
||||
// the two-tag rule is the shape `mc replicate add --tags "k1=v1&k2=v2"`
|
||||
// writes, so a single-tag rule passing is not evidence for this.
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("and-tags/partial.txt")
|
||||
.tagging("env=prod")
|
||||
.body(ByteStream::from_static(b"one of two tags"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "and-tags/partial.txt", Duration::from_secs(3)).await?;
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("and-tags/full.txt")
|
||||
.tagging("env=prod&tier=gold")
|
||||
.body(ByteStream::from_static(b"both tags"))
|
||||
.send()
|
||||
.await?;
|
||||
wait_for_user_get_object(&target_client_b, target_bucket_b, "and-tags/full.txt").await?;
|
||||
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// 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.
|
||||
|
||||
//! Build-bound evidence for scanner and heal restart tests.
|
||||
|
||||
use crate::common::ClusterTopology;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::error::Error;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct EvidenceBuild {
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RestartEvidenceRun {
|
||||
schema: u32,
|
||||
run_id: String,
|
||||
source_revision: String,
|
||||
test_build: serde_json::Value,
|
||||
binary: EvidenceBuild,
|
||||
test_binary: EvidenceBuild,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ScannerHealEvidenceCase {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) oracle: &'static str,
|
||||
pub(crate) evidence: &'static str,
|
||||
pub(crate) unclean_shutdown_marker: bool,
|
||||
pub(crate) topology: EvidenceTopology,
|
||||
pub(crate) storage_class_standard: Option<&'static str>,
|
||||
pub(crate) erasure_set_drive_count: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct EvidenceTopology {
|
||||
pub(crate) nodes: usize,
|
||||
pub(crate) drives_per_node: usize,
|
||||
}
|
||||
|
||||
impl EvidenceTopology {
|
||||
pub(crate) const fn new(nodes: usize, drives_per_node: usize) -> Self {
|
||||
Self { nodes, drives_per_node }
|
||||
}
|
||||
|
||||
pub(crate) fn total_drives(self) -> usize {
|
||||
self.nodes * self.drives_per_node
|
||||
}
|
||||
|
||||
pub(crate) fn cluster_topology(self) -> ClusterTopology {
|
||||
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RestartEvidenceContext {
|
||||
directory: PathBuf,
|
||||
run: RestartEvidenceRun,
|
||||
case: ScannerHealEvidenceCase,
|
||||
}
|
||||
|
||||
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..read]);
|
||||
}
|
||||
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn restart_evidence_run(
|
||||
binary: &Path,
|
||||
case: ScannerHealEvidenceCase,
|
||||
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
|
||||
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
||||
return Ok(None);
|
||||
};
|
||||
if case.id.is_empty()
|
||||
|| case.oracle.is_empty()
|
||||
|| !case.oracle.ends_with(".json")
|
||||
|| case.oracle.contains('/')
|
||||
|| case.oracle.contains('\\')
|
||||
|| case.oracle.contains("..")
|
||||
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|
||||
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
|
||||
{
|
||||
return Err("invalid scanner/heal evidence case".into());
|
||||
}
|
||||
let directory = PathBuf::from(directory);
|
||||
let receipt = directory.join("run.json");
|
||||
if receipt.metadata()?.len() > 1024 * 1024 {
|
||||
return Err("oversized scanner/heal execution receipt".into());
|
||||
}
|
||||
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
|
||||
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
|
||||
return Err("invalid scanner/heal execution identity".into());
|
||||
}
|
||||
let built = compiled_test_identity();
|
||||
for key in ["source_revision", "dirty", "lock_blob", "features"] {
|
||||
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
|
||||
}
|
||||
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
|
||||
assert_eq!(
|
||||
file_sha256(&std::env::current_exe()?)?,
|
||||
run.test_binary.sha256,
|
||||
"test executable must match the run receipt"
|
||||
);
|
||||
if directory.join(case.oracle).exists() {
|
||||
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
||||
}
|
||||
Ok(Some(RestartEvidenceContext { directory, run, case }))
|
||||
}
|
||||
|
||||
fn compiled_test_identity() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
||||
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
|
||||
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
|
||||
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
|
||||
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
|
||||
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
|
||||
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct RestartObservation {
|
||||
pub(crate) nodes: usize,
|
||||
pub(crate) drives_per_node: usize,
|
||||
pub(crate) pid_before: u32,
|
||||
pub(crate) pid_after: u32,
|
||||
pub(crate) unclean_shutdown_marker: bool,
|
||||
pub(crate) objects: Vec<serde_json::Value>,
|
||||
pub(crate) node_listings: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl RestartEvidenceContext {
|
||||
pub(crate) fn write(self, binary: &Path, observed: RestartObservation) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
assert_ne!(observed.pid_before, observed.pid_after, "target must be a new process");
|
||||
assert_eq!(file_sha256(binary)?, self.run.binary.sha256, "server build changed during restart");
|
||||
let evidence = serde_json::json!({
|
||||
"schema": 1, "case": self.case.id, "evidence": self.case.evidence,
|
||||
"run_id": self.run.run_id, "source_revision": self.run.source_revision,
|
||||
"test_build": compiled_test_identity(),
|
||||
"binary_sha256": self.run.binary.sha256,
|
||||
"test_binary_sha256": self.run.test_binary.sha256,
|
||||
"topology": {"nodes": observed.nodes, "drives_per_node": observed.drives_per_node},
|
||||
"pid_before": observed.pid_before, "pid_after": observed.pid_after,
|
||||
"unclean_shutdown_marker": observed.unclean_shutdown_marker,
|
||||
"objects": observed.objects, "node_listings": observed.node_listings,
|
||||
});
|
||||
let data = serde_json::to_vec(&evidence)?;
|
||||
if data.len() > 1024 * 1024 {
|
||||
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
|
||||
}
|
||||
let mut output = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(self.directory.join(self.case.oracle))?;
|
||||
output.write_all(&data)?;
|
||||
output.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,10 @@ use aws_sdk_s3::types::{
|
||||
VersioningConfiguration,
|
||||
};
|
||||
use http::{Method, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
@@ -41,6 +43,7 @@ type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||
const G09_EVIDENCE_DIR_ENV: &str = "RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR";
|
||||
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
|
||||
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||
@@ -81,6 +84,14 @@ const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// of treating it as an upgrade failure.
|
||||
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
struct G09EvidenceContext {
|
||||
directory: PathBuf,
|
||||
current_revision: String,
|
||||
previous_revision: String,
|
||||
run_id: String,
|
||||
measurement_window_id: String,
|
||||
}
|
||||
|
||||
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
||||
.map(PathBuf::from)
|
||||
@@ -91,6 +102,155 @@ fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn is_lower_hex_revision(value: &str) -> bool {
|
||||
value.len() == 40
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
fn current_source_revision() -> Result<String, BoxError> {
|
||||
let revision = env!("RUSTFS_E2E_BUILD_COMMIT");
|
||||
if !is_lower_hex_revision(revision) {
|
||||
return Err(format!("current test binary has invalid source revision {revision}").into());
|
||||
}
|
||||
Ok(revision.to_string())
|
||||
}
|
||||
|
||||
async fn binary_source_revision(binary: &Path) -> Result<String, BoxError> {
|
||||
let output = tokio::process::Command::new(binary).arg("--version").output().await?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("{} --version failed with {}", binary.display(), output.status).into());
|
||||
}
|
||||
let version = String::from_utf8(output.stdout)?;
|
||||
source_revision_from_version_output(&version, binary)
|
||||
}
|
||||
|
||||
fn source_revision_from_version_output(version: &str, binary: &Path) -> Result<String, BoxError> {
|
||||
version
|
||||
.split(|ch: char| !ch.is_ascii_hexdigit())
|
||||
.find(|token| is_lower_hex_revision(token))
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("{} --version did not expose a 40-byte source revision", binary.display()).into())
|
||||
}
|
||||
|
||||
async fn g09_evidence_context(previous_binary: &Path) -> Result<Option<G09EvidenceContext>, BoxError> {
|
||||
let Some(directory) = std::env::var_os(G09_EVIDENCE_DIR_ENV) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let directory = PathBuf::from(directory);
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
let current_revision = current_source_revision()?;
|
||||
let previous_revision = binary_source_revision(previous_binary).await?;
|
||||
if current_revision == previous_revision {
|
||||
return Err("G09 mixed-version evidence requires distinct current and previous source revisions".into());
|
||||
}
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||
Ok(Some(G09EvidenceContext {
|
||||
directory,
|
||||
current_revision,
|
||||
previous_revision,
|
||||
run_id: format!("g09-upgrade-{}-{now}", std::process::id()),
|
||||
measurement_window_id: format!("g09-mixed-version-window-{now}"),
|
||||
}))
|
||||
}
|
||||
|
||||
fn write_g09_evidence(
|
||||
context: &G09EvidenceContext,
|
||||
field: &str,
|
||||
role: &str,
|
||||
cases: &[&str],
|
||||
test: &str,
|
||||
details: Value,
|
||||
) -> TestResult {
|
||||
let path = context.directory.join(format!("G09-{field}.json"));
|
||||
let mut evidence = serde_json::json!({
|
||||
"schema": 1,
|
||||
"evidence_type": "measured",
|
||||
"artifact_kind": "upgrade-compatibility-e2e",
|
||||
"source_revision": context.current_revision,
|
||||
"run_id": context.run_id,
|
||||
"measurement_window_id": context.measurement_window_id,
|
||||
"gate": "G09",
|
||||
"field": field,
|
||||
"versions": [context.previous_revision, context.current_revision],
|
||||
"current_revision": context.current_revision,
|
||||
"previous_revision": context.previous_revision,
|
||||
"mixed_version_role": role,
|
||||
"mixed_version_cases": cases,
|
||||
"test": test,
|
||||
"details": details,
|
||||
});
|
||||
if field == "rollback_payload_evidence" {
|
||||
evidence["rollback_payload_replayed"] = Value::Bool(true);
|
||||
}
|
||||
let data = serde_json::to_vec_pretty(&evidence)?;
|
||||
if data.len() > 1024 * 1024 {
|
||||
return Err("G09 mixed-version evidence exceeds the 1 MiB artifact budget".into());
|
||||
}
|
||||
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
output.write_all(&data)?;
|
||||
output.write_all(b"\n")?;
|
||||
output.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod g09_evidence_tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn source_revision_parser_requires_lowercase_forty_byte_sha() -> TestResult {
|
||||
let binary = Path::new("rustfs");
|
||||
assert_eq!(
|
||||
source_revision_from_version_output("rustfs 1.0.0 abcdef0123456789abcdef0123456789abcdef01 clean", binary)?,
|
||||
"abcdef0123456789abcdef0123456789abcdef01"
|
||||
);
|
||||
assert!(source_revision_from_version_output("rustfs ABCDEF0123456789ABCDEF0123456789ABCDEF01", binary).is_err());
|
||||
assert!(source_revision_from_version_output("rustfs abcdef", binary).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g09_evidence_writer_records_measured_role_and_refuses_overwrite() -> TestResult {
|
||||
let directory = TempDir::new()?;
|
||||
let context = G09EvidenceContext {
|
||||
directory: directory.path().to_path_buf(),
|
||||
current_revision: "b".repeat(40),
|
||||
previous_revision: "a".repeat(40),
|
||||
run_id: "g09-upgrade-test-run".to_string(),
|
||||
measurement_window_id: "g09-upgrade-test-window".to_string(),
|
||||
};
|
||||
write_g09_evidence(
|
||||
&context,
|
||||
"mixed_version_reader_evidence",
|
||||
"mixed-version-reader",
|
||||
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||
"unit",
|
||||
serde_json::json!({"assertions": ["reader evidence"]}),
|
||||
)?;
|
||||
let path = directory.path().join("G09-mixed_version_reader_evidence.json");
|
||||
let evidence: Value = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||
assert_eq!(evidence["evidence_type"], "measured");
|
||||
assert_eq!(evidence["gate"], "G09");
|
||||
assert_eq!(evidence["field"], "mixed_version_reader_evidence");
|
||||
assert_eq!(evidence["mixed_version_role"], "mixed-version-reader");
|
||||
assert_eq!(evidence["versions"], serde_json::json!(["a".repeat(40), "b".repeat(40)]));
|
||||
|
||||
let overwrite = write_g09_evidence(
|
||||
&context,
|
||||
"mixed_version_reader_evidence",
|
||||
"mixed-version-reader",
|
||||
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||
"unit",
|
||||
serde_json::json!({}),
|
||||
);
|
||||
assert!(overwrite.is_err(), "G09 evidence must not overwrite an existing artifact");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
|
||||
let configuration = VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
@@ -523,6 +683,7 @@ async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
||||
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
|
||||
init_logging();
|
||||
let previous_binary = source_binary()?;
|
||||
let evidence_context = g09_evidence_context(&previous_binary).await?;
|
||||
let current_binary = rustfs_binary_path();
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
|
||||
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
|
||||
@@ -555,6 +716,45 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(context) = evidence_context.as_ref() {
|
||||
let phases = ["one-current-node", "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_rc2_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"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
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_rc2_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 under one-current-node and one-previous-node layouts"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1136,6 +1336,7 @@ async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -
|
||||
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
|
||||
init_logging();
|
||||
let previous_binary = source_binary()?;
|
||||
let evidence_context = g09_evidence_context(&previous_binary).await?;
|
||||
|
||||
let replication_target = FakeS3Target::start().await?;
|
||||
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
|
||||
@@ -1204,6 +1405,54 @@ async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestRes
|
||||
);
|
||||
assert_eq!(body, post_rollback_bytes);
|
||||
|
||||
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||
let current_again = env.create_s3_client();
|
||||
assert_versioning_enabled(¤t_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
|
||||
assert_default_sse_s3_encryption(¤t_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
|
||||
assert_bucket_tag(¤t_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
|
||||
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after rolling forward again").await?;
|
||||
assert_eq!(
|
||||
read_object(¤t_again, ROLLBACK_BUCKET, single_key, Some(&single_version))
|
||||
.await?
|
||||
.1,
|
||||
single_bytes
|
||||
);
|
||||
assert_eq!(
|
||||
read_object(¤t_again, ROLLBACK_BUCKET, multipart_key, None).await?.1,
|
||||
multipart_bytes
|
||||
);
|
||||
assert_eq!(
|
||||
read_object(¤t_again, ROLLBACK_BUCKET, post_rollback_key, None).await?.1,
|
||||
post_rollback_bytes
|
||||
);
|
||||
|
||||
if let Some(context) = evidence_context.as_ref() {
|
||||
write_g09_evidence(
|
||||
context,
|
||||
"rollback_payload_evidence",
|
||||
"rollback-payload",
|
||||
&["rollback-to-old", "rollback-to-new", "unknown-field-retained"],
|
||||
"upgrade_compatibility_test::rollback_to_previous_release_reads_current_bucket_metadata",
|
||||
serde_json::json!({
|
||||
"bucket": ROLLBACK_BUCKET,
|
||||
"cases": {
|
||||
"rollback-to-old": [
|
||||
"previous-release binary reads current-build versioning, SSE-S3, tags, replication target and objects",
|
||||
"previous-release writer honors the decoded current-build encryption configuration"
|
||||
],
|
||||
"rollback-to-new": [
|
||||
"current build reads the object written by the rolled-back previous release",
|
||||
"current build reads the current-build single-part and multipart objects after rolling forward again"
|
||||
],
|
||||
"unknown-field-retained": [
|
||||
"previous release skips current-build bucket metadata extension fields without dropping known bucket configuration",
|
||||
"current build reads the retained bucket configuration after the previous-release round trip"
|
||||
]
|
||||
},
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
replication_target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5165,6 +5165,9 @@ pub async fn put_restore_opts(
|
||||
user_defined: meta,
|
||||
version_id: oi.version_id.map(|e| e.to_string()),
|
||||
mod_time: oi.mod_time,
|
||||
// Restore writes stored (possibly encrypted) bytes, so the writer's
|
||||
// computed MD5 is not the object's public plaintext ETag.
|
||||
preserve_etag: oi.etag.clone(),
|
||||
//expires: oi.expires,
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
@@ -22543,6 +22543,112 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn conditional_mrf_manifest_storage_full_keeps_recovery_anchors() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
|
||||
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
|
||||
|
||||
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
|
||||
let dir = tempdir().expect("temp dir should be created");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||
let previous_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
|
||||
let successor_manifest = Bytes::from_static(b"mrf-committed-manifest-v2");
|
||||
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
|
||||
|
||||
assert_eq!(
|
||||
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(previous_manifest.clone()),)
|
||||
.await
|
||||
.expect("previous MRF manifest should commit"),
|
||||
ConditionalFileUpdate::Updated
|
||||
);
|
||||
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
|
||||
.await
|
||||
.expect("legacy MRF journal should be retained");
|
||||
|
||||
let manifest_path = disk
|
||||
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||
.expect("MRF manifest path should resolve");
|
||||
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
|
||||
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
|
||||
|
||||
let err = disk
|
||||
.compare_and_update_file(
|
||||
RUSTFS_META_BUCKET,
|
||||
MRF_COMMIT_MANIFEST_SLOT_0,
|
||||
Some(previous_manifest.clone()),
|
||||
Some(successor_manifest),
|
||||
)
|
||||
.await
|
||||
.expect_err("storage-full fsync failure must fail the MRF manifest successor commit");
|
||||
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
|
||||
assert_eq!(
|
||||
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||
.await
|
||||
.expect("previous committed MRF manifest should remain readable after storage-full rollback"),
|
||||
previous_manifest
|
||||
);
|
||||
assert_eq!(
|
||||
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
|
||||
.await
|
||||
.expect("legacy MRF journal should remain readable after storage-full manifest publication failure"),
|
||||
legacy_journal
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn conditional_mrf_manifest_storage_full_delete_keeps_recovery_anchors() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
|
||||
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
|
||||
|
||||
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
|
||||
let dir = tempdir().expect("temp dir should be created");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||
let committed_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
|
||||
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
|
||||
|
||||
assert_eq!(
|
||||
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(committed_manifest.clone()),)
|
||||
.await
|
||||
.expect("committed MRF manifest should publish"),
|
||||
ConditionalFileUpdate::Updated
|
||||
);
|
||||
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
|
||||
.await
|
||||
.expect("legacy MRF journal should be retained");
|
||||
|
||||
let manifest_path = disk
|
||||
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||
.expect("MRF manifest path should resolve");
|
||||
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
|
||||
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
|
||||
|
||||
let err = disk
|
||||
.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, Some(committed_manifest.clone()), None)
|
||||
.await
|
||||
.expect_err("storage-full fsync failure must fail the MRF manifest cleanup delete");
|
||||
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
|
||||
assert_eq!(
|
||||
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||
.await
|
||||
.expect("committed MRF manifest should be restored after failed cleanup delete"),
|
||||
committed_manifest
|
||||
);
|
||||
assert_eq!(
|
||||
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
|
||||
.await
|
||||
.expect("legacy MRF journal should remain readable after failed cleanup delete"),
|
||||
legacy_journal
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() {
|
||||
|
||||
@@ -570,25 +570,11 @@ pub(crate) async fn initialize_local_disk_maps(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
pub(crate) static TEST_TIER_CONFIG_MGR: Arc<tokio::sync::RwLock<TierConfigMgr>>;
|
||||
}
|
||||
|
||||
pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
|
||||
#[cfg(not(test))]
|
||||
let handle = get_global_tier_config_mgr();
|
||||
#[cfg(test)]
|
||||
let handle = TEST_TIER_CONFIG_MGR
|
||||
.try_with(Arc::clone)
|
||||
.unwrap_or_else(|_| get_global_tier_config_mgr());
|
||||
let initial_reload = TierConfigMgr::reload_handle(&handle, store.clone()).await;
|
||||
if initial_reload.is_err() {
|
||||
// Keep local recovery active when the initial snapshot cannot be loaded.
|
||||
TierConfigMgr::request_committed_mutation_refresh(&handle).await;
|
||||
}
|
||||
TierConfigMgr::reload_handle(&handle, store.clone()).await?;
|
||||
tokio::spawn(TierConfigMgr::refresh_tier_config_handle(handle, store));
|
||||
initial_reload.map_err(Error::from)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -9209,6 +9209,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
let dest_obj = transaction.remote_object.clone();
|
||||
let mut transition_meta = (*oi.user_defined).clone();
|
||||
rustfs_utils::http::remove_str(&mut transition_meta, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
|
||||
// The tier holds opaque stored bytes. Its metadata must not be treated
|
||||
// as a second object header set: forwarding SSE intent or wrapped DEKs
|
||||
// would request a second encryption pass and disclose local envelope
|
||||
// material to the remote provider.
|
||||
transition_meta.retain(|key, _| !rustfs_utils::http::is_replication_stripped_encryption_key(key));
|
||||
transition_meta.insert("name".to_string(), object.to_string());
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut transition_meta,
|
||||
@@ -9765,13 +9770,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
part_opts.part_number = Some(part_info.number);
|
||||
#[cfg(test)]
|
||||
fail_restore_multipart_at(RestoreMultipartFailurePoint::InvalidPartSize)?;
|
||||
if part_info.actual_size <= 0 {
|
||||
return Err(Error::other(format!("invalid multipart restore part size {}", part_info.actual_size)));
|
||||
if part_info.size == 0 {
|
||||
return Err(Error::other(format!("invalid multipart restore stored part size {}", part_info.size)));
|
||||
}
|
||||
let stored_part_size = i64::try_from(part_info.size).map_err(|_| {
|
||||
Error::other(format!("multipart restore stored part size exceeds i64: {}", part_info.size))
|
||||
})?;
|
||||
#[cfg(test)]
|
||||
fail_restore_multipart_at(RestoreMultipartFailurePoint::RangeOverflow)?;
|
||||
let part_end = part_offset
|
||||
.checked_add(part_info.actual_size - 1)
|
||||
.checked_add(stored_part_size - 1)
|
||||
.ok_or_else(|| Error::other("multipart restore part range overflow".to_string()))?;
|
||||
let rs = Some(HTTPRangeSpec {
|
||||
is_suffix_length: false,
|
||||
@@ -9799,13 +9807,19 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
#[cfg(test)]
|
||||
fail_restore_multipart_at(RestoreMultipartFailurePoint::HashReader)?;
|
||||
let hash_reader =
|
||||
HashReader::from_stream(reader, part_info.actual_size, part_info.actual_size, None, None, false)?;
|
||||
HashReader::from_stream(reader, stored_part_size, part_info.actual_size, None, None, false)?;
|
||||
let mut p_reader = PutObjReader::new(hash_reader);
|
||||
#[cfg(test)]
|
||||
fail_restore_multipart_at(RestoreMultipartFailurePoint::PutPart)?;
|
||||
// `ropts` carries the object's ETag so the single-part copy-back
|
||||
// keeps it (the writer only ever sees stored bytes). A part write
|
||||
// must not inherit that object-level value, or every restored part
|
||||
// would be recorded under the same ETag; each part keeps its own.
|
||||
let mut part_write_opts = ropts.clone();
|
||||
part_write_opts.preserve_etag = Some(part_info.etag.clone()).filter(|etag| !etag.is_empty());
|
||||
let p_info = self_
|
||||
.clone()
|
||||
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &ropts)
|
||||
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &part_write_opts)
|
||||
.await?;
|
||||
#[cfg(test)]
|
||||
let p_info = if restore_multipart_failure_is(RestoreMultipartFailurePoint::SizeMismatch) {
|
||||
@@ -9815,7 +9829,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
} else {
|
||||
p_info
|
||||
};
|
||||
if p_info.size as i64 != part_info.actual_size {
|
||||
if p_info.size as i64 != stored_part_size {
|
||||
return Err(Error::other(ObjectApiError::InvalidObjectState(GenericError {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
@@ -9847,6 +9861,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
user_defined: restore_commit_metadata,
|
||||
no_lock: false,
|
||||
decommission_capacity_admission: opts.decommission_capacity_admission.clone(),
|
||||
// The composite ETag would otherwise be recomputed from the
|
||||
// parts as they were written back, which for an encrypted or
|
||||
// compressed object digests stored bytes rather than the
|
||||
// object's public ETag.
|
||||
preserve_etag: oi.etag.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
self_
|
||||
@@ -13488,6 +13507,303 @@ mod transition_commit_failure_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// backlog#2368 B5: the tier stores opaque bytes, so the archive request
|
||||
/// must not carry the object's encryption headers. Forwarding them made
|
||||
/// every S3 target reject an SSE-C archive outright, asked the target to
|
||||
/// encrypt an SSE-KMS object a second time under a key id it does not own,
|
||||
/// and handed the wrapped DEK to a third-party provider.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn transition_does_not_forward_encryption_metadata_to_the_tier() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "transition-encryption-metadata-bucket";
|
||||
let object = "object.bin";
|
||||
for disk in &disk_stores {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
|
||||
let encryption_metadata = [
|
||||
("x-amz-server-side-encryption", "aws:kms"),
|
||||
("x-amz-server-side-encryption-aws-kms-key-id", "arn:aws:kms:us-east-1:123:key/abc"),
|
||||
("x-amz-server-side-encryption-customer-algorithm", "AES256"),
|
||||
(rustfs_utils::http::INTERNAL_ENCRYPTION_KEY_HEADER, "d3JhcHBlZC1kZWs="),
|
||||
(rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER, "AAAAAAAAAAAAAAAA"),
|
||||
(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER, "AES256"),
|
||||
];
|
||||
let mut user_defined: HashMap<String, String> = encryption_metadata
|
||||
.iter()
|
||||
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
|
||||
.collect();
|
||||
user_defined.insert("x-amz-meta-owner".to_string(), "finance".to_string());
|
||||
|
||||
let mut reader = PutObjReader::from_vec(b"stored bytes the tier keeps opaque ".repeat(64));
|
||||
set_disks
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
user_defined,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the encrypted source object should be written");
|
||||
let original = set_disks
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the source object should be readable");
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
let backend = register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
|
||||
set_disks
|
||||
.transition_object(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
transition: TransitionOptions {
|
||||
status: TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name,
|
||||
etag: original.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: original.version_id.map(|version| version.to_string()),
|
||||
mod_time: original.mod_time,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the encrypted object should transition");
|
||||
|
||||
let transitioned = set_disks
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the transitioned object should be readable");
|
||||
let remote_metadata = backend
|
||||
.metadata(&transitioned.transitioned_object.name)
|
||||
.await
|
||||
.expect("the tier must have received the object");
|
||||
|
||||
for (key, _) in encryption_metadata {
|
||||
assert!(
|
||||
!remote_metadata.keys().any(|stored| stored.eq_ignore_ascii_case(key)),
|
||||
"transition must not forward {key} to the tier: {remote_metadata:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
remote_metadata
|
||||
.iter()
|
||||
.any(|(key, value)| key.eq_ignore_ascii_case("x-amz-meta-owner") && value == "finance"),
|
||||
"ordinary user metadata must still travel to the tier: {remote_metadata:?}"
|
||||
);
|
||||
|
||||
// Read-through and restore both resolve encryption locally, so the
|
||||
// stripped keys must survive untouched in the local metadata.
|
||||
for (key, value) in encryption_metadata {
|
||||
assert_eq!(
|
||||
transitioned.user_defined.get(key).map(String::as_str),
|
||||
Some(value),
|
||||
"the local copy must keep {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic bytes that do not repeat with a short period, so a slice
|
||||
/// taken at the wrong offset cannot coincidentally compare equal.
|
||||
fn stored_representation_bytes(seed: u32, len: usize) -> Vec<u8> {
|
||||
(0..len)
|
||||
.map(|index| {
|
||||
let mixed = (index as u32).wrapping_add(seed).wrapping_mul(2_654_435_761);
|
||||
(mixed >> 13) as u8
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compares two stored representations without dumping megabytes of bytes
|
||||
/// into the failure output.
|
||||
fn assert_stored_representation_eq(actual: &[u8], expected: &[u8], what: &str) {
|
||||
assert_eq!(actual.len(), expected.len(), "{what}: stored length differs");
|
||||
if let Some(offset) = actual.iter().zip(expected).position(|(left, right)| left != right) {
|
||||
panic!(
|
||||
"{what}: stored bytes differ at offset {offset} (found {:#04x}, expected {:#04x})",
|
||||
actual[offset], expected[offset]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_stored_representation(set_disks: &Arc<SetDisks>, bucket: &str, object: &str) -> Vec<u8> {
|
||||
let mut reader = set_disks
|
||||
.get_object_reader(
|
||||
bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
raw_data_movement_read: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("stored-representation reader should open");
|
||||
let mut body = Vec::new();
|
||||
reader.stream.read_to_end(&mut body).await.expect("stored body should drain");
|
||||
body
|
||||
}
|
||||
|
||||
/// backlog#2368 B3: the multipart restore loop addresses the tier in STORED
|
||||
/// coordinates. Accumulating each part's PLAINTEXT length instead handed
|
||||
/// every part a misaligned slice of the remote object whose length still
|
||||
/// satisfied the range, the `HashReader` and the completion size check, so
|
||||
/// the copy-back reported success while silently replacing the bytes.
|
||||
///
|
||||
/// The fixture reproduces the encrypted geometry — a stored form LONGER
|
||||
/// than the plaintext it encodes — because that is what keeps a
|
||||
/// plaintext-coordinate range inside the tier object and makes the
|
||||
/// corruption silent rather than a short read.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn multipart_restore_copies_the_stored_representation_back_verbatim() {
|
||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||
let bucket = "restore-multipart-stored-coordinates-bucket";
|
||||
let object = "object.bin";
|
||||
for disk in &disk_stores {
|
||||
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||
}
|
||||
|
||||
// The minimum-part-size gate reads the PLAINTEXT length, so part one
|
||||
// clears 5 MiB there while its stored form carries encoding overhead.
|
||||
let part_shapes = [(6 * 1024 * 1024_usize, 9_216_usize), (256 * 1024_usize, 512_usize)];
|
||||
let mut user_defined = HashMap::new();
|
||||
user_defined.insert(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "AES256".to_string());
|
||||
user_defined.insert(
|
||||
rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER.to_string(),
|
||||
"AAAAAAAAAAAAAAAA".to_string(),
|
||||
);
|
||||
|
||||
let upload = set_disks
|
||||
.new_multipart_upload(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
user_defined: user_defined.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("multipart upload should be created");
|
||||
|
||||
let mut uploaded_parts = Vec::new();
|
||||
let mut expected_stored = Vec::new();
|
||||
for (index, (plaintext_len, overhead)) in part_shapes.iter().enumerate() {
|
||||
let stored = stored_representation_bytes(index as u32 * 7 + 1, plaintext_len + overhead);
|
||||
expected_stored.extend_from_slice(&stored);
|
||||
let stored_len = stored.len() as i64;
|
||||
let hash_reader =
|
||||
HashReader::from_stream(std::io::Cursor::new(stored), stored_len, *plaintext_len as i64, None, None, false)
|
||||
.expect("hash reader over the stored representation");
|
||||
let mut reader = PutObjReader::new(hash_reader);
|
||||
let info = set_disks
|
||||
.put_object_part(bucket, object, &upload.upload_id, index + 1, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("stored part should be staged");
|
||||
assert_eq!(info.size as i64, stored_len, "a part is stored in its encoded length");
|
||||
uploaded_parts.push(CompletePart {
|
||||
part_num: info.part_num,
|
||||
etag: info.etag,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let original = set_disks
|
||||
.clone()
|
||||
.complete_multipart_upload(bucket, object, &upload.upload_id, uploaded_parts, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("source multipart upload should complete");
|
||||
let original_parts: Vec<(usize, usize, i64, String)> = original
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
|
||||
.collect();
|
||||
for (_, size, actual_size, _) in &original_parts {
|
||||
assert!(
|
||||
*size as i64 > *actual_size,
|
||||
"the fixture must keep the two coordinate systems apart: stored {size} vs plaintext {actual_size}"
|
||||
);
|
||||
}
|
||||
let stored_before = read_stored_representation(&set_disks, bucket, object).await;
|
||||
assert_stored_representation_eq(&stored_before, &expected_stored, "the fixture must store its encoded bytes verbatim");
|
||||
|
||||
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||
register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
|
||||
set_disks
|
||||
.transition_object(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
transition: TransitionOptions {
|
||||
status: TRANSITION_PENDING.to_string(),
|
||||
tier: tier_name,
|
||||
etag: original.etag.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
version_id: original.version_id.map(|version| version.to_string()),
|
||||
mod_time: original.mod_time,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("multipart source should transition before restore");
|
||||
|
||||
let operation_id = Uuid::new_v4();
|
||||
set_disks
|
||||
.put_object_metadata(
|
||||
bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
eval_metadata: Some(restore_metadata(operation_id, true)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the restore generation should be installed");
|
||||
let mut restore_opts = ObjectOptions::default();
|
||||
restore_opts.transition.restore_request.days = Some(1);
|
||||
restore_opts.user_defined = restore_operation_id_metadata(operation_id);
|
||||
set_disks
|
||||
.clone()
|
||||
.restore_transitioned_object(bucket, object, &restore_opts)
|
||||
.await
|
||||
.expect("multipart restore should complete");
|
||||
|
||||
let stored_after = read_stored_representation(&set_disks, bucket, object).await;
|
||||
assert_stored_representation_eq(
|
||||
&stored_after,
|
||||
&expected_stored,
|
||||
"a multipart restore must copy the stored representation back verbatim",
|
||||
);
|
||||
|
||||
let restored = set_disks
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the restored object should be readable");
|
||||
let restored_parts: Vec<(usize, usize, i64, String)> = restored
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
restored_parts, original_parts,
|
||||
"restore must rebuild the same part layout, sizes and part ETags"
|
||||
);
|
||||
assert_eq!(restored.size, original.size, "restore must keep the stored object size");
|
||||
// backlog#2369 P7.1: the copy-back digests stored bytes, so the object's
|
||||
// public ETag has to be carried over rather than recomputed.
|
||||
assert_eq!(restored.etag, original.etag, "restore must preserve the object ETag");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn restore_failure_after_snapshot_cleans_exact_generation_and_returns_primary_error() {
|
||||
|
||||
@@ -18,7 +18,6 @@ use crate::services::rebalance::{REBAL_META_NAME, RebalStatus};
|
||||
use crate::set_disk::get_lock_acquire_timeout;
|
||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use rustfs_heal_contracts::heal_channel::DriveState;
|
||||
use rustfs_lock::NamespaceLockGuard;
|
||||
use std::collections::BTreeSet;
|
||||
use tracing::trace;
|
||||
@@ -379,79 +378,6 @@ impl ECStore {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Heal every pool metadata owner in the selected scope without allowing
|
||||
/// one healthy pool to hide another pool's failed repair.
|
||||
pub async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||
let scopes = self.heal_erasure_set_scopes(opts).await?;
|
||||
let mut results = Vec::new();
|
||||
for (pool_index, set_index) in scopes {
|
||||
if !self.replacement_pool_metadata_applies(pool_index, set_index)? {
|
||||
continue;
|
||||
}
|
||||
let set = &self.pools[pool_index].disk_set[set_index];
|
||||
let targets = set.set_endpoints.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
if targets.is_empty()
|
||||
|| targets.len() != set.set_drive_count
|
||||
|| targets.iter().collect::<BTreeSet<_>>().len() != targets.len()
|
||||
{
|
||||
return Err(Error::SlowDown);
|
||||
}
|
||||
// Administrative remove/no-lock options apply to user objects,
|
||||
// never to the cluster's authoritative metadata transaction.
|
||||
let metadata_opts = HealOpts {
|
||||
dry_run: opts.dry_run,
|
||||
recreate: opts.recreate,
|
||||
scan_mode: opts.scan_mode,
|
||||
pool: Some(pool_index),
|
||||
set: Some(set_index),
|
||||
..Default::default()
|
||||
};
|
||||
let (result, error) = self
|
||||
.handle_heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, "", &metadata_opts)
|
||||
.await?;
|
||||
if let Some(error) = error {
|
||||
return Err(error);
|
||||
}
|
||||
if !opts.dry_run {
|
||||
let ok_state = DriveState::Ok.to_string();
|
||||
let complete = result.after.drives.len() == targets.len()
|
||||
&& targets.iter().all(|target| {
|
||||
let mut outcomes = result.after.drives.iter().filter(|drive| drive.endpoint == *target);
|
||||
outcomes.next().is_some_and(|drive| drive.state == ok_state) && outcomes.next().is_none()
|
||||
});
|
||||
if !complete
|
||||
|| !set
|
||||
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, "", &targets)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::SlowDown);
|
||||
}
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Whether this replacement set owns the pool's metadata replica.
|
||||
///
|
||||
/// Pool metadata follows normal object placement within each pool. A valid
|
||||
/// non-owner set has no replica to repair; missing metadata on the owner
|
||||
/// set still requires healing and target-specific readback.
|
||||
pub fn replacement_pool_metadata_applies(&self, pool_index: usize, set_index: usize) -> Result<bool> {
|
||||
let pool = self
|
||||
.pools
|
||||
.get(pool_index)
|
||||
.ok_or_else(|| invalid_heal_pool_index(pool_index, self.pools.len()))?;
|
||||
let selected = pool.get_disks_for_heal_object(
|
||||
POOL_META_NAME,
|
||||
&HealOpts {
|
||||
set: Some(set_index),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
Ok(Arc::ptr_eq(&selected, &pool.get_disks_by_key(POOL_META_NAME)))
|
||||
}
|
||||
|
||||
#[instrument(skip(self, targets), fields(pool_index, set_index, target_count = targets.len()))]
|
||||
pub async fn replacement_targets_have_version(
|
||||
&self,
|
||||
@@ -903,249 +829,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_pool_metadata_applies_to_the_written_replica_in_each_pool() {
|
||||
let mut store = minimal_heal_store().await;
|
||||
for pool_index in 0..store.pools.len() {
|
||||
assert!(
|
||||
store
|
||||
.replacement_pool_metadata_applies(pool_index, 0)
|
||||
.expect("a valid single-set pool should have a metadata owner")
|
||||
);
|
||||
}
|
||||
store.ctx = Arc::new(InstanceContext::new());
|
||||
for algorithm in [
|
||||
crate::disk::format::DistributionAlgoVersion::V1,
|
||||
crate::disk::format::DistributionAlgoVersion::V2,
|
||||
crate::disk::format::DistributionAlgoVersion::V3,
|
||||
] {
|
||||
let mut temp_dirs = Vec::new();
|
||||
for pool_index in 0..store.pools.len() {
|
||||
let (dirs, mut pool) =
|
||||
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
|
||||
temp_dirs.extend(dirs);
|
||||
Arc::get_mut(&mut pool)
|
||||
.expect("fixture pool should have one owner")
|
||||
.distribution_algo = algorithm.clone();
|
||||
store.pools[pool_index] = pool;
|
||||
}
|
||||
for (pool_index, pool) in store.pools.iter().enumerate() {
|
||||
let mut required_sets = 0;
|
||||
for set_index in 0..pool.disk_set.len() {
|
||||
required_sets += usize::from(
|
||||
store
|
||||
.replacement_pool_metadata_applies(pool_index, set_index)
|
||||
.expect("valid replacement topology should be classified before metadata exists"),
|
||||
);
|
||||
}
|
||||
assert_eq!(required_sets, 1, "missing metadata cannot exempt the owner set");
|
||||
save_config(pool.clone(), POOL_META_NAME, b"pool metadata placement".to_vec())
|
||||
.await
|
||||
.expect("normal config writes should persist one metadata replica per pool");
|
||||
for (set_index, set) in pool.disk_set.iter().enumerate() {
|
||||
let applies = store
|
||||
.replacement_pool_metadata_applies(pool_index, set_index)
|
||||
.expect("valid replacement topology should be classified");
|
||||
let disks = set.disks.read().await.clone();
|
||||
for disk in disks.iter().flatten() {
|
||||
let replica = disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await;
|
||||
if applies {
|
||||
replica.expect("the metadata owner must match actual persisted shards");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(replica, Err(crate::disk::error::DiskError::FileNotFound)),
|
||||
"non-owner sets must have no persisted metadata shard; observed error: {:?}",
|
||||
replica.as_ref().err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.replacement_pool_metadata_applies(pool_index, pool.disk_set.len())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(store.replacement_pool_metadata_applies(store.pools.len(), 0).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn ordinary_pool_metadata_heal_repairs_each_owner_and_preserves_dry_run() {
|
||||
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
|
||||
let first_missing = remove_pool_meta_shard(&store, 0).await;
|
||||
let second_missing = remove_pool_meta_shard(&store, 1).await;
|
||||
let destructive_options = HealOpts {
|
||||
remove: true,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let results = store
|
||||
.heal_pool_metadata(&HealOpts {
|
||||
dry_run: true,
|
||||
..destructive_options
|
||||
})
|
||||
.await
|
||||
.expect("dry-run should inspect both metadata owners without requiring a commit");
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(
|
||||
first_missing
|
||||
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
second_missing
|
||||
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let lock = store.pools[0]
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||
.await
|
||||
.expect("metadata namespace lock should be available");
|
||||
let guard = lock
|
||||
.get_read_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("a metadata reader should hold the shared fence");
|
||||
let error = temp_env::async_with_vars(
|
||||
[(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))],
|
||||
store.heal_pool_metadata(&destructive_options),
|
||||
)
|
||||
.await
|
||||
.expect_err("administrative no-lock cannot bypass the metadata write fence");
|
||||
assert!(matches!(error, Error::Lock(rustfs_lock::LockError::Timeout { .. })));
|
||||
drop(guard);
|
||||
let results = store
|
||||
.heal_pool_metadata(&destructive_options)
|
||||
.await
|
||||
.expect("every metadata owner should be repaired");
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(first_missing.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_ok());
|
||||
assert!(
|
||||
second_missing
|
||||
.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn ordinary_pool_metadata_heal_does_not_hide_missing_later_pool() {
|
||||
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
|
||||
delete_config(store.pools[1].clone(), POOL_META_NAME)
|
||||
.await
|
||||
.expect("the second pool metadata replica should be removed");
|
||||
|
||||
let error = store
|
||||
.heal_pool_metadata(&HealOpts::default())
|
||||
.await
|
||||
.expect_err("the healthy first pool must not hide the second owner's missing replica");
|
||||
|
||||
assert!(!matches!(error, Error::NoHealRequired));
|
||||
let second_set = store.pools[1].get_disks_by_key(POOL_META_NAME);
|
||||
for disk in second_set.disks.read().await.iter().flatten() {
|
||||
assert!(disk.read_xl(RUSTFS_META_BUCKET, POOL_META_NAME, false).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_pool_metadata_heal_skips_only_valid_non_owner_sets() {
|
||||
let mut store = minimal_heal_store().await;
|
||||
store.ctx = Arc::new(InstanceContext::new());
|
||||
for algorithm in [
|
||||
crate::disk::format::DistributionAlgoVersion::V1,
|
||||
crate::disk::format::DistributionAlgoVersion::V2,
|
||||
crate::disk::format::DistributionAlgoVersion::V3,
|
||||
] {
|
||||
let mut temp_dirs = Vec::new();
|
||||
for pool_index in 0..store.pools.len() {
|
||||
let (dirs, mut pool) =
|
||||
crate::core::sets::make_local_two_set_sets_for_pool_with_ctx(Arc::clone(&store.ctx), pool_index).await;
|
||||
temp_dirs.extend(dirs);
|
||||
Arc::get_mut(&mut pool)
|
||||
.expect("fixture pool should have one owner")
|
||||
.distribution_algo = algorithm.clone();
|
||||
store.pools[pool_index] = pool;
|
||||
}
|
||||
for pool_index in 0..store.pools.len() {
|
||||
let owner = (0..store.pools[pool_index].disk_set.len())
|
||||
.find(|set_index| {
|
||||
store
|
||||
.replacement_pool_metadata_applies(pool_index, *set_index)
|
||||
.expect("valid metadata placement")
|
||||
})
|
||||
.expect("every pool must have one metadata owner");
|
||||
let non_owner = 1 - owner;
|
||||
assert!(
|
||||
store
|
||||
.heal_pool_metadata(&HealOpts {
|
||||
pool: Some(pool_index),
|
||||
set: Some(non_owner),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("valid non-owner should need no metadata write")
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.heal_pool_metadata(&HealOpts {
|
||||
pool: Some(pool_index),
|
||||
set: Some(owner),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.is_err(),
|
||||
"an owner with no authoritative metadata must fail"
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.heal_pool_metadata(&HealOpts {
|
||||
pool: Some(pool_index),
|
||||
set: Some(2),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.is_err(),
|
||||
"invalid sets cannot claim the non-owner exemption"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.heal_pool_metadata(&HealOpts {
|
||||
pool: Some(2),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn ordinary_pool_metadata_heal_requires_every_owner_endpoint() {
|
||||
let (_temp_dirs, store, _other_store) = test_two_pool_stores(None).await;
|
||||
let owner = store.pools[0].get_disks_by_key(POOL_META_NAME);
|
||||
let offline_disk = owner.disks.write().await[0]
|
||||
.take()
|
||||
.expect("fixture owner disk should start online");
|
||||
|
||||
let result = store
|
||||
.heal_pool_metadata(&HealOpts {
|
||||
pool: Some(0),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "a surviving metadata shard must not hide an offline owner endpoint");
|
||||
owner.disks.write().await[0] = Some(offline_disk);
|
||||
}
|
||||
|
||||
async fn remove_pool_meta_shard(store: &ECStore, pool_idx: usize) -> DiskStore {
|
||||
let target_set = store.pools[pool_idx].get_disks_by_key(POOL_META_NAME);
|
||||
let missing_disk = target_set.disks.read().await[0]
|
||||
|
||||
@@ -18932,124 +18932,6 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
fn tier_config_init_recovers_after_initial_reload_failure_without_another_mutation() {
|
||||
run_large_stack_async_test("tier-config-init-recovery", || async {
|
||||
use crate::services::tier::tier::{TIER_DRIVER_TEST_FACTORY, TierDriverTestFactory};
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create tier startup recovery store dir");
|
||||
let (ctx, store, _shutdown) = build_isolated_test_store(temp_dir.path(), "tier-startup-recovery", &[4]).await;
|
||||
let manager = ctx.tier_config_mgr();
|
||||
let candidate = TierConfigMgr::new();
|
||||
let tier_name = "STARTUP-RECOVERY";
|
||||
let backend = register_mock_tier(&candidate, tier_name).await;
|
||||
let backend_identity = TierConfigMgr::acquire_operation_lease(&candidate, tier_name)
|
||||
.await
|
||||
.expect("candidate backend identity should resolve")
|
||||
.backend_identity();
|
||||
let candidate_digest = {
|
||||
let candidate = candidate.read().await;
|
||||
candidate
|
||||
.save_tiering_config(store.clone())
|
||||
.await
|
||||
.expect("committed tier config should persist");
|
||||
tier_config_candidate_digest(&candidate).expect("committed candidate digest should build")
|
||||
};
|
||||
let config_path = format!("{}/{}", com::CONFIG_PREFIX, TIER_CONFIG_FILE);
|
||||
let config_bytes = com::read_config(store.clone(), &config_path)
|
||||
.await
|
||||
.expect("committed tier config bytes should load");
|
||||
let config_etag = store
|
||||
.get_object_info(RUSTFS_META_BUCKET, &config_path, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("committed tier config metadata should load")
|
||||
.etag
|
||||
.expect("committed tier config should have an ETag");
|
||||
let mutation_id = uuid::Uuid::new_v4();
|
||||
let intent = TierMutationIntent {
|
||||
mutation_id,
|
||||
revision: 2,
|
||||
kind: TierMutationIntentKind::Add,
|
||||
state: TierMutationIntentState::Committed,
|
||||
old_config_etag: None,
|
||||
committed_config_etag: Some(config_etag),
|
||||
candidate_digest,
|
||||
affected_targets: vec![TierMutationIntentTarget {
|
||||
tier_name: tier_name.to_string(),
|
||||
old_backend_identity: None,
|
||||
new_backend_identity: Some(backend_identity),
|
||||
}],
|
||||
expires_at_unix_nanos: 1,
|
||||
};
|
||||
// Persist the restart state directly: no peer Commit or runtime block
|
||||
// installation may leave a notification that masks a missing startup wakeup.
|
||||
save_tier_mutation_intent_record(store.clone(), &intent)
|
||||
.await
|
||||
.expect("committed restart intent should persist without notifying the manager");
|
||||
com::save_config(store.clone(), &config_path, vec![0])
|
||||
.await
|
||||
.expect("controlled invalid config should persist");
|
||||
|
||||
runtime_sources::TEST_TIER_CONFIG_MGR
|
||||
.scope(manager.clone(), async {
|
||||
let err = runtime_sources::init_tier_config_mgr(store.clone())
|
||||
.await
|
||||
.expect_err("initial reload must report the controlled config parse failure");
|
||||
assert!(err.to_string().contains("tierConfigInit: no data"), "unexpected startup failure: {err}");
|
||||
})
|
||||
.await;
|
||||
assert!(manager.read().await.tiers.is_empty(), "failed startup must not publish the candidate");
|
||||
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
|
||||
Ok(_) => panic!("failed startup must retain the recovered committed fence"),
|
||||
Err(err) => err,
|
||||
};
|
||||
assert!(TierConfigMgr::operation_lease_blocked_by_mutation(&blocked));
|
||||
|
||||
// Restore only the durable bytes. Recovery must come from the worker
|
||||
// started by init, after the test-only handle scope has already ended.
|
||||
com::save_config(store.clone(), &config_path, config_bytes)
|
||||
.await
|
||||
.expect("restoring committed config bytes should remove the startup failure");
|
||||
// Reload only publishes this Add into an empty manager, so it has
|
||||
// no replaced backend to construct or probe. Only the lease check
|
||||
// below needs a driver; keep its factory on the observing task.
|
||||
let driver_factory: TierDriverTestFactory = Arc::new(move |_| Ok(Box::new(backend.clone())));
|
||||
TIER_DRIVER_TEST_FACTORY
|
||||
.scope(driver_factory, async {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
match TierConfigMgr::acquire_operation_lease(&manager, tier_name).await {
|
||||
Ok(lease) => {
|
||||
drop(lease);
|
||||
match load_tier_mutation_intent_record(store.clone(), mutation_id).await {
|
||||
Err(Error::ConfigNotFound) => break,
|
||||
Ok(retained) => assert_eq!(retained, intent),
|
||||
Err(err) => panic!("committed recovery intent should remain readable: {err}"),
|
||||
}
|
||||
}
|
||||
Err(err) => assert!(
|
||||
TierConfigMgr::operation_lease_blocked_by_mutation(&err),
|
||||
"recovery must retain the fence until the tier becomes available: {err}"
|
||||
),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("startup worker must publish the tier and clean its fence without another mutation");
|
||||
})
|
||||
.await;
|
||||
let recovered = manager.read().await;
|
||||
assert_eq!(
|
||||
tier_config_candidate_digest(&recovered).expect("recovered config digest should build"),
|
||||
candidate_digest,
|
||||
"startup recovery must publish the committed configuration"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
|
||||
@@ -851,7 +851,7 @@ impl ErasureSetHealer {
|
||||
}
|
||||
|
||||
if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 {
|
||||
self.heal_pool_metadata(
|
||||
self.heal_replacement_pool_metadata(
|
||||
set_disk_id,
|
||||
&mut ErasureSetPassCounters {
|
||||
processed_objects: &mut processed_objects,
|
||||
@@ -950,43 +950,26 @@ impl ErasureSetHealer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn heal_pool_metadata(
|
||||
async fn heal_replacement_pool_metadata(
|
||||
&self,
|
||||
set_disk_id: &str,
|
||||
counters: &mut ErasureSetPassCounters<'_>,
|
||||
resume_manager: &ResumeManager,
|
||||
checkpoint_manager: &CheckpointManager,
|
||||
) -> Result<()> {
|
||||
let mut metadata_opts = self.heal_opts;
|
||||
metadata_opts.remove = false;
|
||||
metadata_opts.no_lock = false;
|
||||
if self.replacement_task_id.is_none() {
|
||||
let (pool_index, set_index) = crate::heal::utils::parse_set_disk_id(set_disk_id)?;
|
||||
if metadata_opts.pool.is_some_and(|pool| pool != pool_index) || metadata_opts.set.is_some_and(|set| set != set_index)
|
||||
{
|
||||
return Err(Error::TaskExecutionFailed {
|
||||
message: format!("Pool metadata scope does not match resumed set {set_disk_id}"),
|
||||
});
|
||||
}
|
||||
metadata_opts.pool = Some(pool_index);
|
||||
metadata_opts.set = Some(set_index);
|
||||
}
|
||||
let target_endpoints = if self.replacement_task_id.is_some() || self.pool_metadata_target_endpoints.is_empty() {
|
||||
let target_endpoints = if self.pool_metadata_target_endpoints.is_empty() {
|
||||
self.target_endpoints.as_ref()
|
||||
} else {
|
||||
self.pool_metadata_target_endpoints.as_ref()
|
||||
};
|
||||
let target_scoped_recreate = !metadata_opts.dry_run && metadata_opts.recreate && !target_endpoints.is_empty();
|
||||
let ordinary_heal = self.replacement_task_id.is_none() && !target_scoped_recreate;
|
||||
if !ordinary_heal {
|
||||
if target_endpoints.is_empty() {
|
||||
return Err(Error::TaskExecutionFailed {
|
||||
message: "Replacement pool metadata heal requires target endpoints".to_string(),
|
||||
});
|
||||
}
|
||||
if !self.storage.replacement_pool_metadata_applies(&metadata_opts).await? {
|
||||
return Ok(());
|
||||
}
|
||||
let target_scoped_recreate = !self.heal_opts.dry_run && self.heal_opts.recreate && !target_endpoints.is_empty();
|
||||
if self.replacement_task_id.is_none() && !target_scoped_recreate {
|
||||
return Ok(());
|
||||
}
|
||||
if target_endpoints.is_empty() {
|
||||
return Err(Error::TaskExecutionFailed {
|
||||
message: "Replacement pool metadata heal requires target endpoints".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}");
|
||||
@@ -1005,88 +988,61 @@ impl ErasureSetHealer {
|
||||
.set_current_item(Some(RUSTFS_META_BUCKET.to_string()), Some(POOL_META_NAME.to_string()))
|
||||
.await?;
|
||||
|
||||
let result = if ordinary_heal {
|
||||
match self.storage.heal_pool_metadata(&metadata_opts).await {
|
||||
Ok(results) if results.is_empty() => return Ok(()),
|
||||
Ok(results) => {
|
||||
let [result] = results.as_slice() else {
|
||||
return Err(Error::TaskExecutionFailed {
|
||||
message: format!("Pool metadata returned multiple replicas for set {set_disk_id}"),
|
||||
});
|
||||
};
|
||||
(result_object_size_u64(result), Ok(()))
|
||||
let result = match self
|
||||
.storage
|
||||
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts)
|
||||
.await
|
||||
{
|
||||
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
|
||||
let object_size = result_object_size_u64(&result);
|
||||
match self
|
||||
.storage
|
||||
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts, target_endpoints)
|
||||
.await
|
||||
{
|
||||
Ok(true) => (object_size, Ok(())),
|
||||
Ok(false) => (
|
||||
object_size,
|
||||
Err(Error::transient_skip(
|
||||
"Skipped replacement pool metadata heal because target readback did not confirm the committed version",
|
||||
)),
|
||||
),
|
||||
Err(err) => (
|
||||
object_size,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped replacement pool metadata heal because target readback failed: {err}"
|
||||
))),
|
||||
),
|
||||
}
|
||||
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
||||
Err(err) => match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent | HealObjectOutcome::Transient => {
|
||||
(0, Err(Error::transient_skip(format!("Pool metadata heal must be retried: {err}"))))
|
||||
}
|
||||
HealObjectOutcome::Failed => (0, Err(err)),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
match self
|
||||
.storage
|
||||
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &metadata_opts)
|
||||
.await
|
||||
{
|
||||
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
|
||||
let object_size = result_object_size_u64(&result);
|
||||
match self
|
||||
.storage
|
||||
.replacement_targets_have_version(
|
||||
RUSTFS_META_BUCKET,
|
||||
POOL_META_NAME,
|
||||
None,
|
||||
&metadata_opts,
|
||||
target_endpoints,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => (object_size, Ok(())),
|
||||
Ok(false) => (
|
||||
object_size,
|
||||
Err(Error::transient_skip(
|
||||
"Skipped replacement pool metadata heal because target readback did not confirm the committed version",
|
||||
)),
|
||||
),
|
||||
Err(err) => (
|
||||
object_size,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped replacement pool metadata heal because target readback failed: {err}"
|
||||
))),
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok((result, None)) => (
|
||||
result_object_size_u64(&result),
|
||||
Err(Error::transient_skip(
|
||||
"Skipped replacement pool metadata heal because a replacement target was not committed",
|
||||
)),
|
||||
),
|
||||
Ok((result, Some(err))) => {
|
||||
let object_size = result_object_size_u64(&result);
|
||||
match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
|
||||
object_size,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped replacement pool metadata heal due to transient error: {err}"
|
||||
))),
|
||||
),
|
||||
HealObjectOutcome::Failed => (object_size, Err(err)),
|
||||
}
|
||||
}
|
||||
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
||||
Err(err) => match Self::classify_heal_object_error(&err) {
|
||||
Ok((result, None)) => (
|
||||
result_object_size_u64(&result),
|
||||
Err(Error::transient_skip(
|
||||
"Skipped replacement pool metadata heal because a replacement target was not committed",
|
||||
)),
|
||||
),
|
||||
Ok((result, Some(err))) => {
|
||||
let object_size = result_object_size_u64(&result);
|
||||
match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
|
||||
0,
|
||||
object_size,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped replacement pool metadata heal due to transient error: {err}"
|
||||
))),
|
||||
),
|
||||
HealObjectOutcome::Failed => (0, Err(err)),
|
||||
},
|
||||
HealObjectOutcome::Failed => (object_size, Err(err)),
|
||||
}
|
||||
}
|
||||
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
||||
Err(err) => match Self::classify_heal_object_error(&err) {
|
||||
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
|
||||
0,
|
||||
Err(Error::transient_skip(format!(
|
||||
"Skipped replacement pool metadata heal due to transient error: {err}"
|
||||
))),
|
||||
),
|
||||
HealObjectOutcome::Failed => (0, Err(err)),
|
||||
},
|
||||
};
|
||||
|
||||
let (object_size, result) = result;
|
||||
@@ -1105,7 +1061,7 @@ impl ErasureSetHealer {
|
||||
bucket = RUSTFS_META_BUCKET,
|
||||
object = POOL_META_NAME,
|
||||
state = "healed",
|
||||
"Pool metadata healed"
|
||||
"Replacement pool metadata healed"
|
||||
);
|
||||
CheckpointObjectOutcome::Processed
|
||||
}
|
||||
@@ -1122,7 +1078,7 @@ impl ErasureSetHealer {
|
||||
object = POOL_META_NAME,
|
||||
state = "transient_skip",
|
||||
error = %message,
|
||||
"Pool metadata heal skipped due to transient error"
|
||||
"Replacement pool metadata heal skipped due to transient error"
|
||||
);
|
||||
CheckpointObjectOutcome::Skipped
|
||||
}
|
||||
@@ -1139,7 +1095,7 @@ impl ErasureSetHealer {
|
||||
object = POOL_META_NAME,
|
||||
state = "failed",
|
||||
error = %err,
|
||||
"Pool metadata heal failed"
|
||||
"Replacement pool metadata heal failed"
|
||||
);
|
||||
CheckpointObjectOutcome::Failed
|
||||
}
|
||||
@@ -1580,9 +1536,7 @@ impl ErasureSetHealer {
|
||||
);
|
||||
CheckpointObjectOutcome::Processed
|
||||
}
|
||||
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => {
|
||||
return Err(err);
|
||||
}
|
||||
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
||||
Err(Error::TransientSkip { message }) => {
|
||||
telemetry_unknown |= !increment_counter(skipped_objects);
|
||||
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||
@@ -2084,8 +2038,6 @@ mod resume_loop_tests {
|
||||
#[derive(Clone)]
|
||||
enum HealOutcome {
|
||||
Ok,
|
||||
/// The object has no metadata on any disk in the selected set.
|
||||
FileNotFound,
|
||||
/// The version vanished before heal ran (deleted mid-heal).
|
||||
VersionNotFound,
|
||||
/// A transient infrastructure condition (offline disk / unmet quorum):
|
||||
@@ -2111,10 +2063,6 @@ mod resume_loop_tests {
|
||||
/// Target-specific physical readback evidence per `compose_key`; the
|
||||
/// fake models a healthy backend unless a test explicitly revokes it.
|
||||
replacement_commit_evidence: Mutex<HashMap<String, ReplacementCommitEvidence>>,
|
||||
ordinary_pool_metadata_required: AtomicBool,
|
||||
pool_metadata_opts: Mutex<Vec<HealOpts>>,
|
||||
pool_metadata_not_applicable: AtomicBool,
|
||||
fail_pool_metadata_scope: AtomicBool,
|
||||
lifecycle_expired: Mutex<HashSet<String>>,
|
||||
/// every heal_object call recorded as (name, version_id)
|
||||
heal_calls: Mutex<Vec<(String, Option<String>)>>,
|
||||
@@ -2203,14 +2151,11 @@ mod resume_loop_tests {
|
||||
}
|
||||
async fn heal_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
_bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<&str>,
|
||||
opts: &HealOpts,
|
||||
_opts: &HealOpts,
|
||||
) -> Result<(HealResultItem, Option<Error>)> {
|
||||
if bucket == RUSTFS_META_BUCKET && object == POOL_META_NAME {
|
||||
self.pool_metadata_opts.lock().expect("metadata options").push(*opts);
|
||||
}
|
||||
self.heal_calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -2219,7 +2164,6 @@ mod resume_loop_tests {
|
||||
let outcome = self.outcomes.lock().unwrap().get(&key).cloned().unwrap_or(HealOutcome::Ok);
|
||||
match outcome {
|
||||
HealOutcome::Ok => Ok((self.results.lock().unwrap().get(&key).cloned().unwrap_or_default(), None)),
|
||||
HealOutcome::FileNotFound => Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileNotFound)))),
|
||||
HealOutcome::VersionNotFound => {
|
||||
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::FileVersionNotFound))))
|
||||
}
|
||||
@@ -2233,30 +2177,6 @@ mod resume_loop_tests {
|
||||
async fn heal_format(&self, _dry: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||
Ok((HealResultItem::default(), None))
|
||||
}
|
||||
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||
if !self.ordinary_pool_metadata_required.load(Ordering::SeqCst) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if !self.replacement_pool_metadata_applies(opts).await? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (result, error) = self.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, opts).await?;
|
||||
if let Some(error) = error {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(vec![result])
|
||||
}
|
||||
async fn replacement_pool_metadata_applies(&self, opts: &HealOpts) -> Result<bool> {
|
||||
if self.fail_pool_metadata_scope.load(Ordering::SeqCst) {
|
||||
return Err(Error::other("injected pool metadata scope failure"));
|
||||
}
|
||||
if self.pool_metadata_not_applicable.load(Ordering::SeqCst) {
|
||||
assert_eq!(opts.pool, Some(0));
|
||||
assert_eq!(opts.set, Some(1));
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
async fn replacement_targets_have_version(
|
||||
&self,
|
||||
_bucket: &str,
|
||||
@@ -2748,115 +2668,6 @@ mod resume_loop_tests {
|
||||
assert!(state.completed, "successful data heal must be persisted before cleanup is attempted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_set_heals_pool_metadata_without_replacement_generation_or_targets() {
|
||||
let env = make_env().await;
|
||||
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||
env.storage
|
||||
.set_result(POOL_META_NAME, None, replacement_target_ok_result("metadata-disk", POOL_META_NAME));
|
||||
assert!(env.healer.replacement_task_id.is_none());
|
||||
assert!(env.healer.target_endpoints.is_empty());
|
||||
|
||||
env.healer
|
||||
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||
.await
|
||||
.expect("ordinary set recovery should repair metadata even without user buckets");
|
||||
|
||||
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||
{
|
||||
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
|
||||
assert_eq!(opts.len(), 1);
|
||||
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
|
||||
}
|
||||
let state = env.resume.get_state().await;
|
||||
assert!(state.completed);
|
||||
assert_eq!(state.successful_objects, 1, "metadata must enter durable completion counters");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_set_pool_metadata_respects_non_owner_and_dry_run() {
|
||||
let mut env = make_env().await;
|
||||
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||
env.storage.pool_metadata_not_applicable.store(true, Ordering::SeqCst);
|
||||
env.healer.heal_opts.pool = Some(0);
|
||||
env.healer.heal_opts.set = Some(1);
|
||||
env.healer
|
||||
.execute_heal_with_resume(&[], "pool_0_set_1", &env.resume, &env.checkpoint)
|
||||
.await
|
||||
.expect("a valid non-owner set must not invent a metadata replica");
|
||||
assert!(env.storage.calls().is_empty());
|
||||
assert_eq!(env.resume.get_state().await.successful_objects, 0);
|
||||
|
||||
let mut env = make_env().await;
|
||||
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||
env.healer.heal_opts.dry_run = true;
|
||||
env.healer.heal_opts.remove = true;
|
||||
env.healer.heal_opts.no_lock = true;
|
||||
env.storage.set_replacement_commit_evidence(POOL_META_NAME, None, false);
|
||||
env.healer
|
||||
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||
.await
|
||||
.expect("ordinary dry-run metadata work must not require a replacement commit");
|
||||
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
|
||||
assert_eq!(opts.len(), 1);
|
||||
assert!(opts[0].dry_run);
|
||||
assert!(!opts[0].remove);
|
||||
assert!(!opts[0].no_lock);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_set_missing_pool_metadata_preserves_retry_state() {
|
||||
let env = make_env().await;
|
||||
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
|
||||
|
||||
let error = env
|
||||
.healer
|
||||
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||
.await
|
||||
.expect_err("missing required pool metadata must prevent ordinary set completion");
|
||||
|
||||
assert!(matches!(error, Error::TransientSkip { .. }));
|
||||
let state = env.resume.get_state().await;
|
||||
assert!(!state.completed);
|
||||
assert_eq!(state.retry_count, 1);
|
||||
assert!(CheckpointManager::has_checkpoint(&env.healer.disk, &env.task_id).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_set_pool_metadata_timeout_keeps_control_error() {
|
||||
let env = make_env().await;
|
||||
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::Timeout);
|
||||
|
||||
let error = env
|
||||
.healer
|
||||
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||
.await
|
||||
.expect_err("metadata timeout must abort the ordinary set pass");
|
||||
|
||||
assert!(matches!(error, Error::TaskTimeout));
|
||||
assert!(!env.resume.get_state().await.completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_set_pool_metadata_rejects_mismatched_explicit_scope() {
|
||||
let mut env = make_env().await;
|
||||
env.storage.ordinary_pool_metadata_required.store(true, Ordering::SeqCst);
|
||||
env.healer.heal_opts.pool = Some(1);
|
||||
|
||||
let error = env
|
||||
.healer
|
||||
.execute_heal_with_resume(&[], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||
.await
|
||||
.expect_err("explicit metadata scope must agree with the resumed set");
|
||||
|
||||
assert!(matches!(error, Error::TaskExecutionFailed { .. }));
|
||||
assert!(env.storage.calls().is_empty(), "scope mismatch must fail before metadata mutation");
|
||||
assert!(!env.resume.get_state().await.completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_completion_keeps_resume_artifacts_until_marker_cleanup() {
|
||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
@@ -2911,111 +2722,6 @@ mod resume_loop_tests {
|
||||
drop(checkpoint);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_pool_metadata_non_owner_completes_but_missing_owner_retries() {
|
||||
for owns_pool_metadata in [false, true] {
|
||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
let replacement_task_id = ResumeUtils::generate_task_id();
|
||||
let set_index = usize::from(!owns_pool_metadata);
|
||||
let set_disk_id = format!("pool_0_set_{set_index}");
|
||||
ResumeManager::new_replacement_intent(
|
||||
env.healer.disk.clone(),
|
||||
replacement_task_id.clone(),
|
||||
set_disk_id.clone(),
|
||||
vec!["b".to_string()],
|
||||
vec!["replacement-a".to_string()],
|
||||
vec![crate::heal::resume::ReplacementTargetIdentity {
|
||||
endpoint: "replacement-a".to_string(),
|
||||
canonical_path: "/mnt/replacement-a".to_string(),
|
||||
physical_device_ids: vec!["device-a".to_string()],
|
||||
filesystem_identity: "1:2:3".to_string(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("replacement intent should persist");
|
||||
env.storage
|
||||
.pool_metadata_not_applicable
|
||||
.store(!owns_pool_metadata, Ordering::SeqCst);
|
||||
env.storage.set_outcome(POOL_META_NAME, None, HealOutcome::FileNotFound);
|
||||
let healer = ErasureSetHealer::new(
|
||||
env.storage.clone(),
|
||||
Arc::new(RwLock::new(HealProgress::new())),
|
||||
CancellationToken::new(),
|
||||
env.healer.disk.clone(),
|
||||
HealOpts {
|
||||
pool: Some(0),
|
||||
set: Some(set_index),
|
||||
..Default::default()
|
||||
},
|
||||
HealRequestSource::AutoHeal,
|
||||
)
|
||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
|
||||
|
||||
let result = healer.heal_erasure_set(&["b".to_string()], &set_disk_id).await;
|
||||
let state = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
|
||||
.await
|
||||
.expect("replacement state must remain until marker cleanup")
|
||||
.get_state()
|
||||
.await;
|
||||
if owns_pool_metadata {
|
||||
let error = result.expect_err("missing metadata in the owner set must keep replacement incomplete");
|
||||
assert!(error.to_string().contains("Replacement erasure set heal incomplete"));
|
||||
assert!(!state.completed);
|
||||
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Intent);
|
||||
assert_eq!(state.retry_count, 1);
|
||||
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||
} else {
|
||||
result.expect("a non-owner set must complete without a pool metadata replica");
|
||||
assert!(state.completed);
|
||||
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Verified);
|
||||
assert_eq!(state.retry_count, 0);
|
||||
assert!(env.storage.calls().is_empty(), "non-owner sets must not attempt pool metadata repair");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_pool_metadata_unknown_scope_cannot_complete() {
|
||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
let healer = ErasureSetHealer::new(
|
||||
env.storage.clone(),
|
||||
Arc::new(RwLock::new(HealProgress::new())),
|
||||
CancellationToken::new(),
|
||||
env.healer.disk.clone(),
|
||||
HealOpts {
|
||||
pool: Some(0),
|
||||
set: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
HealRequestSource::AutoHeal,
|
||||
)
|
||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some("generation-a".to_string()));
|
||||
env.storage.fail_pool_metadata_scope.store(true, Ordering::SeqCst);
|
||||
env.storage
|
||||
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
|
||||
let mut processed_objects = 0;
|
||||
let mut successful_objects = 0;
|
||||
let mut failed_objects = 0;
|
||||
let mut skipped_objects = 0;
|
||||
let error = healer
|
||||
.heal_pool_metadata(
|
||||
"pool_0_set_0",
|
||||
&mut super::ErasureSetPassCounters {
|
||||
processed_objects: &mut processed_objects,
|
||||
successful_objects: &mut successful_objects,
|
||||
failed_objects: &mut failed_objects,
|
||||
skipped_objects: &mut skipped_objects,
|
||||
},
|
||||
&env.resume,
|
||||
&env.checkpoint,
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown metadata placement must keep replacement incomplete");
|
||||
assert!(error.to_string().contains("injected pool metadata scope failure"));
|
||||
assert!(env.storage.calls().is_empty());
|
||||
assert_eq!((processed_objects, successful_objects, failed_objects, skipped_objects), (0, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacement_pool_metadata_readback_failure_schedules_retry() {
|
||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
@@ -3075,8 +2781,8 @@ mod resume_loop_tests {
|
||||
env.healer.disk.clone(),
|
||||
HealOpts {
|
||||
recreate: true,
|
||||
remove: true,
|
||||
no_lock: true,
|
||||
pool: Some(0),
|
||||
set: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
HealRequestSource::Admin,
|
||||
@@ -3092,43 +2798,6 @@ mod resume_loop_tests {
|
||||
|
||||
assert!(env.resume.get_state().await.completed);
|
||||
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||
let opts = env.storage.pool_metadata_opts.lock().expect("metadata options");
|
||||
assert_eq!(opts.len(), 1);
|
||||
assert_eq!((opts[0].pool, opts[0].set), (Some(0), Some(0)));
|
||||
assert!(opts[0].recreate);
|
||||
assert!(!opts[0].remove);
|
||||
assert!(!opts[0].no_lock);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_recreate_pool_metadata_validates_owner_scope_before_io() {
|
||||
for (non_owner, unknown_scope, pool) in [(true, false, None), (false, true, None), (false, false, Some(1))] {
|
||||
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||
env.storage.pool_metadata_not_applicable.store(non_owner, Ordering::SeqCst);
|
||||
env.storage.fail_pool_metadata_scope.store(unknown_scope, Ordering::SeqCst);
|
||||
let healer = ErasureSetHealer::new(
|
||||
env.storage.clone(),
|
||||
Arc::new(RwLock::new(HealProgress::new())),
|
||||
CancellationToken::new(),
|
||||
env.healer.disk.clone(),
|
||||
HealOpts {
|
||||
recreate: true,
|
||||
pool,
|
||||
..Default::default()
|
||||
},
|
||||
HealRequestSource::Admin,
|
||||
)
|
||||
.with_pool_metadata_targets(vec!["replacement-a".to_string()]);
|
||||
|
||||
let set_disk_id = if non_owner { "pool_0_set_1" } else { "pool_0_set_0" };
|
||||
let result = healer
|
||||
.execute_heal_with_resume(&[], set_disk_id, &env.resume, &env.checkpoint)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.is_ok(), non_owner, "only a known non-owner may skip metadata: {result:?}");
|
||||
assert!(env.storage.calls().is_empty(), "scope validation must precede metadata I/O");
|
||||
assert_eq!(env.resume.get_state().await.completed, non_owner);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -555,10 +555,6 @@ async fn completed_retention_scheduler_preserves_progress_aliases_and_atomic_han
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl HealStorageAPI for MockStorage {
|
||||
async fn heal_pool_metadata(&self, _opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_object_meta(&self, _bucket: &str, _object: &str) -> Result<Option<HealObjectInfo>> {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -1873,7 +1869,7 @@ async fn test_submit_heal_request_returns_merged_for_duplicate() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let manager = HealManager::new(storage, None);
|
||||
|
||||
let mut request = HealRequest::new(
|
||||
let request = HealRequest::new(
|
||||
HealType::Object {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
@@ -1890,7 +1886,6 @@ async fn test_submit_heal_request_returns_merged_for_duplicate() {
|
||||
.expect("first request should be accepted"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
request.id = uuid::Uuid::new_v4().to_string();
|
||||
assert_eq!(
|
||||
manager
|
||||
.submit_heal_request(request)
|
||||
@@ -3730,7 +3725,7 @@ async fn test_submit_heal_request_returns_merged_before_full_for_duplicate() {
|
||||
}),
|
||||
);
|
||||
|
||||
let mut request = HealRequest::new(
|
||||
let request = HealRequest::new(
|
||||
HealType::Object {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
@@ -3747,7 +3742,6 @@ async fn test_submit_heal_request_returns_merged_before_full_for_duplicate() {
|
||||
.expect("first request should be accepted"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
request.id = uuid::Uuid::new_v4().to_string();
|
||||
assert_eq!(
|
||||
manager
|
||||
.submit_heal_request(request)
|
||||
|
||||
+145
-368
@@ -38,7 +38,7 @@ use crate::heal::manager::{HealManager, MrfRepairNoticeTarget};
|
||||
use metrics::{counter, gauge};
|
||||
use rustfs_common::mrf_channel::{MRF_MAX_ATTEMPTS, MrfDurableRepairAnchor, MrfIngressResult, MrfIntent};
|
||||
use rustfs_heal_contracts::heal_channel::{HealAdmissionDropReason, HealAdmissionResult};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -134,10 +134,7 @@ struct MrfQueueKey {
|
||||
}
|
||||
|
||||
fn queue_key(intent: &MrfIntent) -> MrfQueueKey {
|
||||
let version_id = (!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption))
|
||||
.then_some(intent.version_id)
|
||||
.flatten()
|
||||
.filter(|bytes| *bytes != [0; 16]);
|
||||
let version_id = intent.version_id.filter(|bytes| *bytes != [0; 16]);
|
||||
let scope = (!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption))
|
||||
.then_some(intent.scope)
|
||||
.flatten();
|
||||
@@ -542,10 +539,6 @@ struct MrfRuntime {
|
||||
/// Partial-write responsibilities accepted from replay and waiting for an
|
||||
/// exact storage-owned proof before the startup journal can be deleted.
|
||||
durable_replay_anchors: Vec<MrfDurableRepairAnchor>,
|
||||
/// Startup responsibilities remain in every successor snapshot until an
|
||||
/// exact verified repair discharges them. Live admissions never grow this
|
||||
/// set, so its size is bounded by the decoded startup journal.
|
||||
retained_replay_intents: HashMap<MrfQueueKey, MrfIntent>,
|
||||
/// Startup replay source to remove after the retained replay
|
||||
/// responsibilities are discharged. `None` means the runtime only needs
|
||||
/// the legacy journal cleanup path for snapshots it wrote itself.
|
||||
@@ -557,92 +550,24 @@ struct MrfRuntime {
|
||||
}
|
||||
|
||||
impl MrfRuntime {
|
||||
fn enqueue_batch(&mut self, intents: impl IntoIterator<Item = MrfIntent>) -> usize {
|
||||
// Reserve retained startup work before admitting live hints. Computing
|
||||
// the union once per batch avoids scanning it for every incoming hint.
|
||||
let mut snapshot_count = self.queue.depth();
|
||||
let mut snapshot_bytes = self.queue.bytes();
|
||||
for (key, intent) in &self.retained_replay_intents {
|
||||
if !self.queue.pending_keys.contains(key) {
|
||||
snapshot_count = snapshot_count.saturating_add(1);
|
||||
snapshot_bytes = snapshot_bytes.saturating_add(intent.estimated_bytes());
|
||||
}
|
||||
}
|
||||
let mut enqueued = 0;
|
||||
for intent in intents {
|
||||
let key = queue_key(&intent);
|
||||
let retained = self.retained_replay_intents.get(&key);
|
||||
let additional = retained.is_none() && !self.queue.pending_keys.contains(&key);
|
||||
let next_count = snapshot_count.saturating_add(usize::from(additional));
|
||||
let next_bytes = snapshot_bytes.saturating_add(if additional { intent.estimated_bytes() } else { 0 });
|
||||
let result = if retained.is_some_and(|retained| retained.lease != intent.lease)
|
||||
|| next_count > self.queue.capacity
|
||||
|| next_bytes > self.queue.byte_budget
|
||||
{
|
||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "queue_overflow").increment(1);
|
||||
MrfQueuePushResult::Rejected
|
||||
} else {
|
||||
self.queue.try_push_typed(intent.clone())
|
||||
};
|
||||
match result {
|
||||
MrfQueuePushResult::Enqueued => {
|
||||
snapshot_count = next_count;
|
||||
snapshot_bytes = next_bytes;
|
||||
enqueued += 1;
|
||||
self.new_since_flush += 1;
|
||||
self.dirty = true;
|
||||
}
|
||||
MrfQueuePushResult::Coalesced | MrfQueuePushResult::Rejected => {
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
}
|
||||
}
|
||||
enqueued
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
fn snapshot(&self) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut authoritative = Vec::new();
|
||||
let mut legacy = Vec::new();
|
||||
let mut encoded = HashSet::new();
|
||||
for intent in self.queue.intents().chain(self.retained_replay_intents.values()) {
|
||||
let key = queue_key(intent);
|
||||
if self
|
||||
.retained_replay_intents
|
||||
.get(&key)
|
||||
.is_some_and(|retained| retained.lease != intent.lease)
|
||||
{
|
||||
// Legacy records cannot distinguish concurrent responsibilities
|
||||
// with different leases. Preserve the existing disk anchor.
|
||||
return None;
|
||||
}
|
||||
if !encoded.insert(key) {
|
||||
continue;
|
||||
}
|
||||
if encoded.len() > self.queue.capacity {
|
||||
return None;
|
||||
}
|
||||
for intent in self.queue.intents() {
|
||||
let scoped_identity =
|
||||
!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption) && intent.scope.is_some();
|
||||
if !encode_intent(intent, &mut authoritative) {
|
||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "journal_identity_oversized").increment(1);
|
||||
return None;
|
||||
}
|
||||
if authoritative.len() > self.queue.byte_budget {
|
||||
return None;
|
||||
}
|
||||
if !scoped_identity && !encode_intent(intent, &mut legacy) {
|
||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "journal_identity_oversized").increment(1);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((authoritative, legacy))
|
||||
(authoritative, legacy)
|
||||
}
|
||||
|
||||
async fn flush(&mut self) {
|
||||
let Some((authoritative, legacy)) = self.snapshot() else {
|
||||
self.dirty = true;
|
||||
return;
|
||||
};
|
||||
let (authoritative, legacy) = self.snapshot();
|
||||
let (committed_persisted, committed_on_disk) = if authoritative.is_empty() {
|
||||
(true, false)
|
||||
} else {
|
||||
@@ -709,31 +634,11 @@ impl MrfRuntime {
|
||||
// attempts counter) changes the encoded snapshot; mark it dirty
|
||||
// either way.
|
||||
self.dirty = true;
|
||||
let replay_key = queue_key(&intent);
|
||||
let replayed = self
|
||||
.retained_replay_intents
|
||||
.get(&replay_key)
|
||||
.is_some_and(|retained| retained.lease == intent.lease);
|
||||
if replayed {
|
||||
if !matches!(
|
||||
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut intent),
|
||||
MrfIngressResult::Enqueued
|
||||
) {
|
||||
self.retain_replay_journal = true;
|
||||
self.queue.push_back(intent);
|
||||
self.backoff_until = Some(tokio::time::Instant::now() + self.config.admission_backoff);
|
||||
break;
|
||||
}
|
||||
self.retained_replay_intents.insert(replay_key, intent.clone());
|
||||
}
|
||||
match submit_mrf_heal_request(manager, &intent).await {
|
||||
// Admission removes executable work from the pending queue,
|
||||
// but startup responsibilities still require an exact proof.
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {
|
||||
if replayed && let Some(anchor) = manager.durable_mrf_repair_anchor(&intent).await {
|
||||
self.durable_replay_anchors.push(anchor);
|
||||
}
|
||||
}
|
||||
// Accepted intents leave the pending set; the next flush persists the
|
||||
// smaller snapshot. This is not a durable successor receipt and
|
||||
// does not discharge the producer's existing retry hints.
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {}
|
||||
Ok(HealAdmissionResult::Full) | Ok(HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)) => {
|
||||
intent.attempts = intent.attempts.saturating_add(1);
|
||||
if intent.attempts >= MRF_MAX_ATTEMPTS {
|
||||
@@ -767,7 +672,7 @@ impl MrfRuntime {
|
||||
}
|
||||
|
||||
fn retained_replay_journal(&self) -> bool {
|
||||
self.retain_replay_journal || !self.retained_replay_intents.is_empty()
|
||||
self.retain_replay_journal || !self.durable_replay_anchors.is_empty()
|
||||
}
|
||||
|
||||
fn replay_cleanup_to_delete(&self) -> Option<ReplayCleanup> {
|
||||
@@ -813,7 +718,6 @@ impl MrfRuntime {
|
||||
if self.durable_replay_anchors.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut discharged_leases: HashSet<_> = self.durable_replay_anchors.iter().map(|anchor| anchor.lease).collect();
|
||||
let mut buckets: Vec<Arc<str>> = self
|
||||
.durable_replay_anchors
|
||||
.iter()
|
||||
@@ -827,13 +731,6 @@ impl MrfRuntime {
|
||||
&mut self.durable_replay_anchors,
|
||||
);
|
||||
}
|
||||
for anchor in &self.durable_replay_anchors {
|
||||
discharged_leases.remove(&anchor.lease);
|
||||
}
|
||||
let before = self.retained_replay_intents.len();
|
||||
self.retained_replay_intents
|
||||
.retain(|_, intent| !intent.lease.is_some_and(|lease| discharged_leases.contains(&lease)));
|
||||
self.dirty |= self.retained_replay_intents.len() != before;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,7 +783,6 @@ struct ReplayOutcome {
|
||||
journal_on_disk: bool,
|
||||
retain_journal_for_replay: bool,
|
||||
durable_replay_anchors: Vec<MrfDurableRepairAnchor>,
|
||||
retained_replay_intents: HashMap<MrfQueueKey, MrfIntent>,
|
||||
cleanup: Option<ReplayCleanup>,
|
||||
next_checkpoint_sequence: u64,
|
||||
}
|
||||
@@ -974,7 +870,6 @@ async fn replay_into(
|
||||
journal_on_disk: false,
|
||||
retain_journal_for_replay: false,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
retained_replay_intents: HashMap::new(),
|
||||
cleanup: None,
|
||||
next_checkpoint_sequence: 1,
|
||||
};
|
||||
@@ -990,7 +885,6 @@ async fn replay_into(
|
||||
journal_on_disk: true,
|
||||
retain_journal_for_replay: true,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
retained_replay_intents: HashMap::new(),
|
||||
cleanup: None,
|
||||
next_checkpoint_sequence: 1,
|
||||
};
|
||||
@@ -1035,7 +929,6 @@ async fn replay_into(
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut retained_replay_intents: HashMap<_, _> = queue.intents().map(|intent| (queue_key(intent), intent.clone())).collect();
|
||||
|
||||
// Drain the replayed intents immediately; whatever the manager refuses
|
||||
// stays armed in `queue` for the consumer's retry loop.
|
||||
@@ -1050,7 +943,6 @@ async fn replay_into(
|
||||
*backoff_until = Some(tokio::time::Instant::now());
|
||||
break;
|
||||
}
|
||||
retained_replay_intents.insert(queue_key(&intent), intent.clone());
|
||||
match submit_mrf_heal_request(manager, &intent).await {
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {
|
||||
if let Some(anchor) = manager.durable_mrf_repair_anchor(&intent).await {
|
||||
@@ -1072,7 +964,6 @@ async fn replay_into(
|
||||
break;
|
||||
}
|
||||
Ok(HealAdmissionResult::Dropped(_)) => {
|
||||
retained_replay_intents.remove(&queue_key(&intent));
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -1107,7 +998,6 @@ async fn replay_into(
|
||||
journal_on_disk,
|
||||
retain_journal_for_replay,
|
||||
durable_replay_anchors,
|
||||
retained_replay_intents,
|
||||
cleanup: journal_on_disk.then_some(cleanup),
|
||||
next_checkpoint_sequence,
|
||||
}
|
||||
@@ -1127,7 +1017,6 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
journal_on_disk: false,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
retained_replay_intents: HashMap::new(),
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
@@ -1139,7 +1028,6 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
runtime.journal_on_disk = replay.journal_on_disk;
|
||||
runtime.retain_replay_journal = replay.retain_journal_for_replay;
|
||||
runtime.durable_replay_anchors = replay.durable_replay_anchors;
|
||||
runtime.retained_replay_intents = replay.retained_replay_intents;
|
||||
runtime.replay_cleanup = replay.cleanup;
|
||||
runtime.next_checkpoint_sequence = replay.next_checkpoint_sequence;
|
||||
// Anything still pending (e.g. the manager was full and backoff armed)
|
||||
@@ -1168,7 +1056,17 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
);
|
||||
return;
|
||||
}
|
||||
runtime.enqueue_batch(batch.drain(..));
|
||||
for intent in batch.drain(..) {
|
||||
match runtime.queue.try_push_typed(intent.clone()) {
|
||||
MrfQueuePushResult::Enqueued => {
|
||||
runtime.new_since_flush += 1;
|
||||
runtime.dirty = true;
|
||||
}
|
||||
MrfQueuePushResult::Coalesced | MrfQueuePushResult::Rejected => {
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.dispatch(manager.as_ref()).await;
|
||||
if runtime.new_since_flush >= runtime.config.flush_threshold {
|
||||
runtime.flush().await;
|
||||
@@ -1240,6 +1138,7 @@ fn tick_action(dirty: bool, depth: usize, journal_on_disk: bool, retain_replay_j
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustfs_common::mrf_channel::{MrfIntent, MrfKind, MrfVerifiedRepairDisposition, MrfVerifiedRepairEvent};
|
||||
use serial_test::serial;
|
||||
use std::sync::Arc as StdArc;
|
||||
|
||||
fn intent(bucket: &str, object: &str, attempts: u8) -> MrfIntent {
|
||||
@@ -1255,6 +1154,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn encoded_payload(intent: &MrfIntent) -> Vec<u8> {
|
||||
let mut payload = Vec::new();
|
||||
assert!(encode_intent(intent, &mut payload), "fixture intent must encode");
|
||||
payload
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_action_table() {
|
||||
use TickAction::*;
|
||||
@@ -1327,7 +1232,6 @@ mod tests {
|
||||
journal_on_disk: true,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: vec![anchor],
|
||||
retained_replay_intents: HashMap::from([(queue_key(&intent), intent.clone())]),
|
||||
replay_cleanup: Some(cleanup),
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
@@ -1347,11 +1251,6 @@ mod tests {
|
||||
runtime.retained_replay_journal(),
|
||||
"anchor must retain the startup journal before proof is consumed"
|
||||
);
|
||||
assert_eq!(
|
||||
decode_journal(&runtime.snapshot().expect("retained snapshot").0).0.len(),
|
||||
1,
|
||||
"an admitted responsibility must remain in the successor before proof"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.replay_cleanup_to_delete(),
|
||||
None,
|
||||
@@ -1362,8 +1261,6 @@ mod tests {
|
||||
!runtime.retained_replay_journal(),
|
||||
"matching verified proof discharges the durable replay anchor"
|
||||
);
|
||||
assert!(runtime.dirty, "proof removal must be persisted by the next flush");
|
||||
assert!(runtime.snapshot().expect("discharged snapshot").0.is_empty());
|
||||
assert_eq!(
|
||||
runtime.replay_cleanup_to_delete(),
|
||||
Some(cleanup),
|
||||
@@ -1372,197 +1269,6 @@ mod tests {
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_snapshot_retains_admitted_replay_and_pending_successor() {
|
||||
let accepted = intent("snapshot-bucket", "accepted", 0);
|
||||
let pending = intent("snapshot-bucket", "pending", 2);
|
||||
let mut queue = MrfQueue::new(4, 4096);
|
||||
assert!(queue.try_push(pending.clone()));
|
||||
let runtime = MrfRuntime {
|
||||
queue,
|
||||
config: MrfConsumerConfig::default(),
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: 1,
|
||||
new_since_flush: 0,
|
||||
dirty: true,
|
||||
journal_on_disk: true,
|
||||
retain_replay_journal: true,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
retained_replay_intents: HashMap::from([
|
||||
(queue_key(&accepted), accepted),
|
||||
(queue_key(&pending), intent("snapshot-bucket", "pending", 0)),
|
||||
]),
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
};
|
||||
|
||||
let (authoritative, legacy) = runtime.snapshot().expect("complete successor should fit");
|
||||
for snapshot in [authoritative, legacy] {
|
||||
let (recovered, truncated) = decode_journal(&snapshot);
|
||||
assert_eq!(truncated, 0);
|
||||
assert_eq!(recovered.len(), 2, "admission must not discard an unproven startup responsibility");
|
||||
assert!(recovered.iter().any(|intent| intent.object.as_ref() == "accepted"));
|
||||
assert!(
|
||||
recovered
|
||||
.iter()
|
||||
.any(|intent| intent.object.as_ref() == "pending" && intent.attempts == 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_snapshot_preserves_anchor_when_successor_exceeds_budget_or_changes_lease() {
|
||||
let mut retained = intent("bounded-bucket", "retained", 0);
|
||||
assert_eq!(
|
||||
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut retained),
|
||||
MrfIngressResult::Enqueued
|
||||
);
|
||||
let mut queue = MrfQueue::new(1, 4096);
|
||||
assert!(queue.try_push(intent("bounded-bucket", "pending", 0)));
|
||||
let mut runtime = MrfRuntime {
|
||||
queue,
|
||||
config: MrfConsumerConfig::default(),
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: 1,
|
||||
new_since_flush: 0,
|
||||
dirty: true,
|
||||
journal_on_disk: true,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
retained_replay_intents: HashMap::from([(queue_key(&retained), retained.clone())]),
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
};
|
||||
assert!(runtime.snapshot().is_none(), "combined count must honor the queue ceiling");
|
||||
runtime.queue.capacity = 2;
|
||||
runtime.queue.byte_budget = 1;
|
||||
assert!(runtime.snapshot().is_none(), "oversized successor must not replace the startup journal");
|
||||
|
||||
runtime.queue = MrfQueue::new(2, 4096);
|
||||
let mut newer = retained.clone();
|
||||
newer.lease = None;
|
||||
assert_eq!(
|
||||
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut newer),
|
||||
MrfIngressResult::Enqueued
|
||||
);
|
||||
assert_ne!(retained.lease, newer.lease);
|
||||
assert!(runtime.queue.try_push(newer));
|
||||
assert!(
|
||||
runtime.snapshot().is_none(),
|
||||
"legacy encoding cannot conflate distinct responsibilities for one object"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_admission_reserves_replay_budget_until_verified_repair() {
|
||||
for count_limited in [true, false] {
|
||||
let mut retained = intent("reserved-bucket", "retained", 0);
|
||||
assert_eq!(
|
||||
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut retained),
|
||||
MrfIngressResult::Enqueued
|
||||
);
|
||||
let incarnation = Uuid::new_v4();
|
||||
let anchor = MrfDurableRepairAnchor::from_intent(&retained, incarnation).expect("replay anchor");
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(if count_limited { 1 } else { 2 }, retained.estimated_bytes()),
|
||||
config: MrfConsumerConfig::default(),
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: 1,
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: true,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: vec![anchor],
|
||||
retained_replay_intents: HashMap::from([(queue_key(&retained), retained.clone())]),
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
};
|
||||
if count_limited {
|
||||
runtime.queue.byte_budget = 4096;
|
||||
}
|
||||
let pending = intent("reserved-bucket", "pending", 0);
|
||||
assert_eq!(runtime.enqueue_batch([pending.clone()]), 0, "retained work consumes admission budget");
|
||||
assert_eq!(runtime.queue.depth(), 0);
|
||||
let (snapshot, _) = runtime.snapshot().expect("rejection must leave a writable retained snapshot");
|
||||
let (recovered, truncated) = decode_journal(&snapshot);
|
||||
assert_eq!(truncated, 0);
|
||||
assert_eq!(recovered.len(), 1);
|
||||
assert_eq!(recovered[0].object.as_ref(), "retained");
|
||||
|
||||
rustfs_common::mrf_channel::note_mrf_verified_repair(MrfVerifiedRepairEvent {
|
||||
kind: retained.kind,
|
||||
bucket: retained.bucket.clone(),
|
||||
object: retained.object.clone(),
|
||||
version_id: retained.version_id,
|
||||
scope: retained.scope,
|
||||
lease: retained.lease,
|
||||
bucket_incarnation_id: incarnation,
|
||||
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||
});
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert_eq!(runtime.enqueue_batch([pending]), 1, "proof must release admission capacity for retry");
|
||||
let (snapshot, _) = runtime.snapshot().expect("new admitted work must be persistable");
|
||||
let (recovered, truncated) = decode_journal(&snapshot);
|
||||
assert_eq!(truncated, 0);
|
||||
assert_eq!(recovered.len(), 1);
|
||||
assert_eq!(recovered[0].object.as_ref(), "pending");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_admission_rejects_new_lease_without_blocking_other_successors() {
|
||||
let mut retained = intent("lease-bucket", "retained", 0);
|
||||
assert_eq!(
|
||||
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut retained),
|
||||
MrfIngressResult::Enqueued
|
||||
);
|
||||
let incarnation = Uuid::new_v4();
|
||||
let anchor = MrfDurableRepairAnchor::from_intent(&retained, incarnation).expect("replay anchor");
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(2, 4096),
|
||||
config: MrfConsumerConfig::default(),
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: 1,
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: true,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: vec![anchor],
|
||||
retained_replay_intents: HashMap::from([(queue_key(&retained), retained.clone())]),
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
};
|
||||
let mut newer = retained.clone();
|
||||
newer.lease = None;
|
||||
assert_eq!(
|
||||
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut newer),
|
||||
MrfIngressResult::Enqueued
|
||||
);
|
||||
assert_ne!(newer.lease, retained.lease);
|
||||
assert_eq!(runtime.enqueue_batch([newer.clone(), intent("lease-bucket", "pending", 0)]), 1);
|
||||
assert_eq!(runtime.queue.intents().next().expect("unrelated successor").object.as_ref(), "pending");
|
||||
assert_eq!(decode_journal(&runtime.snapshot().expect("unblocked successor").0).0.len(), 2);
|
||||
|
||||
rustfs_common::mrf_channel::note_mrf_verified_repair(MrfVerifiedRepairEvent {
|
||||
kind: retained.kind,
|
||||
bucket: retained.bucket.clone(),
|
||||
object: retained.object.clone(),
|
||||
version_id: retained.version_id,
|
||||
scope: retained.scope,
|
||||
lease: retained.lease,
|
||||
bucket_incarnation_id: incarnation,
|
||||
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||
});
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert_eq!(runtime.enqueue_batch([newer.clone()]), 1);
|
||||
assert!(runtime.queue.intents().any(|intent| intent.lease == newer.lease));
|
||||
assert_eq!(decode_journal(&runtime.snapshot().expect("new lease successor").0).0.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_cleanup_defaults_to_legacy_for_runtime_written_journals() {
|
||||
let runtime = MrfRuntime {
|
||||
@@ -1575,7 +1281,6 @@ mod tests {
|
||||
journal_on_disk: true,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
retained_replay_intents: HashMap::new(),
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
@@ -1588,6 +1293,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial]
|
||||
async fn runtime_idle_cleanup_deletes_runtime_and_replay_recovery_anchors() {
|
||||
let _env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||
.prefix("rustfs_mrf_runtime_idle_cleanup")
|
||||
.build()
|
||||
.await;
|
||||
let disks = journal_disks().await;
|
||||
assert!(!disks.is_empty(), "test environment must register local disks");
|
||||
|
||||
let replay_owner = Uuid::new_v4();
|
||||
let runtime_owner = Uuid::new_v4();
|
||||
let config = MrfConsumerConfig::default();
|
||||
let journal_max_bytes = config.journal_max_bytes;
|
||||
let replay_payload = encoded_payload(&intent("cleanup-bucket", "replay-object", 0));
|
||||
let runtime_payload = encoded_payload(&intent("cleanup-bucket", "runtime-object", 0));
|
||||
snapshot::publish_committed_snapshot(&disks, replay_owner, 7, &replay_payload, journal_max_bytes)
|
||||
.await
|
||||
.expect("publish retained replay checkpoint");
|
||||
snapshot::publish_committed_snapshot(&disks, runtime_owner, 8, &runtime_payload, journal_max_bytes)
|
||||
.await
|
||||
.expect("publish runtime checkpoint");
|
||||
assert!(write_journal(MRF_SCOPED_JOURNAL_PATH, &runtime_payload).await);
|
||||
assert!(write_journal(MRF_JOURNAL_PATH, &runtime_payload).await);
|
||||
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(2, usize::MAX),
|
||||
config,
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: 9,
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: true,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
replay_cleanup: Some(ReplayCleanup::Committed {
|
||||
owner: replay_owner,
|
||||
sequence: 7,
|
||||
}),
|
||||
runtime_checkpoint: Some((runtime_owner, 8)),
|
||||
backoff_until: None,
|
||||
};
|
||||
|
||||
assert!(
|
||||
runtime.delete_idle_recovery_anchors().await,
|
||||
"idle cleanup should remove both runtime and replay recovery anchors"
|
||||
);
|
||||
assert_eq!(runtime.replay_cleanup, None);
|
||||
assert_eq!(runtime.runtime_checkpoint, None);
|
||||
assert!(
|
||||
snapshot::inspect_local_committed_snapshot(journal_max_bytes)
|
||||
.await
|
||||
.expect("inspect committed checkpoints after cleanup")
|
||||
.is_none(),
|
||||
"both committed checkpoint generations must be gone after idle cleanup"
|
||||
);
|
||||
assert_eq!(read_journal(MRF_SCOPED_JOURNAL_PATH).await, None);
|
||||
assert_eq!(read_journal(MRF_JOURNAL_PATH).await, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_replay_acquires_a_fresh_lease_before_manager_admission() {
|
||||
let unique = uuid::Uuid::new_v4();
|
||||
@@ -1622,50 +1387,6 @@ mod tests {
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&replay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_replay_canonicalization_preserves_one_bounded_responsibility() {
|
||||
let mut legacy = intent("metadata-replay-bucket", "object", 0);
|
||||
legacy.kind = MrfKind::MetadataCorruption;
|
||||
let mut bytes = Vec::new();
|
||||
assert!(encode_intent(&legacy, &mut bytes));
|
||||
let (mut decoded, truncated) = decode_journal(&bytes);
|
||||
assert_eq!(truncated, 0);
|
||||
let mut replay = decoded.pop().expect("legacy metadata record");
|
||||
assert!(replay.version_id.is_some(), "the legacy wire record carries an ignored version");
|
||||
let original_key = queue_key(&replay);
|
||||
let mut retained_replay_intents = HashMap::from([(original_key.clone(), replay.clone())]);
|
||||
assert_eq!(
|
||||
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut replay),
|
||||
MrfIngressResult::Enqueued
|
||||
);
|
||||
assert!(replay.version_id.is_none());
|
||||
assert_eq!(queue_key(&replay), original_key, "rearm must not create another retained key");
|
||||
retained_replay_intents.insert(queue_key(&replay), replay);
|
||||
assert_eq!(retained_replay_intents.len(), 1);
|
||||
let runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(1, 4096),
|
||||
config: MrfConsumerConfig::default(),
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: 1,
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: true,
|
||||
retain_replay_journal: true,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
retained_replay_intents,
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
};
|
||||
let (snapshot, _) = runtime
|
||||
.snapshot()
|
||||
.expect("canonical metadata fits the original one-record budget");
|
||||
let (recovered, truncated) = decode_journal(&snapshot);
|
||||
assert_eq!(truncated, 0);
|
||||
assert_eq!(recovered.len(), 1);
|
||||
assert!(recovered[0].version_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_can_arm_more_records_than_live_queue_budget() {
|
||||
let mut queue = MrfQueue::new(1, intent("bucket", "object-0", 0).estimated_bytes());
|
||||
@@ -1752,6 +1473,62 @@ mod tests {
|
||||
assert!(!snapshot.is_empty(), "the resident intent remains journalable after rejection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_legacy_payload_omits_scoped_only_responsibilities() {
|
||||
let mut scoped = intent("rollback-bucket", "scoped-only-object", 0);
|
||||
scoped.kind = MrfKind::PartialWrite;
|
||||
scoped.scope = Some(rustfs_common::mrf_channel::MrfScope {
|
||||
pool_index: 3,
|
||||
set_index: 7,
|
||||
});
|
||||
let compat = intent("rollback-bucket", "v1-compatible-object", 0);
|
||||
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(4, usize::MAX),
|
||||
config: MrfConsumerConfig::default(),
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: 1,
|
||||
new_since_flush: 0,
|
||||
dirty: true,
|
||||
journal_on_disk: false,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
};
|
||||
assert_eq!(runtime.queue.try_push_typed(scoped.clone()), MrfQueuePushResult::Enqueued);
|
||||
assert_eq!(runtime.queue.try_push_typed(compat.clone()), MrfQueuePushResult::Enqueued);
|
||||
|
||||
let (authoritative, legacy) = runtime.snapshot();
|
||||
let (authoritative_decoded, authoritative_truncated) = decode_journal(&authoritative);
|
||||
assert_eq!(authoritative_truncated, 0);
|
||||
assert_eq!(authoritative_decoded.len(), 2);
|
||||
assert!(
|
||||
authoritative_decoded
|
||||
.iter()
|
||||
.any(|intent| intent.object.as_ref() == "scoped-only-object" && intent.scope == scoped.scope),
|
||||
"new readers must retain the scoped partial-write responsibility"
|
||||
);
|
||||
|
||||
let (legacy_decoded, legacy_truncated) = decode_journal(&legacy);
|
||||
assert_eq!(legacy_truncated, 0);
|
||||
assert_eq!(legacy_decoded.len(), 1, "rollback payload must contain one v1-compatible record");
|
||||
let legacy_record = legacy_decoded.first().expect("one rollback-compatible record");
|
||||
assert_eq!(legacy_record.bucket, compat.bucket);
|
||||
assert_eq!(legacy_record.object, compat.object);
|
||||
assert_eq!(legacy_record.version_id, compat.version_id);
|
||||
assert_eq!(legacy_record.kind, compat.kind);
|
||||
assert_eq!(legacy_record.scope, None);
|
||||
assert_eq!(legacy_record.attempts, compat.attempts);
|
||||
assert!(
|
||||
!legacy
|
||||
.windows(b"scoped-only-object".len())
|
||||
.any(|window| window == b"scoped-only-object"),
|
||||
"legacy rollback bytes must not disguise a scoped-only responsibility as an unscoped record"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_dedupe_failure_releases_key_for_retry() {
|
||||
let mut queue = MrfQueue::new(1, usize::MAX);
|
||||
|
||||
@@ -1140,6 +1140,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_snapshot_writer_payload_failure_does_not_publish_manifest() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
let store = disk(&root, "disk").await;
|
||||
let owner = Uuid::new_v4();
|
||||
let old = payload("old");
|
||||
let next = payload("next");
|
||||
commit(&store, 0, owner, 1, &old).await;
|
||||
std::fs::create_dir(root.path().join("disk").join(RUSTFS_META_BUCKET).join(PAYLOAD_PATHS[1]))
|
||||
.expect("payload path blocks successor staging");
|
||||
|
||||
let result = publish_committed_snapshot(std::slice::from_ref(&store), owner, 2, &next, 4096).await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(SnapshotError::Disk(_) | SnapshotError::Read(_))),
|
||||
"payload failure must be visible before manifest publication: {result:?}"
|
||||
);
|
||||
let reopened = disk(&root, "disk").await;
|
||||
let recovered = read_committed(std::slice::from_ref(&reopened), 4096)
|
||||
.await
|
||||
.expect("read previous committed snapshot")
|
||||
.expect("old anchor remains committed");
|
||||
assert_eq!(recovered.sequence(), 1);
|
||||
assert_eq!(recovered.slot(), 0);
|
||||
assert_eq!(recovered.payload(), old.as_slice());
|
||||
assert_eq!(
|
||||
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, PAYLOAD_PATHS[0])
|
||||
.await
|
||||
.expect("old payload retained")
|
||||
.as_ref(),
|
||||
old.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[0])
|
||||
.await
|
||||
.expect("old manifest retained")
|
||||
.as_ref(),
|
||||
manifest(owner, 1, &old).as_slice()
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
EcstoreDiskAPI::read_all(reopened.as_ref(), RUSTFS_META_BUCKET, MANIFEST_PATHS[1]).await,
|
||||
Err(EcstoreDiskError::FileNotFound | EcstoreDiskError::VolumeNotFound)
|
||||
),
|
||||
"successor manifest must not be published when payload staging fails"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_snapshot_writer_capacity_failure_preserves_previous_anchor() {
|
||||
let root = TempDir::new().expect("test directory");
|
||||
|
||||
@@ -436,23 +436,6 @@ pub trait HealStorageAPI: Send + Sync {
|
||||
Err(Error::other("target-scoped replacement format is unsupported"))
|
||||
}
|
||||
|
||||
/// Heal each pool metadata replica owned by the selected live scope.
|
||||
///
|
||||
/// A successful result requires every applicable owner to finish; an empty
|
||||
/// result is valid only for a known scope with no metadata replica. Backends
|
||||
/// without pool metadata must explicitly implement that empty result.
|
||||
async fn heal_pool_metadata(&self, _opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||
Err(Error::other("pool metadata healing is unsupported"))
|
||||
}
|
||||
|
||||
/// Whether the selected replacement set owns the pool metadata replica.
|
||||
///
|
||||
/// Only a topology-aware backend may exempt a valid non-owner set. The
|
||||
/// conservative default requires the existing repair and readback checks.
|
||||
async fn replacement_pool_metadata_applies(&self, _opts: &HealOpts) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Read target-specific physical evidence for one replacement version.
|
||||
///
|
||||
/// This is only used by automatic replacement healing after the normal
|
||||
@@ -1285,22 +1268,6 @@ impl HealStorageAPI for ECStoreHealStorage {
|
||||
.map_err(Error::Storage)
|
||||
}
|
||||
|
||||
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||
self.ecstore.heal_pool_metadata(opts).await.map_err(Error::Storage)
|
||||
}
|
||||
|
||||
async fn replacement_pool_metadata_applies(&self, opts: &HealOpts) -> Result<bool> {
|
||||
let pool_index = opts
|
||||
.pool
|
||||
.ok_or_else(|| Error::other("replacement pool metadata is missing pool scope"))?;
|
||||
let set_index = opts
|
||||
.set
|
||||
.ok_or_else(|| Error::other("replacement pool metadata is missing set scope"))?;
|
||||
self.ecstore
|
||||
.replacement_pool_metadata_applies(pool_index, set_index)
|
||||
.map_err(Error::Storage)
|
||||
}
|
||||
|
||||
async fn replacement_targets_have_version(
|
||||
&self,
|
||||
bucket: &str,
|
||||
|
||||
@@ -45,7 +45,7 @@ use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, RUSTFS_META_BUCKET};
|
||||
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, POOL_META_NAME, RUSTFS_META_BUCKET};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct OutcomeFinishTestHook {
|
||||
|
||||
@@ -328,23 +328,6 @@ impl HealTask {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.options.dry_run {
|
||||
let metadata_opts = HealOpts {
|
||||
dry_run: self.options.dry_run,
|
||||
recreate: self.options.recreate_missing,
|
||||
scan_mode: self.options.scan_mode,
|
||||
pool: self.options.pool_index,
|
||||
set: self.options.set_index,
|
||||
..Default::default()
|
||||
};
|
||||
for result in self
|
||||
.await_with_control(self.storage.heal_pool_metadata(&metadata_opts))
|
||||
.await?
|
||||
{
|
||||
self.record_result_item(result).await;
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
let failure = BatchHealFailure {
|
||||
scope: "cluster".to_string(),
|
||||
@@ -357,9 +340,88 @@ impl HealTask {
|
||||
return Err(self.record_batch_failure(failure).await);
|
||||
}
|
||||
|
||||
if self.options.recreate_missing && !self.options.dry_run {
|
||||
self.heal_cluster_pool_metadata().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn heal_cluster_pool_metadata(&self) -> Result<()> {
|
||||
let heal_opts = HealOpts {
|
||||
recursive: false,
|
||||
dry_run: self.options.dry_run,
|
||||
remove: false,
|
||||
recreate: self.options.recreate_missing,
|
||||
scan_mode: self.options.scan_mode,
|
||||
update_parity: self.options.update_parity,
|
||||
no_lock: self.options.no_lock,
|
||||
read_repair: false,
|
||||
pool: self.options.pool_index,
|
||||
set: self.options.set_index,
|
||||
};
|
||||
|
||||
let heal_result = self
|
||||
.await_with_control(self.storage.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &heal_opts))
|
||||
.await;
|
||||
match heal_result {
|
||||
Ok((result, None)) => {
|
||||
debug!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket = RUSTFS_META_BUCKET,
|
||||
object = POOL_META_NAME,
|
||||
drives_healed = result.drives_healed(),
|
||||
drives_total = result.drives_reported(),
|
||||
result = "pool_metadata_ok",
|
||||
"Heal cluster pool metadata repaired"
|
||||
);
|
||||
self.record_result_item(result).await;
|
||||
Ok(())
|
||||
}
|
||||
Ok((result, Some(err))) => {
|
||||
self.record_result_item(result).await;
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket = RUSTFS_META_BUCKET,
|
||||
object = POOL_META_NAME,
|
||||
result = "pool_metadata_failed",
|
||||
error = %err,
|
||||
"Heal cluster pool metadata failed"
|
||||
);
|
||||
Err(Error::TaskExecutionFailed {
|
||||
message: format!("Failed to heal cluster pool metadata: {err}"),
|
||||
})
|
||||
}
|
||||
Err(Error::TaskCancelled) => Err(Error::TaskCancelled),
|
||||
Err(Error::TaskTimeout) => Err(Error::TaskTimeout),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
target: "rustfs::heal::task",
|
||||
event = EVENT_HEAL_BUCKET_RESULT,
|
||||
component = LOG_COMPONENT_HEAL,
|
||||
subsystem = LOG_SUBSYSTEM_TASK,
|
||||
task_id = %self.id,
|
||||
bucket = RUSTFS_META_BUCKET,
|
||||
object = POOL_META_NAME,
|
||||
result = "pool_metadata_failed",
|
||||
error = %err,
|
||||
"Heal cluster pool metadata failed"
|
||||
);
|
||||
Err(Error::TaskExecutionFailed {
|
||||
message: format!("Failed to heal cluster pool metadata: {err}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn heal_prefix(&self, bucket: &str, prefix: &str) -> Result<()> {
|
||||
debug!(
|
||||
target: "rustfs::heal::task",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
use super::super::{DiskOption, DiskStore, Endpoint, new_disk};
|
||||
use super::*;
|
||||
use crate::heal::POOL_META_NAME;
|
||||
use crate::heal::storage::HealStorageObjectResult;
|
||||
|
||||
mod deferred_retry;
|
||||
@@ -43,7 +42,6 @@ mod canonical_outcome {
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cluster_retries_only_the_failed_listing_page() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
recoverable_second_page_failures: Mutex::new(Some(1)),
|
||||
..Default::default()
|
||||
});
|
||||
@@ -1327,7 +1325,6 @@ struct MockStorage {
|
||||
retry_test_events: Mutex<Vec<String>>,
|
||||
listed: Mutex<bool>,
|
||||
list_each_bucket: bool,
|
||||
pool_metadata_required: bool,
|
||||
fail_second_listing_page: bool,
|
||||
recoverable_second_page_failures: Mutex<Option<usize>>,
|
||||
listing_tokens: Mutex<Vec<Option<String>>>,
|
||||
@@ -1342,10 +1339,6 @@ struct MockStorage {
|
||||
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
|
||||
heal_object_receipts: Mutex<HashMap<String, VecDeque<HealObjectReceipt>>>,
|
||||
bucket_incarnation_id: Mutex<Option<Uuid>>,
|
||||
bucket_incarnation_calls: AtomicU64,
|
||||
bucket_incarnation_error: Mutex<Option<Error>>,
|
||||
block_bucket_incarnation: bool,
|
||||
bucket_incarnation_started: tokio::sync::Notify,
|
||||
bucket_incarnation_after_object_heal: Mutex<Option<Uuid>>,
|
||||
bucket_incarnation_unavailable: Mutex<bool>,
|
||||
format_no_heal_required: Mutex<bool>,
|
||||
@@ -1489,7 +1482,7 @@ async fn object_heal_records_matching_positive_storage_receipt() {
|
||||
});
|
||||
let task = HealTask::from_request(
|
||||
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
|
||||
storage.clone(),
|
||||
storage,
|
||||
);
|
||||
|
||||
task.execute().await.expect("mock object heal should complete");
|
||||
@@ -1502,114 +1495,6 @@ async fn object_heal_records_matching_positive_storage_receipt() {
|
||||
assert_eq!(object.identity.version_id.as_deref(), Some("version-a"));
|
||||
assert!(object.identity.bucket_incarnation_id.is_some());
|
||||
assert_eq!(object.disposition, HealObjectDisposition::Repaired);
|
||||
assert_eq!(
|
||||
storage.bucket_incarnation_calls.load(Ordering::Relaxed),
|
||||
1,
|
||||
"latch the owner exactly once before repair"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_heal_owner_lookup_failure_preserves_unverified_repair() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_incarnation_error: Mutex::new(Some(Error::other("owner metadata unavailable"))),
|
||||
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||
"object-a".to_string(),
|
||||
VecDeque::from([object_receipt(
|
||||
"object-a",
|
||||
None,
|
||||
HealObjectDisposition::Repaired,
|
||||
Uuid::new_v4(),
|
||||
)]),
|
||||
)])),
|
||||
..Default::default()
|
||||
});
|
||||
let task = HealTask::from_request(HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None), storage.clone());
|
||||
|
||||
task.execute().await.expect("missing receipt owner must not prevent repair");
|
||||
|
||||
assert_eq!(storage.heal_object_calls.lock().expect("heal calls").as_slice(), ["object-a"]);
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.healed, 0);
|
||||
assert_eq!(outcome.counters.unknown, 1);
|
||||
assert_eq!(
|
||||
outcome.objects.front().expect("unverified outcome").disposition,
|
||||
HealObjectDisposition::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_heal_dry_run_skips_owner_lookup_and_positive_receipts() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
bucket_incarnation_error: Mutex::new(Some(Error::other("dry-run must not query the receipt owner"))),
|
||||
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||
"object-a".to_string(),
|
||||
VecDeque::from([object_receipt(
|
||||
"object-a",
|
||||
None,
|
||||
HealObjectDisposition::Repaired,
|
||||
Uuid::new_v4(),
|
||||
)]),
|
||||
)])),
|
||||
..Default::default()
|
||||
});
|
||||
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
|
||||
request.options.dry_run = true;
|
||||
let task = HealTask::from_request(request, storage.clone());
|
||||
|
||||
task.execute().await.expect("dry-run should complete without owner metadata");
|
||||
|
||||
assert!(storage.object_heal_opts.lock().expect("heal options")[0].dry_run);
|
||||
assert_eq!(storage.bucket_incarnation_calls.load(Ordering::Relaxed), 0);
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.healed, 0);
|
||||
assert_eq!(outcome.counters.unknown, 0);
|
||||
assert_eq!(outcome.counters.skipped, 1);
|
||||
assert_eq!(
|
||||
outcome.objects.front().expect("dry-run outcome").disposition,
|
||||
HealObjectDisposition::DryRunObserved
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn object_heal_owner_lookup_obeys_task_timeout() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
block_bucket_incarnation: true,
|
||||
..Default::default()
|
||||
});
|
||||
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
|
||||
request.options.timeout = Some(Duration::from_secs(5));
|
||||
let task = HealTask::from_request(request, storage.clone());
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(60), task.execute())
|
||||
.await
|
||||
.expect("owner lookup must honor the task deadline");
|
||||
|
||||
assert!(matches!(result, Err(Error::TaskTimeout)));
|
||||
assert!(storage.heal_object_calls.lock().expect("heal calls").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_heal_owner_lookup_obeys_cancellation() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
block_bucket_incarnation: true,
|
||||
..Default::default()
|
||||
});
|
||||
let mut request = HealRequest::object("bucket-a".to_string(), "object-a".to_string(), None);
|
||||
request.options.timeout = None;
|
||||
let task = HealTask::from_request(request, storage.clone());
|
||||
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::join!(task.execute(), async {
|
||||
storage.bucket_incarnation_started.notified().await;
|
||||
task.cancel().await.expect("cancel pending owner lookup");
|
||||
})
|
||||
})
|
||||
.await
|
||||
.expect("cancellation must interrupt owner lookup");
|
||||
|
||||
assert!(matches!(result, Err(Error::TaskCancelled)));
|
||||
assert!(storage.heal_object_calls.lock().expect("heal calls").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1947,34 +1832,6 @@ impl HealStorageAPI for MockStorage {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn heal_pool_metadata(&self, opts: &HealOpts) -> Result<Vec<HealResultItem>> {
|
||||
if !self.pool_metadata_required {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let scopes = self.erasure_set_scopes.lock().expect("metadata scopes").clone();
|
||||
let scopes = if scopes.is_empty() {
|
||||
vec![(opts.pool.unwrap_or(0), opts.set.unwrap_or(0))]
|
||||
} else {
|
||||
scopes
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
for (pool, set) in scopes {
|
||||
let scoped_opts = HealOpts {
|
||||
pool: Some(pool),
|
||||
set: Some(set),
|
||||
..*opts
|
||||
};
|
||||
let (result, error) = self
|
||||
.heal_object(RUSTFS_META_BUCKET, crate::heal::POOL_META_NAME, None, &scoped_opts)
|
||||
.await?;
|
||||
if let Some(error) = error {
|
||||
return Err(error);
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn object_exists(&self, _bucket: &str, object: &str) -> Result<bool> {
|
||||
if let Some(result) = self.object_exists_by_name.lock().unwrap().get(object).copied() {
|
||||
return match result {
|
||||
@@ -1987,14 +1844,6 @@ impl HealStorageAPI for MockStorage {
|
||||
}
|
||||
|
||||
async fn bucket_incarnation_id(&self, _bucket: &str) -> Result<Option<Uuid>> {
|
||||
self.bucket_incarnation_calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.bucket_incarnation_started.notify_one();
|
||||
if self.block_bucket_incarnation {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
if let Some(error) = self.bucket_incarnation_error.lock().expect("owner lookup error").take() {
|
||||
return Err(error);
|
||||
}
|
||||
if *self.bucket_incarnation_unavailable.lock().unwrap() {
|
||||
return Err(Error::Other("bucket incarnation unavailable".to_string()));
|
||||
}
|
||||
@@ -2715,187 +2564,9 @@ async fn test_recursive_bucket_heal_treats_missing_continuation_token_as_end() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_heal_restores_pool_metadata_without_user_buckets() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(storage.pool_metadata_required);
|
||||
let task = HealTask::from_request(
|
||||
HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal),
|
||||
storage.clone(),
|
||||
);
|
||||
|
||||
task.execute().await.expect("root heal should restore required pool metadata");
|
||||
|
||||
assert_eq!(
|
||||
storage.heal_object_calls.lock().expect("heal calls").as_slice(),
|
||||
[crate::heal::POOL_META_NAME]
|
||||
);
|
||||
assert!(matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_heal_pool_metadata_cannot_hide_a_later_owner_failure() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||
erasure_set_scopes: Mutex::new(vec![(0, 0), (1, 1)]),
|
||||
..Default::default()
|
||||
});
|
||||
storage.heal_object_outcomes.lock().expect("metadata outcomes").insert(
|
||||
crate::heal::POOL_META_NAME.to_string(),
|
||||
VecDeque::from([
|
||||
MockHealObjectOutcome::UnavailableDrive(DriveState::Ok),
|
||||
MockHealObjectOutcome::OkWithReadQuorum,
|
||||
]),
|
||||
);
|
||||
let task = HealTask::from_request(
|
||||
HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal),
|
||||
storage.clone(),
|
||||
);
|
||||
|
||||
let error = task
|
||||
.execute()
|
||||
.await
|
||||
.expect_err("one healthy owner cannot satisfy another owner's recovery");
|
||||
|
||||
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
|
||||
{
|
||||
let opts = storage.object_heal_opts.lock().expect("owner options");
|
||||
assert_eq!(
|
||||
opts.iter().map(|opts| (opts.pool, opts.set)).collect::<Vec<_>>(),
|
||||
vec![(Some(0), Some(0)), (Some(1), Some(1))]
|
||||
);
|
||||
}
|
||||
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_heal_pool_metadata_does_not_inherit_remove_or_no_lock() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||
..Default::default()
|
||||
});
|
||||
let task = HealTask::from_request(
|
||||
HealRequest::new(
|
||||
HealType::Cluster,
|
||||
HealOptions {
|
||||
remove_corrupted: true,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
),
|
||||
storage.clone(),
|
||||
);
|
||||
|
||||
task.execute()
|
||||
.await
|
||||
.expect("metadata repair should retain its write fence and reject destructive options");
|
||||
|
||||
let opts = storage.object_heal_opts.lock().expect("metadata options");
|
||||
assert_eq!(opts.len(), 1, "an empty user namespace must still inspect metadata");
|
||||
assert!(!opts[0].dry_run);
|
||||
assert!(!opts[0].remove);
|
||||
assert!(!opts[0].no_lock);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_heal_pool_metadata_failure_does_not_prevent_user_repairs() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
..Default::default()
|
||||
});
|
||||
storage.heal_object_outcomes.lock().expect("metadata outcome").insert(
|
||||
crate::heal::POOL_META_NAME.to_string(),
|
||||
VecDeque::from([MockHealObjectOutcome::OkWithReadQuorum]),
|
||||
);
|
||||
let task = HealTask::from_request(
|
||||
HealRequest::new(
|
||||
HealType::Cluster,
|
||||
HealOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
),
|
||||
storage.clone(),
|
||||
);
|
||||
|
||||
let error = task
|
||||
.execute()
|
||||
.await
|
||||
.expect_err("unrecovered metadata must still fail root completion");
|
||||
|
||||
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
|
||||
assert_eq!(
|
||||
storage.heal_object_calls.lock().expect("heal calls").as_slice(),
|
||||
["object-a", "object-b", crate::heal::POOL_META_NAME]
|
||||
);
|
||||
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_heal_pool_metadata_preserves_typed_quorum_failure() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||
..Default::default()
|
||||
});
|
||||
storage.heal_object_outcomes.lock().expect("metadata outcome").insert(
|
||||
crate::heal::POOL_META_NAME.to_string(),
|
||||
VecDeque::from([MockHealObjectOutcome::OkWithReadQuorum]),
|
||||
);
|
||||
let task = HealTask::from_request(HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::Normal), storage);
|
||||
|
||||
let error = task
|
||||
.execute()
|
||||
.await
|
||||
.expect_err("metadata quorum failure must prevent root completion");
|
||||
|
||||
assert!(matches!(error, Error::Storage(EcstoreError::InsufficientReadQuorum(_, _))));
|
||||
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn root_heal_pool_metadata_obeys_task_timeout() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
listed_buckets: Mutex::new(Some(Vec::new())),
|
||||
retry_test_delays: HashMap::from([(crate::heal::POOL_META_NAME.to_string(), Duration::from_secs(10))]),
|
||||
..Default::default()
|
||||
});
|
||||
let task = HealTask::from_request(
|
||||
HealRequest::new(
|
||||
HealType::Cluster,
|
||||
HealOptions {
|
||||
timeout: Some(Duration::from_millis(10)),
|
||||
..Default::default()
|
||||
},
|
||||
HealPriority::Normal,
|
||||
),
|
||||
storage,
|
||||
);
|
||||
|
||||
let error = task
|
||||
.execute()
|
||||
.await
|
||||
.expect_err("metadata work must stay inside the root task budget");
|
||||
|
||||
assert!(matches!(error, Error::TaskTimeout));
|
||||
assert!(!matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cluster_heal_visits_bucket_objects() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
..Default::default()
|
||||
});
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
let request = HealRequest::new(
|
||||
HealType::Cluster,
|
||||
HealOptions {
|
||||
@@ -2918,10 +2589,7 @@ async fn test_cluster_heal_visits_bucket_objects() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
..Default::default()
|
||||
});
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
let request = HealRequest::new(
|
||||
HealType::Cluster,
|
||||
HealOptions {
|
||||
@@ -2948,10 +2616,7 @@ async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
..Default::default()
|
||||
});
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
storage.heal_object_outcomes.lock().expect("object outcomes").insert(
|
||||
POOL_META_NAME.to_string(),
|
||||
VecDeque::from([MockHealObjectOutcome::ErrOther("pool metadata missing")]),
|
||||
@@ -2973,7 +2638,7 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
|
||||
.await
|
||||
.expect_err("cluster recreate heal must not hide pool metadata failure");
|
||||
|
||||
assert!(matches!(err, Error::Other(message) if message == "pool metadata missing"));
|
||||
assert!(matches!(err, Error::TaskExecutionFailed { .. }));
|
||||
assert_eq!(
|
||||
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
||||
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
|
||||
@@ -2982,10 +2647,7 @@ async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_dry_run_does_not_heal_pool_metadata() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
pool_metadata_required: true,
|
||||
..Default::default()
|
||||
});
|
||||
let storage = Arc::new(MockStorage::default());
|
||||
let request = HealRequest::new(
|
||||
HealType::Cluster,
|
||||
HealOptions {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
//! Under `cargo nextest` each test runs in its own process, which keeps the
|
||||
//! process-global MRF channel singleton safe.
|
||||
|
||||
use rustfs_common::mrf_channel::{self, MrfKind};
|
||||
use rustfs_common::mrf_channel::{self, MrfIngressResult, MrfKind, MrfScope};
|
||||
use rustfs_heal::heal::{
|
||||
manager::{HealConfig, HealManager},
|
||||
mrf_queue,
|
||||
@@ -237,6 +237,33 @@ fn journal_matches_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, exp
|
||||
.all(|path| std::fs::read(path.join(META_BUCKET).join(relative_path)).is_ok_and(|actual| actual == expected))
|
||||
}
|
||||
|
||||
fn journal_contains_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, needle: &[u8]) -> bool {
|
||||
disk_paths.iter().all(|path| {
|
||||
std::fs::read(path.join(META_BUCKET).join(relative_path))
|
||||
.is_ok_and(|actual| actual.windows(needle.len()).any(|window| window == needle))
|
||||
})
|
||||
}
|
||||
|
||||
fn journal_contains_on_any_disk(disk_paths: &[PathBuf], relative_path: &str, needle: &[u8]) -> bool {
|
||||
disk_paths.iter().any(|path| {
|
||||
std::fs::read(path.join(META_BUCKET).join(relative_path))
|
||||
.is_ok_and(|actual| actual.windows(needle.len()).any(|window| window == needle))
|
||||
})
|
||||
}
|
||||
|
||||
fn committed_payload_contains_on_all_disks(disk_paths: &[PathBuf], needles: &[&[u8]]) -> bool {
|
||||
disk_paths.iter().all(|path| {
|
||||
let root = path.join(META_BUCKET);
|
||||
COMMITTED_PAYLOAD_RELS.into_iter().any(|payload_rel| {
|
||||
std::fs::read(root.join(payload_rel)).is_ok_and(|payload| {
|
||||
needles
|
||||
.iter()
|
||||
.all(|needle| payload.windows(needle.len()).any(|window| window == *needle))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn committed_checkpoint_matches_on_all_disks(disk_paths: &[PathBuf], sequence: u64, expected_payload: &[u8]) -> bool {
|
||||
disk_paths.iter().all(|path| {
|
||||
let root = path.join(META_BUCKET);
|
||||
@@ -615,6 +642,73 @@ async fn journal_replay_retains_file_when_manager_is_full() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Rollback mirrors are for v1 readers only: the committed and scoped
|
||||
/// snapshots remain authoritative, while the legacy journal omits scoped-only
|
||||
/// records that an older binary cannot represent safely.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn rollback_legacy_mirror_persists_only_v1_compatible_records() {
|
||||
let (disk_paths, storage) = heal_env().await;
|
||||
register_local_disks(&disk_paths, "mrf-rollback-mirror-test").await;
|
||||
|
||||
let manager = Arc::new(HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
queue_size: 0,
|
||||
heal_interval: Duration::from_secs(3600),
|
||||
enable_auto_heal: false,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
mrf_queue::spawn_mrf_consumer(manager.clone());
|
||||
|
||||
let scoped_only = b"rollback-scoped-only-object";
|
||||
let v1_compatible = b"rollback-v1-compatible-object";
|
||||
assert_eq!(
|
||||
mrf_channel::try_send_mrf_intent_typed(
|
||||
MrfKind::PartialWrite,
|
||||
"rollback-bucket",
|
||||
std::str::from_utf8(scoped_only).expect("fixture object is UTF-8"),
|
||||
None,
|
||||
Some(MrfScope {
|
||||
pool_index: 3,
|
||||
set_index: 7,
|
||||
}),
|
||||
),
|
||||
MrfIngressResult::Enqueued,
|
||||
"scoped-only intent should be accepted by the live consumer"
|
||||
);
|
||||
assert_eq!(
|
||||
mrf_channel::try_send_mrf_intent_typed(
|
||||
MrfKind::PartialWrite,
|
||||
"rollback-bucket",
|
||||
std::str::from_utf8(v1_compatible).expect("fixture object is UTF-8"),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
MrfIngressResult::Enqueued,
|
||||
"v1-compatible intent should be accepted by the live consumer"
|
||||
);
|
||||
|
||||
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||
committed_payload_contains_on_all_disks(&disk_paths, &[scoped_only, v1_compatible])
|
||||
&& journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, scoped_only)
|
||||
&& journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, v1_compatible)
|
||||
&& journal_contains_on_all_disks(&disk_paths, JOURNAL_REL, v1_compatible)
|
||||
&& !journal_contains_on_any_disk(&disk_paths, JOURNAL_REL, scoped_only)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
flushed,
|
||||
"runtime flush must persist rollback-safe mirrors without leaking scoped-only records into the legacy journal"
|
||||
);
|
||||
assert_eq!(
|
||||
manager.operations_snapshot().await.queued_by_source.mrf,
|
||||
0,
|
||||
"zero-capacity manager keeps both intents in the MRF runtime so the persisted snapshot is observable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_journal_child_process_fixture() {
|
||||
let Ok(root) = std::env::var("RUSTFS_MRF_REPLAY_CHILD_ROOT") else {
|
||||
@@ -666,8 +760,7 @@ fn mrf_successor_flush_child_process_fixture() {
|
||||
}),
|
||||
));
|
||||
mrf_queue::spawn_mrf_consumer(manager.clone());
|
||||
let mut expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
||||
expected_successor.extend(journal_record(1, "successor-bucket", "first-object", None, 0));
|
||||
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
||||
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
||||
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
|
||||
@@ -715,8 +808,7 @@ fn mrf_successor_flush_waiting_child_process_fixture() {
|
||||
}),
|
||||
));
|
||||
mrf_queue::spawn_mrf_consumer(manager.clone());
|
||||
let mut expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
||||
expected_successor.extend(journal_record(1, "service-kill-bucket", "first-object", None, 0));
|
||||
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
||||
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
||||
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
|
||||
@@ -757,8 +849,7 @@ fn mrf_authoritative_fsync_waiting_child_process_fixture() {
|
||||
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &startup);
|
||||
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &startup);
|
||||
|
||||
let mut successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
||||
successor.extend(journal_record(1, "fsync-kill-bucket", "first-object", None, 0));
|
||||
let successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
||||
write_journal_path_to_disks_synced(&disk_paths, SCOPED_JOURNAL_REL, &successor);
|
||||
assert!(
|
||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &successor)
|
||||
@@ -815,8 +906,9 @@ async fn journal_replay_retains_child_process_anchor_when_manager_is_full() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A successor flush must preserve both pending work and accepted work whose
|
||||
/// repair has not been proven when the process restarts.
|
||||
/// If a process crashes after flushing a smaller successor snapshot but before
|
||||
/// deleting the startup anchor, the restarted process must replay the
|
||||
/// successor tail rather than losing it or merging it with stale records.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn journal_replay_survives_successor_flush_before_delete() {
|
||||
@@ -831,8 +923,7 @@ async fn journal_replay_survives_successor_flush_before_delete() {
|
||||
assert_eq!(status.code(), Some(78), "child process did not reach the successor flush boundary");
|
||||
|
||||
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
||||
let mut expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
||||
expected_successor.extend(journal_record(1, "successor-bucket", "first-object", None, 0));
|
||||
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
||||
assert!(
|
||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
|
||||
"restarted process must see the pending successor snapshot"
|
||||
@@ -840,11 +931,11 @@ async fn journal_replay_survives_successor_flush_before_delete() {
|
||||
|
||||
let restarted = make_manager(storage);
|
||||
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
||||
assert_eq!(replayed, 2, "restart must replay both the admitted and pending responsibilities");
|
||||
assert_eq!(replayed, 1, "restart after successor flush must replay only the still-pending tail");
|
||||
assert_eq!(
|
||||
restarted.operations_snapshot().await.queued_by_source.mrf,
|
||||
2,
|
||||
"both unproven successor responsibilities must be accepted after restart"
|
||||
1,
|
||||
"the successor tail must be accepted after restart"
|
||||
);
|
||||
assert!(
|
||||
disk_paths.iter().all(|path| {
|
||||
@@ -856,8 +947,8 @@ async fn journal_replay_survives_successor_flush_before_delete() {
|
||||
}
|
||||
|
||||
/// A service-style hard kill after successor flush must be equivalent to a
|
||||
/// crash at the flush-before-delete boundary: restart must recover every
|
||||
/// unproven responsibility from the successor snapshot.
|
||||
/// crash at the flush-before-delete boundary: restart may replay the smaller
|
||||
/// successor snapshot, but must not lose or merge stale startup records.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
#[cfg(unix)]
|
||||
@@ -885,8 +976,7 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
|
||||
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
|
||||
|
||||
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
||||
let mut expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
||||
expected_successor.extend(journal_record(1, "service-kill-bucket", "first-object", None, 0));
|
||||
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
||||
assert!(
|
||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
|
||||
"restarted process must see the successor snapshot produced before the kill"
|
||||
@@ -894,11 +984,11 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
|
||||
|
||||
let restarted = make_manager(storage);
|
||||
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
||||
assert_eq!(replayed, 2, "service-kill restart must preserve every unproven responsibility");
|
||||
assert_eq!(replayed, 1, "restart after service kill must replay only the still-pending tail");
|
||||
assert_eq!(
|
||||
restarted.operations_snapshot().await.queued_by_source.mrf,
|
||||
2,
|
||||
"both unproven responsibilities must be accepted after service kill restart"
|
||||
1,
|
||||
"the successor tail must be accepted after service kill restart"
|
||||
);
|
||||
assert!(
|
||||
disk_paths.iter().all(|path| {
|
||||
@@ -910,9 +1000,9 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
|
||||
}
|
||||
|
||||
/// A hard kill between the authoritative successor fsync and the legacy mirror
|
||||
/// rewrite must prefer the canonical successor over the stale legacy startup
|
||||
/// epoch while retaining every unproven responsibility. New readers must not
|
||||
/// merge epochs, while the old mirror remains crash-visible.
|
||||
/// rewrite must prefer the canonical successor tail over the stale legacy
|
||||
/// startup epoch. This models the mixed-version boundary conservatively: new
|
||||
/// readers must not merge epochs, while the old mirror remains crash-visible.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
#[cfg(unix)]
|
||||
@@ -940,8 +1030,7 @@ async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_bef
|
||||
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
|
||||
|
||||
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
||||
let mut expected_successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
||||
expected_successor.extend(journal_record(1, "fsync-kill-bucket", "first-object", None, 0));
|
||||
let expected_successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
||||
let stale_startup = {
|
||||
let mut startup = journal_record(1, "fsync-kill-bucket", "first-object", None, 0);
|
||||
startup.extend(journal_record(1, "fsync-kill-bucket", "second-object", None, 0));
|
||||
@@ -958,11 +1047,11 @@ async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_bef
|
||||
|
||||
let restarted = make_manager(storage);
|
||||
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
||||
assert_eq!(replayed, 2, "new reader must recover every responsibility in the authoritative successor");
|
||||
assert_eq!(replayed, 1, "new reader must replay only the authoritative successor tail");
|
||||
assert_eq!(
|
||||
restarted.operations_snapshot().await.queued_by_source.mrf,
|
||||
2,
|
||||
"both responsibilities must be accepted after the fsync-boundary restart"
|
||||
1,
|
||||
"the successor tail must be accepted after the fsync-boundary restart"
|
||||
);
|
||||
assert!(
|
||||
disk_paths.iter().all(|path| {
|
||||
|
||||
@@ -819,7 +819,9 @@ impl KmsBackend for AwsKmsBackend {
|
||||
.with_rotate(true)
|
||||
.with_enable_disable(true)
|
||||
.with_schedule_deletion(true)
|
||||
.with_versioning(true)
|
||||
// AWS KMS exposes rotation state but does not enumerate key
|
||||
// versions through this backend's API contract.
|
||||
.with_versioning(false)
|
||||
.with_physical_delete(false)
|
||||
.with_production_supported(true)
|
||||
}
|
||||
|
||||
@@ -2098,7 +2098,11 @@ impl KmsBackend for LocalKmsBackend {
|
||||
let _write_guard = self.client.lock_key_for_write(key_id).await;
|
||||
|
||||
// First, load the key from disk to get the master key
|
||||
let mut master_key = self.client.load_master_key(key_id).await?;
|
||||
let mut master_key = self
|
||||
.client
|
||||
.load_master_key(key_id)
|
||||
.await
|
||||
.map_err(|_| KmsError::key_not_found(format!("Key {key_id} not found")))?;
|
||||
|
||||
let (deletion_date_str, deletion_date_dt) = if request.force_immediate.unwrap_or(false) {
|
||||
// Tombstone first: mark the record Deleted before removing the
|
||||
@@ -2201,7 +2205,11 @@ impl KmsBackend for LocalKmsBackend {
|
||||
let _write_guard = self.client.lock_key_for_write(key_id).await;
|
||||
|
||||
// Load the key from disk to get the master key
|
||||
let mut master_key = self.client.load_master_key(key_id).await?;
|
||||
let mut master_key = self
|
||||
.client
|
||||
.load_master_key(key_id)
|
||||
.await
|
||||
.map_err(|_| KmsError::key_not_found(format!("Key {key_id} not found")))?;
|
||||
|
||||
if master_key.status != KeyStatus::PendingDeletion {
|
||||
return Err(KmsError::invalid_key_state(format!("Key {key_id} is not pending deletion")));
|
||||
@@ -3030,111 +3038,6 @@ mod tests {
|
||||
assert!(matches!(error, KmsError::InvalidKey { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_key_preserves_directory_io_error() {
|
||||
let (client, temp_dir) = create_dev_mode_client().await;
|
||||
client.create_key("existing-key", "AES_256", None).await.expect("create key");
|
||||
let backend = LocalKmsBackend { client };
|
||||
let offline_dir = TempDir::new().expect("create offline directory");
|
||||
let offline_key_dir = offline_dir.path().join("keys");
|
||||
fs::rename(temp_dir.path(), &offline_key_dir)
|
||||
.await
|
||||
.expect("move key directory offline");
|
||||
fs::write(temp_dir.path(), b"not a directory")
|
||||
.await
|
||||
.expect("replace key directory with a file");
|
||||
|
||||
let error = backend
|
||||
.delete_key(DeleteKeyRequest {
|
||||
key_id: "existing-key".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("unreadable storage must prevent scheduling deletion");
|
||||
|
||||
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
|
||||
fs::rename(&offline_key_dir, temp_dir.path())
|
||||
.await
|
||||
.expect("restore key directory");
|
||||
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
|
||||
let key = backend
|
||||
.client
|
||||
.load_master_key("existing-key")
|
||||
.await
|
||||
.expect("read retained key");
|
||||
assert_eq!(key.status, KeyStatus::Active, "failed deletion must not mutate key state");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_key_deletion_preserves_directory_io_error() {
|
||||
let (client, temp_dir) = create_dev_mode_client().await;
|
||||
client.create_key("existing-key", "AES_256", None).await.expect("create key");
|
||||
let backend = LocalKmsBackend { client };
|
||||
backend
|
||||
.delete_key(DeleteKeyRequest {
|
||||
key_id: "existing-key".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("schedule key deletion");
|
||||
let offline_dir = TempDir::new().expect("create offline directory");
|
||||
let offline_key_dir = offline_dir.path().join("keys");
|
||||
fs::rename(temp_dir.path(), &offline_key_dir)
|
||||
.await
|
||||
.expect("move key directory offline");
|
||||
fs::write(temp_dir.path(), b"not a directory")
|
||||
.await
|
||||
.expect("replace key directory with a file");
|
||||
|
||||
let error = backend
|
||||
.cancel_key_deletion(CancelKeyDeletionRequest {
|
||||
key_id: "existing-key".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("unreadable storage must prevent cancelling deletion");
|
||||
|
||||
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
|
||||
fs::rename(&offline_key_dir, temp_dir.path())
|
||||
.await
|
||||
.expect("restore key directory");
|
||||
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
|
||||
let key = backend
|
||||
.client
|
||||
.load_master_key("existing-key")
|
||||
.await
|
||||
.expect("read retained key");
|
||||
assert_eq!(
|
||||
key.status,
|
||||
KeyStatus::PendingDeletion,
|
||||
"failed cancellation must retain the deletion state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_master_key_directory_replaced_by_file_is_io_error() {
|
||||
let (client, temp_dir) = create_dev_mode_client().await;
|
||||
client.create_key("existing-key", "AES_256", None).await.expect("create key");
|
||||
let offline_dir = TempDir::new().expect("create offline directory");
|
||||
let offline_key_dir = offline_dir.path().join("keys");
|
||||
fs::rename(temp_dir.path(), &offline_key_dir)
|
||||
.await
|
||||
.expect("move key directory offline");
|
||||
fs::write(temp_dir.path(), b"not a directory")
|
||||
.await
|
||||
.expect("replace key directory with a file");
|
||||
|
||||
let error = client
|
||||
.load_master_key("existing-key")
|
||||
.await
|
||||
.expect_err("a file in place of the key directory must fail");
|
||||
|
||||
fs::remove_file(temp_dir.path()).await.expect("remove replacement file");
|
||||
fs::rename(&offline_key_dir, temp_dir.path())
|
||||
.await
|
||||
.expect("restore key directory");
|
||||
assert!(matches!(error, KmsError::IoError { .. }), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_master_key_accepts_legacy_rfc3339_timestamp() {
|
||||
let (client, _temp_dir) = create_dev_mode_client().await;
|
||||
|
||||
@@ -74,6 +74,39 @@ impl ScriptedResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `auth/token/lookup-self` answer every scripted Vault serves for free.
|
||||
///
|
||||
/// A Vault client now probes its token's remaining lifetime at login
|
||||
/// (backlog#2369 P3), which is credential plumbing rather than the protocol any
|
||||
/// of these tests is scripting. Answering it out of band keeps every existing
|
||||
/// script meaningful: `ttl` 0 is Vault's "this token does not expire", so the
|
||||
/// probe changes nothing about how a scripted test behaves.
|
||||
pub(crate) fn token_lookup_self_response() -> String {
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"accessor": "scripted-accessor",
|
||||
"creation_time": 1_700_000_000u64,
|
||||
"creation_ttl": 0,
|
||||
"display_name": "token",
|
||||
"entity_id": "",
|
||||
"explicit_max_ttl": 0,
|
||||
"id": "scripted-token",
|
||||
"num_uses": 0,
|
||||
"orphan": true,
|
||||
"path": "auth/token/create",
|
||||
"policies": ["default"],
|
||||
"renewable": false,
|
||||
"ttl": 0
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Whether a recorded request line addresses the token self-lookup.
|
||||
pub(crate) fn is_token_lookup_self(request_line: &str) -> bool {
|
||||
request_line.contains("/v1/auth/token/lookup-self")
|
||||
}
|
||||
|
||||
/// A scripted stand-in Vault listening on a loopback port.
|
||||
pub(crate) struct ScriptedVault {
|
||||
/// Base address (`http://127.0.0.1:port`) to point a Vault client at.
|
||||
@@ -102,6 +135,19 @@ impl ScriptedVault {
|
||||
let Some((request_line, body, mut stream)) = read_request(stream).await else {
|
||||
continue;
|
||||
};
|
||||
if is_token_lookup_self(&request_line) {
|
||||
// Served out of band so the credential probe does not
|
||||
// consume a scripted response meant for the protocol under
|
||||
// test, and is not recorded as one of its requests.
|
||||
let body = token_lookup_self_response();
|
||||
let payload = format!(
|
||||
"HTTP/1.1 200 Scripted\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len(),
|
||||
);
|
||||
let _ = stream.write_all(payload.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
continue;
|
||||
}
|
||||
recorded
|
||||
.lock()
|
||||
.expect("scripted vault request log poisoned")
|
||||
@@ -155,6 +201,19 @@ impl ScriptedVault {
|
||||
let Some((request_line, body, stream)) = read_request(stream).await else {
|
||||
return;
|
||||
};
|
||||
if is_token_lookup_self(&request_line) {
|
||||
// Credential plumbing, not part of the KV2 protocol
|
||||
// this responder models; see token_lookup_self_response.
|
||||
write_response(
|
||||
stream,
|
||||
ScriptedResponse::Http {
|
||||
status: 200,
|
||||
body: token_lookup_self_response(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
recorded
|
||||
.lock()
|
||||
.expect("scripted vault request log poisoned")
|
||||
|
||||
+1
-1
@@ -13,5 +13,5 @@ expression: capabilities_snapshot(backend.capabilities())
|
||||
"rotate": true,
|
||||
"schedule_deletion": true,
|
||||
"update_key_metadata": false,
|
||||
"versioning": true
|
||||
"versioning": false
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ impl fmt::Debug for SecretString {
|
||||
}
|
||||
|
||||
/// Expiry attributes of a lease-bound token.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct LeaseInfo {
|
||||
/// Time-to-live granted at issue or renewal.
|
||||
pub(crate) ttl: Duration,
|
||||
@@ -207,31 +207,102 @@ pub(crate) trait TokenSource: fmt::Debug + Send + Sync {
|
||||
}
|
||||
|
||||
/// Token source for [`VaultAuthMethod::Token`]: always yields the token fixed
|
||||
/// at configuration time. The token carries no lease, so it is never renewed
|
||||
/// and never expires from the provider's point of view.
|
||||
/// at configuration time.
|
||||
///
|
||||
/// The token itself is never re-issued, but it usually still expires:
|
||||
/// `vault token create` defaults to a 768-hour TTL. Hard-coding "no lease"
|
||||
/// here left the renewal task unstarted and published no remaining-TTL gauge,
|
||||
/// so a healthy-looking cluster turned every KMS call into a 403 a month later
|
||||
/// and could only be recovered by a restart or a reconfigure (backlog#2369 P3).
|
||||
/// The source therefore asks Vault what it is holding, once per client
|
||||
/// generation, and lets the existing renewal loop take over whenever the answer
|
||||
/// carries a TTL.
|
||||
/// Map a `lookup-self` answer onto a lease.
|
||||
///
|
||||
/// A zero TTL is Vault's answer for a token that never expires (root and
|
||||
/// periodic-root tokens), which keeps the pre-probe behaviour exactly: no
|
||||
/// lease, no renewal task, no expiry gate. A response that omits `renewable`
|
||||
/// is treated as not renewable, so the renewal loop falls back to re-reading
|
||||
/// the remaining TTL instead of assuming it can extend it.
|
||||
fn static_token_lease(ttl_secs: u64, renewable: Option<bool>) -> Option<LeaseInfo> {
|
||||
(ttl_secs > 0).then_some(LeaseInfo {
|
||||
ttl: Duration::from_secs(ttl_secs),
|
||||
renewable: renewable.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct StaticToken {
|
||||
token: TokenLease,
|
||||
/// Client authenticated with the configured token, used only for
|
||||
/// `lookup-self`. Per-generation renewals use the generation's own client.
|
||||
lookup_client: VaultClient,
|
||||
}
|
||||
|
||||
impl StaticToken {
|
||||
pub(crate) fn new(token: String) -> Self {
|
||||
Self {
|
||||
pub(crate) fn new(settings: &VaultConnectionSettings, token: String) -> Result<Self> {
|
||||
let lookup_client = settings.build_client(&token)?;
|
||||
Ok(Self {
|
||||
token: TokenLease::new(token, None),
|
||||
}
|
||||
lookup_client,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TokenSource for StaticToken {
|
||||
async fn acquire(&self) -> AttemptResult<TokenLease> {
|
||||
Ok(self.token.clone())
|
||||
// A lookup failure must not fail the login. The token itself may well
|
||||
// be valid: a policy can omit `lookup-self`, and Vault may simply be
|
||||
// unreachable for the moment. Failing here would take down deployments
|
||||
// that work today, so the probe degrades to the pre-probe behaviour —
|
||||
// no lease, no renewal — and says so loudly instead.
|
||||
let lease = match vaultrs::token::lookup_self(&self.lookup_client).await {
|
||||
Ok(lookup) => static_token_lease(lookup.ttl, lookup.renewable),
|
||||
Err(error) => {
|
||||
warn!(
|
||||
event = "vault_static_token_lookup_failed",
|
||||
error = %error,
|
||||
"Could not read the configured Vault token's remaining lifetime, so it will not be \
|
||||
renewed and its expiry will not be tracked. Grant the token `lookup-self` (Vault's \
|
||||
default policy does) or switch to AppRole, Kubernetes or an agent-managed token file"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(lease) = lease
|
||||
&& !lease.renewable
|
||||
{
|
||||
warn!(
|
||||
event = "vault_static_token_not_renewable",
|
||||
ttl_secs = lease.ttl.as_secs(),
|
||||
"The configured Vault token expires and cannot be renewed; RustFS will fail closed as it \
|
||||
approaches expiry. Switch to AppRole, Kubernetes or an agent-managed token file, or \
|
||||
reconfigure with a fresh token before it lapses"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(TokenLease::new(self.token.expose().to_string(), lease))
|
||||
}
|
||||
|
||||
async fn renew(&self, client: &VaultClient) -> AttemptResult<TokenLease> {
|
||||
// Vault refuses renew-self on a non-renewable token; the renewal loop
|
||||
// then falls back to `acquire`, which re-reads the remaining TTL and
|
||||
// keeps the gauge honest until the fail-closed window is reached.
|
||||
let auth = vaultrs::token::renew_self(client, None)
|
||||
.await
|
||||
.map_err(|error| attempt_error("token renewal", error))?;
|
||||
Ok(TokenLease::from_auth(auth))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for StaticToken {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// TokenLease::fmt already redacts the token value.
|
||||
f.debug_struct("StaticToken").field("token", &self.token).finish()
|
||||
// TokenLease::fmt already redacts the token value; VaultClient embeds
|
||||
// its settings, including the token, so it must stay out of Debug.
|
||||
f.debug_struct("StaticToken")
|
||||
.field("token", &self.token)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +612,7 @@ pub(crate) fn token_source_for(
|
||||
settings: &VaultConnectionSettings,
|
||||
) -> Result<Box<dyn TokenSource>> {
|
||||
match auth_method {
|
||||
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(token.clone()))),
|
||||
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(settings, token.clone())?)),
|
||||
VaultAuthMethod::AppRole {
|
||||
role_id,
|
||||
secret_id,
|
||||
@@ -1201,14 +1272,22 @@ mod tests {
|
||||
(Arc::new(provider), state)
|
||||
}
|
||||
|
||||
/// A provider whose token reports no expiry, which is what `lookup-self`
|
||||
/// answers for a root or periodic-root token. Scripted rather than backed
|
||||
/// by [`StaticToken`] because the real source now asks Vault what it holds.
|
||||
async fn static_provider() -> VaultCredentialProvider {
|
||||
VaultCredentialProvider::new(
|
||||
test_settings(),
|
||||
Box::new(StaticToken::new(TEST_TOKEN.to_string())),
|
||||
Box::new(ScriptedSource {
|
||||
state: Arc::new(ScriptedState::default()),
|
||||
ttl: Duration::ZERO,
|
||||
renewable: false,
|
||||
login_delay: Duration::ZERO,
|
||||
}),
|
||||
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
|
||||
)
|
||||
.await
|
||||
.expect("static provider must build without a live Vault")
|
||||
.expect("a token without an expiry must build without a live Vault")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1231,20 +1310,57 @@ mod tests {
|
||||
assert!(provider.spawn_renewal_task().is_none(), "a token without a lease has nothing to renew");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_static_token_source_yields_configured_token() {
|
||||
let settings = test_settings();
|
||||
let source = token_source_for(
|
||||
#[test]
|
||||
fn test_static_token_source_builds_without_contacting_vault() {
|
||||
token_source_for(
|
||||
&VaultAuthMethod::Token {
|
||||
token: TEST_TOKEN.to_string(),
|
||||
},
|
||||
&settings,
|
||||
&test_settings(),
|
||||
)
|
||||
.expect("token auth must map to a source");
|
||||
}
|
||||
|
||||
let lease = source.acquire().await.expect("static acquire cannot fail");
|
||||
assert_eq!(lease.expose(), TEST_TOKEN);
|
||||
assert!(lease.lease_info().is_none(), "static tokens must not carry a lease");
|
||||
/// backlog#2369 P3: `vault token create` defaults to a 768-hour TTL, so
|
||||
/// hard-coding "no lease" for token auth left the renewal task unstarted
|
||||
/// and turned a healthy cluster into one that answers 403 a month later.
|
||||
/// The lease now comes from what Vault reports.
|
||||
#[test]
|
||||
fn static_token_lease_follows_what_vault_reports() {
|
||||
assert_eq!(
|
||||
static_token_lease(0, Some(true)),
|
||||
None,
|
||||
"a token Vault reports as non-expiring must keep behaving as one"
|
||||
);
|
||||
assert_eq!(
|
||||
static_token_lease(0, None),
|
||||
None,
|
||||
"a non-expiring token stays non-expiring whatever renewable says"
|
||||
);
|
||||
assert_eq!(
|
||||
static_token_lease(2_764_800, Some(true)),
|
||||
Some(LeaseInfo {
|
||||
ttl: Duration::from_secs(2_764_800),
|
||||
renewable: true,
|
||||
}),
|
||||
"the default 768-hour token must be tracked and renewed"
|
||||
);
|
||||
assert_eq!(
|
||||
static_token_lease(3_600, Some(false)),
|
||||
Some(LeaseInfo {
|
||||
ttl: Duration::from_secs(3_600),
|
||||
renewable: false,
|
||||
}),
|
||||
"an expiring token that cannot be renewed still needs its expiry tracked"
|
||||
);
|
||||
assert_eq!(
|
||||
static_token_lease(3_600, None),
|
||||
Some(LeaseInfo {
|
||||
ttl: Duration::from_secs(3_600),
|
||||
renewable: false,
|
||||
}),
|
||||
"an omitted renewable flag must not be read as renewable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1759,7 +1875,7 @@ mod tests {
|
||||
renewable: true,
|
||||
}),
|
||||
);
|
||||
let static_source = StaticToken::new(TEST_TOKEN.to_string());
|
||||
let static_source = StaticToken::new(&test_settings(), TEST_TOKEN.to_string()).expect("static source");
|
||||
let approle_source = AppRoleLogin::new(
|
||||
&test_settings(),
|
||||
"approle".to_string(),
|
||||
|
||||
@@ -166,6 +166,24 @@ fn rule_replicates(rule: &ReplicationRule, obj: &ObjectOpts) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn replication_filter_tags_match(filter: &s3s::dto::ReplicationRuleFilter, object_tags: &HashMap<String, String>) -> bool {
|
||||
let tag_matches = |tag: &s3s::dto::Tag| match (&tag.key, &tag.value) {
|
||||
(None, None) => true,
|
||||
(Some(key), _) if key.is_empty() => true,
|
||||
(Some(key), Some(value)) => object_tags.get(key) == Some(value),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
filter
|
||||
.and
|
||||
.as_ref()
|
||||
.and_then(|and| and.tags.as_deref())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(filter.tag.iter())
|
||||
.all(tag_matches)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReplicationTargetValidationError {
|
||||
RoleWithMultipleDestinations,
|
||||
@@ -704,7 +722,7 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
|
||||
|
||||
if let Some(filter) = &rule.filter {
|
||||
let object_tags = ReplicationTagFilter::decode_tags_to_map(&obj.user_tags);
|
||||
if filter.test_tags(&object_tags) {
|
||||
if replication_filter_tags_match(filter, &object_tags) {
|
||||
rules.push(rule.clone());
|
||||
}
|
||||
} else {
|
||||
@@ -1139,6 +1157,47 @@ mod tests {
|
||||
assert_eq!(validate_replication_config_structure(&structure_config(vec![rule])), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actionable_rules_require_every_and_tag_to_match() {
|
||||
let mut rule = replication_rule("rule-1", "arn:target:a");
|
||||
rule.filter = Some(s3s::dto::ReplicationRuleFilter {
|
||||
and: Some(s3s::dto::ReplicationRuleAndOperator {
|
||||
prefix: None,
|
||||
tags: Some(vec![
|
||||
s3s::dto::Tag {
|
||||
key: Some("env".to_string()),
|
||||
value: Some("prod".to_string()),
|
||||
},
|
||||
s3s::dto::Tag {
|
||||
key: Some("tier".to_string()),
|
||||
value: Some("gold".to_string()),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let config = structure_config(vec![rule]);
|
||||
let object = |user_tags: &str| ObjectOpts {
|
||||
name: "object".to_string(),
|
||||
user_tags: user_tags.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(config.filter_target_arns(&object("env=prod")).is_empty());
|
||||
assert_eq!(config.filter_target_arns(&object("env=prod&tier=gold")), vec!["arn:target:a"]);
|
||||
assert!(config.filter_target_arns(&object("")).is_empty());
|
||||
|
||||
let mut malformed = config;
|
||||
malformed.rules[0].filter.as_mut().unwrap().and.as_mut().unwrap().tags = Some(vec![s3s::dto::Tag {
|
||||
key: Some("env".to_string()),
|
||||
value: None,
|
||||
}]);
|
||||
assert!(
|
||||
malformed.filter_target_arns(&object("env=prod")).is_empty(),
|
||||
"a malformed tag filter must fail closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structure_validation_allows_tag_filter_when_delete_marker_replication_disabled() {
|
||||
let mut rule = replication_rule("rule-1", "arn:target:a");
|
||||
|
||||
@@ -580,6 +580,30 @@ impl FailStats {
|
||||
FailedMetric { count, size }
|
||||
}
|
||||
|
||||
/// Both rolling windows from one walk of the samples. `short` must be the
|
||||
/// narrower window; the walk stops at `long`. Callers that need both (the
|
||||
/// per-node site snapshot) would otherwise scan the deque twice while
|
||||
/// holding the bucket-stats read lock, and the deque is only bounded by
|
||||
/// the one-hour window - an unreachable target under load fills it.
|
||||
pub fn recent_windows(&self, short: Duration, long: Duration) -> (FailedMetric, FailedMetric) {
|
||||
let now = Instant::now();
|
||||
let mut short_metric = FailedMetric::default();
|
||||
let mut long_metric = FailedMetric::default();
|
||||
for sample in self.recent.iter().rev() {
|
||||
let age = now.duration_since(sample.observed_at);
|
||||
if age > long {
|
||||
break;
|
||||
}
|
||||
if age <= short {
|
||||
short_metric.count += 1;
|
||||
short_metric.size += sample.size;
|
||||
}
|
||||
long_metric.count += 1;
|
||||
long_metric.size += sample.size;
|
||||
}
|
||||
(short_metric, long_metric)
|
||||
}
|
||||
|
||||
pub fn merge(&self, other: &FailStats) -> Self {
|
||||
Self {
|
||||
count: self.count.saturating_add(other.count),
|
||||
@@ -912,6 +936,26 @@ mod tests {
|
||||
assert_eq!(last_hour.size, 96);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_stats_recent_windows_matches_two_separate_scans() {
|
||||
let mut stats = FailStats::default();
|
||||
stats.add_size(64, None::<&()>);
|
||||
stats.add_size(32, None::<&()>);
|
||||
|
||||
let (minute, hour) = stats.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
|
||||
let expected_minute = stats.recent_since(Duration::from_secs(60));
|
||||
let expected_hour = stats.recent_since(Duration::from_secs(60 * 60));
|
||||
|
||||
assert_eq!((minute.count, minute.size), (expected_minute.count, expected_minute.size));
|
||||
assert_eq!((hour.count, hour.size), (expected_hour.count, expected_hour.size));
|
||||
assert_eq!(minute.count, 2);
|
||||
assert_eq!(hour.size, 96);
|
||||
|
||||
let empty = FailStats::default();
|
||||
let (minute, hour) = empty.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
|
||||
assert_eq!((minute.count, minute.size, hour.count, hour.size), (0, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_stats_saturate_instead_of_wrapping() {
|
||||
let mut stats = FailStats {
|
||||
|
||||
@@ -90,6 +90,7 @@ s3s = { workspace = true, features = ["minio"] }
|
||||
hex-simd.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
temp-env = { workspace = true, features = ["async_closure"] }
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
tokio-test = { workspace = true }
|
||||
criterion = { workspace = true, features = ["html_reports"] }
|
||||
|
||||
@@ -327,6 +327,55 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-side switch for the pre-`1.0.0-alpha.91` nonce layout, in which a whole
|
||||
/// v1 segment reused the part nonce for every block.
|
||||
///
|
||||
/// On by default, because turning it off refuses to decrypt objects written
|
||||
/// before that release. Block zero's derived nonce equals that base nonce, so
|
||||
/// the layout also lets a frame encrypted at index zero authenticate anywhere
|
||||
/// in its segment; the in-segment layout lock catches that as soon as a later
|
||||
/// frame disagrees, but a stream that is nothing but repeats of frame zero has
|
||||
/// no such later frame. A deployment with no pre-alpha.91 objects should set
|
||||
/// this to `false` to remove that surface outright (backlog#2369 P2).
|
||||
///
|
||||
// RUSTFS_COMPAT_TODO(backlog-2369-legacy-nonce-fallback): Remove after the
|
||||
// minimum supported direct-upgrade release and after migration tooling has
|
||||
// rewritten every pre-alpha.91 encrypted object.
|
||||
pub const ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK: &str = "RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK";
|
||||
const DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK: bool = true;
|
||||
|
||||
fn legacy_nonce_fallback_enabled() -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
rustfs_utils::get_env_bool(
|
||||
ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
|
||||
DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
|
||||
)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHED.get_or_init(|| {
|
||||
rustfs_utils::get_env_bool(
|
||||
ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
|
||||
DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The nonce layout selected while decoding a legacy v1 segment.
|
||||
///
|
||||
/// A historical writer used one of these layouts consistently for every
|
||||
/// block in a segment. Once a non-zero block identifies that layout, accepting
|
||||
/// another layout would let an attacker replay a block encrypted at index zero.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum V1NonceLayout {
|
||||
Current,
|
||||
LegacyBlock,
|
||||
ReusedPart,
|
||||
}
|
||||
|
||||
pin_project! {
|
||||
/// A reader wrapper that decrypts data on the fly using AES-256-GCM.
|
||||
/// This is a demonstration. For production, use a secure and audited crypto library.
|
||||
@@ -358,6 +407,8 @@ pin_project! {
|
||||
segment_frames: usize,
|
||||
stream_saw_v2: bool,
|
||||
segments_completed: usize,
|
||||
v1_nonce_layout: Option<V1NonceLayout>,
|
||||
legacy_nonce_fallback: bool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +442,8 @@ where
|
||||
segment_frames: 0,
|
||||
stream_saw_v2: false,
|
||||
segments_completed: 0,
|
||||
v1_nonce_layout: None,
|
||||
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +494,8 @@ where
|
||||
segment_frames: 0,
|
||||
stream_saw_v2: false,
|
||||
segments_completed: 0,
|
||||
v1_nonce_layout: None,
|
||||
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,6 +602,7 @@ where
|
||||
*this.segment_frame_version = None;
|
||||
*this.saw_final_frame = false;
|
||||
*this.segment_frames = 0;
|
||||
*this.v1_nonce_layout = None;
|
||||
|
||||
if *this.multipart_mode {
|
||||
let next_part = if *this.current_part_index + 1 < this.multipart_parts.len() {
|
||||
@@ -696,26 +752,46 @@ where
|
||||
*this.base_nonce
|
||||
};
|
||||
let legacy_block_nonce = derive_block_nonce(&legacy_part_nonce, *this.block_index);
|
||||
match this.cipher.decrypt(&nonce, ciphertext) {
|
||||
Ok(plaintext) => plaintext,
|
||||
Err(primary_err) => {
|
||||
let legacy_nonce =
|
||||
Nonce::try_from(legacy_block_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
|
||||
|
||||
match this.cipher.decrypt(&legacy_nonce, ciphertext) {
|
||||
Ok(plaintext) => plaintext,
|
||||
Err(_) => {
|
||||
// Accept previously written streams that reused the part nonce
|
||||
// for every block inside a segment.
|
||||
let legacy_part_nonce = Nonce::try_from(legacy_part_nonce.as_slice())
|
||||
.map_err(|_| Error::other("invalid nonce length"))?;
|
||||
this.cipher
|
||||
.decrypt(&legacy_part_nonce, ciphertext)
|
||||
.map_err(|_| Error::other(format!("decrypt error: {primary_err}")))?
|
||||
}
|
||||
let legacy_part_nonce =
|
||||
Nonce::try_from(legacy_part_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
|
||||
let legacy_block_nonce =
|
||||
Nonce::try_from(legacy_block_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
|
||||
let layouts = [
|
||||
(V1NonceLayout::Current, &nonce),
|
||||
(V1NonceLayout::LegacyBlock, &legacy_block_nonce),
|
||||
(V1NonceLayout::ReusedPart, &legacy_part_nonce),
|
||||
];
|
||||
let selected = if *this.block_index == 0 { None } else { *this.v1_nonce_layout };
|
||||
let mut plaintext = None;
|
||||
let mut last_error = None;
|
||||
for (layout, candidate_nonce) in layouts {
|
||||
if selected.is_some_and(|expected| expected != layout) {
|
||||
continue;
|
||||
}
|
||||
if layout == V1NonceLayout::ReusedPart && !*this.legacy_nonce_fallback {
|
||||
continue;
|
||||
}
|
||||
match this.cipher.decrypt(candidate_nonce, ciphertext) {
|
||||
Ok(value) => {
|
||||
plaintext = Some((value, layout));
|
||||
break;
|
||||
}
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
let (plaintext, layout) = plaintext.ok_or_else(|| {
|
||||
Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"decrypt error: {}",
|
||||
last_error.map_or_else(|| "nonce layout rejected".to_string(), |error| error.to_string())
|
||||
),
|
||||
)
|
||||
})?;
|
||||
if *this.block_index > 0 && this.v1_nonce_layout.is_none() {
|
||||
*this.v1_nonce_layout = Some(layout);
|
||||
}
|
||||
plaintext
|
||||
};
|
||||
if *this.current_frame_type == FRAME_TYPE_V2_FINAL {
|
||||
*this.saw_final_frame = true;
|
||||
@@ -1003,6 +1079,154 @@ mod tests {
|
||||
assert_eq!(&decrypted, data);
|
||||
}
|
||||
|
||||
/// Encrypts `block_count` full v1 blocks, then overwrites frame one with a
|
||||
/// verbatim copy of frame zero. Every frame is the same length, so the
|
||||
/// stream keeps its original size and the forgery is invisible to any
|
||||
/// length check.
|
||||
async fn v1_stream_with_frame_zero_replayed_at_index_one(key: [u8; 32], nonce: [u8; 12], block_count: usize) -> Vec<u8> {
|
||||
assert!(block_count >= 2, "a replay needs at least two frames");
|
||||
let mut data = Vec::with_capacity(ENCRYPTION_BLOCK_SIZE * block_count);
|
||||
for index in 0..block_count {
|
||||
data.extend(std::iter::repeat_n(0xA1u8.wrapping_add(index as u8 * 17), ENCRYPTION_BLOCK_SIZE));
|
||||
}
|
||||
|
||||
let mut encrypt_reader = EncryptReader::new(Cursor::new(data), key, nonce);
|
||||
let mut encrypted = Vec::new();
|
||||
encrypt_reader.read_to_end(&mut encrypted).await.expect("encrypt v1 frames");
|
||||
|
||||
// Header layout: [type][len:24][crc:32]; `len` counts the payload plus
|
||||
// its own 4-byte CRC field, so the frame occupies 8 + (len - 4) bytes.
|
||||
let declared_len = (encrypted[1] as usize) | ((encrypted[2] as usize) << 8) | ((encrypted[3] as usize) << 16);
|
||||
let frame_len = 8 + declared_len - 4;
|
||||
let replayed_first = encrypted[..frame_len].to_vec();
|
||||
encrypted[frame_len..frame_len * 2].copy_from_slice(&replayed_first);
|
||||
encrypted
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decrypt_reader_rejects_a_replayed_first_v1_frame() {
|
||||
let key = [0x11; 32];
|
||||
let nonce = [0x22; 12];
|
||||
let encrypted = v1_stream_with_frame_zero_replayed_at_index_one(key, nonce, 3).await;
|
||||
|
||||
let mut decrypt_reader = DecryptReader::new(Cursor::new(encrypted), key, nonce);
|
||||
let error = decrypt_reader
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("a repeated index-zero frame must not authenticate at index one");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
/// The in-segment layout lock must not cost compatibility: every legacy v1
|
||||
/// shape the fallback chain exists for still decrypts under the default.
|
||||
#[tokio::test]
|
||||
async fn legacy_v1_streams_still_decrypt_under_the_default_fallback() {
|
||||
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, None::<&str>)], async {
|
||||
assert!(legacy_nonce_fallback_enabled(), "the legacy nonce fallback must stay on by default");
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
let mut nonce = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut key);
|
||||
rand::rng().fill_bytes(&mut nonce);
|
||||
let mut data = vec![0u8; ENCRYPTION_BLOCK_SIZE * 3 + 17];
|
||||
rand::rng().fill(&mut data[..]);
|
||||
|
||||
// Modern single-part stream.
|
||||
let mut encrypted = Vec::new();
|
||||
EncryptReader::new(Cursor::new(data.clone()), key, nonce)
|
||||
.read_to_end(&mut encrypted)
|
||||
.await
|
||||
.expect("modern v1 stream should encrypt");
|
||||
let mut decrypted = Vec::new();
|
||||
DecryptReader::new(Cursor::new(encrypted), key, nonce)
|
||||
.read_to_end(&mut decrypted)
|
||||
.await
|
||||
.expect("modern v1 stream should decrypt");
|
||||
assert_eq!(decrypted, data);
|
||||
|
||||
// Pre-alpha.91 stream that reused the part nonce for every block.
|
||||
let legacy = encrypt_with_legacy_nonce_reuse(&data, key, nonce);
|
||||
let mut decrypted = Vec::new();
|
||||
DecryptReader::new(Cursor::new(legacy), key, nonce)
|
||||
.read_to_end(&mut decrypted)
|
||||
.await
|
||||
.expect("a reused-nonce legacy stream should still decrypt");
|
||||
assert_eq!(decrypted, data);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// The residual after the layout lock: a stream that is nothing but repeats
|
||||
/// of frame zero has no later frame to disagree with the reused-part
|
||||
/// layout, so only turning the fallback off rejects it.
|
||||
#[tokio::test]
|
||||
async fn a_two_frame_replay_is_closed_only_by_disabling_the_legacy_fallback() {
|
||||
let key = [0x33; 32];
|
||||
let nonce = [0x44; 12];
|
||||
let encrypted = v1_stream_with_frame_zero_replayed_at_index_one(key, nonce, 2).await;
|
||||
|
||||
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, None::<&str>)], async {
|
||||
let mut forged = Vec::new();
|
||||
DecryptReader::new(Cursor::new(encrypted.clone()), key, nonce)
|
||||
.read_to_end(&mut forged)
|
||||
.await
|
||||
.expect("with the fallback on this forgery is still accepted");
|
||||
assert_eq!(forged.len(), ENCRYPTION_BLOCK_SIZE * 2);
|
||||
assert_eq!(
|
||||
&forged[..ENCRYPTION_BLOCK_SIZE],
|
||||
&forged[ENCRYPTION_BLOCK_SIZE..],
|
||||
"the accepted forgery is frame zero's plaintext twice over"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, Some("false"))], async {
|
||||
let error = DecryptReader::new(Cursor::new(encrypted.clone()), key, nonce)
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("with the fallback off the replayed frame must not authenticate");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Turning the fallback off removes exactly the third layout: modern v1
|
||||
/// streams keep decrypting, pre-alpha.91 reused-nonce streams stop.
|
||||
#[tokio::test]
|
||||
async fn disabling_the_legacy_nonce_fallback_refuses_only_reused_part_nonces() {
|
||||
let mut key = [0u8; 32];
|
||||
let mut nonce = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut key);
|
||||
rand::rng().fill_bytes(&mut nonce);
|
||||
let mut data = vec![0u8; ENCRYPTION_BLOCK_SIZE * 3 + 17];
|
||||
rand::rng().fill(&mut data[..]);
|
||||
|
||||
let mut modern = Vec::new();
|
||||
EncryptReader::new(Cursor::new(data.clone()), key, nonce)
|
||||
.read_to_end(&mut modern)
|
||||
.await
|
||||
.expect("modern v1 stream should encrypt");
|
||||
let legacy = encrypt_with_legacy_nonce_reuse(&data, key, nonce);
|
||||
|
||||
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, Some("false"))], async {
|
||||
assert!(!legacy_nonce_fallback_enabled(), "the switch must be observed");
|
||||
|
||||
let mut decrypted = Vec::new();
|
||||
DecryptReader::new(Cursor::new(modern), key, nonce)
|
||||
.read_to_end(&mut decrypted)
|
||||
.await
|
||||
.expect("modern v1 streams must keep decrypting with the fallback off");
|
||||
assert_eq!(decrypted, data);
|
||||
|
||||
let error = DecryptReader::new(Cursor::new(legacy), key, nonce)
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("the third layout must be gone when the fallback is off");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_decrypt_reader_only() {
|
||||
// Encrypt some data first
|
||||
|
||||
@@ -97,9 +97,9 @@ pub use scanner::{
|
||||
pub use scanner_io::{
|
||||
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshot, ScannerDirtyUsageState,
|
||||
acknowledge_dirty_usage_generation, acknowledge_scoped_dirty_usage, clear_dirty_usage_bucket, record_dirty_usage_bucket,
|
||||
record_dirty_usage_bucket_from_producer, record_dirty_usage_object, record_dirty_usage_object_from_producer,
|
||||
record_scanner_maintenance_change, scanner_activity_epoch, scanner_dirty_usage_snapshot, scanner_dirty_usage_state,
|
||||
scanner_maintenance_generation,
|
||||
record_dirty_usage_bucket_from_producer, record_dirty_usage_bucket_from_producers, record_dirty_usage_object,
|
||||
record_dirty_usage_object_from_producer, record_scanner_maintenance_change, scanner_activity_epoch,
|
||||
scanner_dirty_usage_snapshot, scanner_dirty_usage_state, scanner_maintenance_generation,
|
||||
};
|
||||
pub use segment_invalidation::SegmentInvalidationProducerIdentity;
|
||||
pub use sleeper::{DynamicSleeper, SCANNER_IDLE_MODE, SCANNER_SLEEPER};
|
||||
|
||||
@@ -964,9 +964,10 @@ async fn run_scanner_usage_recovery_intents_for_startup(
|
||||
Ok(attempted)
|
||||
}
|
||||
|
||||
/// Start normal scanning when enabled, or one resume-only cleanup attempt.
|
||||
/// Start normal scanning when enabled, or one bounded recovery attempt.
|
||||
/// The disabled branch returns a finite task for the startup owner to join;
|
||||
/// it never enables ordinary namespace scanning or accepts a new reset intent.
|
||||
/// it never enables ordinary namespace scanning while it replays durable reset
|
||||
/// intents and cleanup markers.
|
||||
pub async fn init_scanner_with_recovery(
|
||||
ctx: CancellationToken,
|
||||
storeapi: Arc<ECStore>,
|
||||
@@ -988,6 +989,17 @@ pub async fn init_scanner_with_recovery(
|
||||
return None;
|
||||
}
|
||||
Some(tokio::spawn(async move {
|
||||
if let Err(error) = run_scanner_usage_recovery_intents_for_startup(ctx.clone(), storeapi.clone()).await {
|
||||
warn!(
|
||||
target: "rustfs::scanner",
|
||||
event = EVENT_SCANNER_PERSIST_STATE,
|
||||
component = LOG_COMPONENT_SCANNER,
|
||||
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||
state = "recovery_intent_startup_discovery_failed",
|
||||
error = %error,
|
||||
"Scanner recovery intent startup discovery failed"
|
||||
);
|
||||
}
|
||||
if let Err(error) = resume_scanner_cycle_cleanup(ctx, storeapi).await {
|
||||
warn!(
|
||||
target: "rustfs::scanner",
|
||||
|
||||
@@ -128,6 +128,79 @@ pub(super) mod cleanup_io_fault {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) mod recovery_intent_accept_fault {
|
||||
use super::*;
|
||||
|
||||
enum Fault {
|
||||
Corrupt,
|
||||
Running,
|
||||
}
|
||||
|
||||
static NEXT_ACCEPT_READBACK_FAULT: StdMutex<Option<Fault>> = StdMutex::new(None);
|
||||
|
||||
pub(in crate::scanner) struct Guard;
|
||||
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
*NEXT_ACCEPT_READBACK_FAULT
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::scanner) fn corrupt_next_accept_readback() -> Guard {
|
||||
install(Fault::Corrupt)
|
||||
}
|
||||
|
||||
pub(in crate::scanner) fn advance_next_accept_readback_to_running() -> Guard {
|
||||
install(Fault::Running)
|
||||
}
|
||||
|
||||
fn install(fault: Fault) -> Guard {
|
||||
let mut slot = NEXT_ACCEPT_READBACK_FAULT
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
assert!(slot.is_none(), "only one recovery intent accept readback fault may be installed");
|
||||
*slot = Some(fault);
|
||||
Guard
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_apply<S>(storeapi: Arc<S>, path: &str) -> Result<(), ScannerError>
|
||||
where
|
||||
S: ScannerObjectIO,
|
||||
{
|
||||
let Some(fault) = NEXT_ACCEPT_READBACK_FAULT
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.take()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
match fault {
|
||||
Fault::Corrupt => save_config(storeapi, path, b"{corrupt".to_vec()).await.map_err(|err| {
|
||||
ScannerError::Other(format!("failed to inject scanner recovery intent accept readback fault: {err}"))
|
||||
}),
|
||||
Fault::Running => {
|
||||
let mut record = read_recovery_intent_record(storeapi.clone(), path).await?.ok_or_else(|| {
|
||||
ScannerError::Other("scanner recovery intent disappeared before fault injection".to_string())
|
||||
})?;
|
||||
record.state = SCANNER_RECOVERY_INTENT_STATE_RUNNING.to_string();
|
||||
save_config(
|
||||
storeapi,
|
||||
path,
|
||||
serde_json::to_vec(&record)
|
||||
.map_err(|err| ScannerError::Other(format!("failed to encode scanner recovery intent fault: {err}")))?,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
ScannerError::Other(format!("failed to inject scanner recovery intent accept readback fault: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ScannerCycleRecoveryStatus {
|
||||
/// The immutable primary object whose revision is being guarded.
|
||||
@@ -564,6 +637,17 @@ fn compare_recovery_intent(
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_recovery_intent_acceptance(
|
||||
expected: ScannerRecoveryIntentRecord,
|
||||
persisted: ScannerRecoveryIntentRecord,
|
||||
) -> ScannerRecoveryIntentAcceptResult {
|
||||
if persisted == expected {
|
||||
ScannerRecoveryIntentAcceptResult::Accepted { record: persisted }
|
||||
} else {
|
||||
compare_recovery_intent(&expected, persisted)
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_recovery_intent_record(
|
||||
storeapi: Arc<impl ScannerObjectIO>,
|
||||
path: &str,
|
||||
@@ -757,7 +841,20 @@ pub async fn accept_scanner_usage_recovery_intent(
|
||||
.map_err(|err| ScannerError::Other(format!("failed to encode scanner recovery intent: {err}")))?;
|
||||
match save_config_with_preconditions(storeapi.clone(), &path, encoded, DataUsageCacheRevision::Missing.preconditions()).await
|
||||
{
|
||||
Ok(_) => Ok(ScannerRecoveryIntentAcceptResult::Accepted { record: candidate }),
|
||||
Ok(_) => {
|
||||
#[cfg(test)]
|
||||
recovery_intent_accept_fault::maybe_apply(storeapi.clone(), &path).await?;
|
||||
let persisted = match read_recovery_intent_record(storeapi.clone(), &path).await {
|
||||
Ok(Some(record)) => record,
|
||||
Ok(None) => {
|
||||
return Err(ScannerError::Other(
|
||||
"scanner recovery intent disappeared before acceptance confirmation".to_string(),
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
Ok(confirm_recovery_intent_acceptance(candidate, persisted))
|
||||
}
|
||||
Err(EcstoreError::PreconditionFailed) => {
|
||||
let existing = read_recovery_intent_record(storeapi, &path).await?;
|
||||
let Some(existing) = existing else {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::super::cycle_state::cleanup_io_fault;
|
||||
use super::super::cycle_state::{cleanup_io_fault, recovery_intent_accept_fault};
|
||||
use super::*;
|
||||
use crate::storage_api::owner::{EcstoreRebalStatus, EcstoreRebalanceInfo, EcstoreRebalanceMeta, EcstoreRebalanceStats};
|
||||
|
||||
@@ -256,6 +256,100 @@ async fn scanner_recovery_intent_accept_is_durable_and_idempotent() {
|
||||
assert_eq!(replay, ScannerRecoveryIntentAcceptResult::Replayed { record });
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_recovery_intent_accept_requires_confirmed_readback() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
let _fault = recovery_intent_accept_fault::corrupt_next_accept_readback();
|
||||
let error =
|
||||
accept_scanner_usage_recovery_intent(store.clone(), recovery_intent_request("intent-key-0001-readback", "operator-a"))
|
||||
.await
|
||||
.expect_err("accept must fail when the just-written intent cannot be confirmed");
|
||||
assert!(error.to_string().contains("scanner recovery intent is invalid"), "{error}");
|
||||
|
||||
let restarted = restart_scanner_cycle_store_from(&store).await;
|
||||
let error = scanner_usage_recovery_intents_for_startup(&CancellationToken::new(), restarted)
|
||||
.await
|
||||
.expect_err("unconfirmed corrupt intent must remain a fail-closed startup error");
|
||||
assert!(error.to_string().contains("scanner recovery intent is invalid"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_recovery_intent_accept_replays_if_execution_advances_before_readback() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
let request = recovery_intent_request("intent-key-0001-running", "operator-a");
|
||||
let _fault = recovery_intent_accept_fault::advance_next_accept_readback_to_running();
|
||||
let replay = accept_scanner_usage_recovery_intent(store.clone(), request.clone())
|
||||
.await
|
||||
.expect("same request advanced by execution remains idempotent");
|
||||
let record = match replay {
|
||||
ScannerRecoveryIntentAcceptResult::Replayed { record } => record,
|
||||
other => panic!("advanced same-request record must replay instead of conflict: {other:?}"),
|
||||
};
|
||||
assert_eq!(record.state, "running");
|
||||
|
||||
let retry = accept_scanner_usage_recovery_intent(store, request)
|
||||
.await
|
||||
.expect("lost response retry observes the running durable record");
|
||||
assert_eq!(retry, ScannerRecoveryIntentAcceptResult::Replayed { record });
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn concurrent_scanner_recovery_intent_acceptance_uses_one_durable_record() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
let request = recovery_intent_request("intent-key-0001-concurrent", "operator-a");
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..12 {
|
||||
let store = store.clone();
|
||||
let request = request.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
accept_scanner_usage_recovery_intent(store, request)
|
||||
.await
|
||||
.expect("concurrent same-key accept should converge")
|
||||
}));
|
||||
}
|
||||
|
||||
let mut accepted = 0usize;
|
||||
let mut replayed = 0usize;
|
||||
let mut records = Vec::new();
|
||||
for task in tasks {
|
||||
match task.await.expect("accept task should not panic") {
|
||||
ScannerRecoveryIntentAcceptResult::Accepted { record } => {
|
||||
accepted += 1;
|
||||
records.push(record);
|
||||
}
|
||||
ScannerRecoveryIntentAcceptResult::Replayed { record } => {
|
||||
replayed += 1;
|
||||
records.push(record);
|
||||
}
|
||||
other => panic!("same-key accepts must not conflict: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(accepted, 1, "exactly one request may win the missing-record CAS");
|
||||
assert_eq!(replayed, 11, "all other same-key requests must replay the durable winner");
|
||||
assert!(
|
||||
records.windows(2).all(|pair| pair[0] == pair[1]),
|
||||
"all accepts must return the same durable identity"
|
||||
);
|
||||
|
||||
let restarted = restart_scanner_cycle_store_from(&store).await;
|
||||
let replayable = scanner_usage_recovery_intents_for_startup(&CancellationToken::new(), restarted.clone())
|
||||
.await
|
||||
.expect("startup should rediscover the single indexed intent");
|
||||
assert_eq!(replayable, vec![records[0].intent_id.clone()]);
|
||||
let replay = accept_scanner_usage_recovery_intent(restarted, request)
|
||||
.await
|
||||
.expect("lost response after restart should replay the same record");
|
||||
assert_eq!(
|
||||
replay,
|
||||
ScannerRecoveryIntentAcceptResult::Replayed {
|
||||
record: records[0].clone()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_recovery_intent_executor_persists_completed_progress() {
|
||||
@@ -457,7 +551,7 @@ async fn scanner_recovery_intent_startup_rejects_corrupt_pending_record() {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_recovery_intent_disabled_startup_preserves_non_terminal_intent() {
|
||||
async fn scanner_recovery_intent_disabled_startup_replays_non_terminal_intent() {
|
||||
let (_dir, store) = setup_scanner_cycle_store().await;
|
||||
let record = match accept_scanner_usage_recovery_intent(
|
||||
store.clone(),
|
||||
@@ -473,12 +567,12 @@ async fn scanner_recovery_intent_disabled_startup_preserves_non_terminal_intent(
|
||||
let restarted = restart_scanner_cycle_store_from(&store).await;
|
||||
run_disabled_startup(CancellationToken::new(), restarted.clone()).await;
|
||||
|
||||
let preserved = get_scanner_usage_recovery_intent(restarted, &record.intent_id)
|
||||
let completed = get_scanner_usage_recovery_intent(restarted, &record.intent_id)
|
||||
.await
|
||||
.expect("startup-skipped intent should read")
|
||||
.expect("startup-skipped intent should remain durable");
|
||||
assert_eq!(preserved.state, "accepted");
|
||||
assert_eq!(preserved.intent_id, record.intent_id);
|
||||
.expect("startup-replayed intent should read")
|
||||
.expect("startup-replayed intent should remain durable");
|
||||
assert_eq!(completed.state, "completed");
|
||||
assert_eq!(completed.intent_id, record.intent_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -547,6 +641,17 @@ async fn scanner_recovery_intent_query_rejects_corrupt_or_unknown_records() {
|
||||
.await
|
||||
.expect_err("corrupt intent must not decode as absent");
|
||||
assert!(error.to_string().contains("scanner recovery intent is invalid"));
|
||||
|
||||
let mut future = serde_json::to_value(&record).expect("record value");
|
||||
future["future_writer_capability"] = serde_json::json!("durable-accept-v2");
|
||||
save_config(store.clone(), &path, serde_json::to_vec(&future).expect("future record should encode"))
|
||||
.await
|
||||
.expect("future durable record");
|
||||
let error = get_scanner_usage_recovery_intent(store.clone(), &record.intent_id)
|
||||
.await
|
||||
.expect_err("future writer payload must not decode as a known terminal state");
|
||||
assert!(error.to_string().contains("scanner recovery intent is invalid"));
|
||||
|
||||
let unknown = get_scanner_usage_recovery_intent(store, &scanner_recovery_actor_sha256("missing"))
|
||||
.await
|
||||
.expect("missing intent should read as absent");
|
||||
|
||||
@@ -98,6 +98,24 @@ const METRIC_SCANNER_SET_SCANS_QUEUED: &str = "rustfs_scanner_set_scans_queued";
|
||||
const METRIC_SCANNER_DISK_BUCKET_SCANS_ACTIVE: &str = "rustfs_scanner_disk_bucket_scans_active";
|
||||
const METRIC_SCANNER_DISK_BUCKET_SCANS_QUEUED: &str = "rustfs_scanner_disk_bucket_scans_queued";
|
||||
|
||||
pub(crate) const SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS: [&str; 7] = [
|
||||
"source",
|
||||
"bucket_incarnation",
|
||||
"key_format",
|
||||
"baseline_scan_plan_digest",
|
||||
"process_epoch",
|
||||
"generation_window",
|
||||
"producer_identities",
|
||||
];
|
||||
pub(crate) const SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS: [&str; 6] = [
|
||||
"missing_producer_identity",
|
||||
"restart_gap",
|
||||
"generation_gap",
|
||||
"overflow",
|
||||
"missing_cold_zero_walk_oracle",
|
||||
"distributed_without_peer_invalidation",
|
||||
];
|
||||
|
||||
pub type DirtyUsageBuckets = HashMap<String, u64>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -163,11 +181,51 @@ struct ScannerPeerDirtyUsageExpectation {
|
||||
struct VerifiedRemoteDirtyUsage {
|
||||
dirty_buckets: HashSet<String>,
|
||||
acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
|
||||
peer_count: usize,
|
||||
dirty_peer_count: usize,
|
||||
}
|
||||
|
||||
struct ScannerBucketScopeResolutionResult {
|
||||
scope: ScannerBucketScanScope,
|
||||
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
|
||||
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DistributedSegmentInvalidationEvidence {
|
||||
pub(crate) invalidation_domain: crate::segment_invalidation::SegmentInvalidationDomain,
|
||||
pub(crate) distributed_ec_invalidation: bool,
|
||||
pub(crate) peer_count: usize,
|
||||
pub(crate) dirty_peer_count: usize,
|
||||
pub(crate) same_window_remote_proof: bool,
|
||||
pub(crate) all_peers_bound_to_generation_window: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ScannerSegmentReuseActivationProof {
|
||||
pub(crate) production_activation: bool,
|
||||
pub(crate) durable_producer_identity: bool,
|
||||
pub(crate) restart_gap_absent: bool,
|
||||
pub(crate) generation_window_bound: bool,
|
||||
pub(crate) overflow_absent: bool,
|
||||
pub(crate) cold_zero_walk_oracle: bool,
|
||||
pub(crate) distributed_peer_invalidation: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ScannerSegmentReuseActivationPreflight {
|
||||
pub(crate) production_activation: bool,
|
||||
pub(crate) scanner_segment_reuse_activated: bool,
|
||||
pub(crate) proof_inputs: &'static [&'static str],
|
||||
pub(crate) fail_closed_checks: &'static [&'static str],
|
||||
pub(crate) fail_closed_blockers: [Option<&'static str>; 6],
|
||||
}
|
||||
|
||||
impl ScannerSegmentReuseActivationPreflight {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fail_closed_blockers(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.fail_closed_blockers.iter().filter_map(|blocker| *blocker)
|
||||
}
|
||||
}
|
||||
|
||||
fn verified_remote_dirty_usage(
|
||||
@@ -191,6 +249,7 @@ fn verified_remote_dirty_usage(
|
||||
|| !snapshot.complete
|
||||
|| snapshot.pending_bucket_count != u64::try_from(snapshot.buckets.len()).unwrap_or(u64::MAX)
|
||||
|| (expected.pending && snapshot.pending_bucket_count == 0)
|
||||
|| (!expected.pending && snapshot.pending_bucket_count != 0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -216,9 +275,13 @@ fn verified_remote_dirty_usage(
|
||||
}
|
||||
}
|
||||
|
||||
let peer_count = received_peers.len();
|
||||
let dirty_peer_count = acknowledgements.len();
|
||||
(received_peers.len() == expected_peers.len()).then_some(VerifiedRemoteDirtyUsage {
|
||||
dirty_buckets,
|
||||
acknowledgements,
|
||||
peer_count,
|
||||
dirty_peer_count,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -244,8 +307,11 @@ fn resolve_remote_dirty_usage_scope(
|
||||
let default_result = |scope: ScannerBucketScanScope| ScannerBucketScopeResolutionResult {
|
||||
scope,
|
||||
remote_dirty_usage_acknowledgements: Vec::new(),
|
||||
distributed_segment_invalidation_evidence: None,
|
||||
};
|
||||
|
||||
let peer_count = remote_dirty_usage.peer_count;
|
||||
let dirty_peer_count = remote_dirty_usage.dirty_peer_count;
|
||||
dirty_buckets.extend(remote_dirty_usage.dirty_buckets);
|
||||
// Peer snapshots contribute bucket names only; the local prefix scopes
|
||||
// would narrow a bucket a peer dirtied elsewhere, so the merged scope
|
||||
@@ -283,10 +349,21 @@ fn resolve_remote_dirty_usage_scope(
|
||||
if scanner_scoped_dirty_usage_ack_exceeds_cost_threshold(&scoped_acknowledgements) {
|
||||
return default_result(ScannerBucketScanScope::default());
|
||||
}
|
||||
let has_scoped_acknowledgements = !scoped_acknowledgements.is_empty();
|
||||
|
||||
ScannerBucketScopeResolutionResult {
|
||||
scope,
|
||||
remote_dirty_usage_acknowledgements: scoped_acknowledgements,
|
||||
distributed_segment_invalidation_evidence: (dirty_peer_count > 0 && has_scoped_acknowledgements).then_some(
|
||||
DistributedSegmentInvalidationEvidence {
|
||||
invalidation_domain: crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc,
|
||||
distributed_ec_invalidation: true,
|
||||
peer_count,
|
||||
dirty_peer_count,
|
||||
same_window_remote_proof: true,
|
||||
all_peers_bound_to_generation_window: true,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,10 +483,58 @@ fn scoped_scan_scope_from_dirty_buckets(
|
||||
ScannerBucketScanScope::from_dirty_buckets(selected_buckets, selected_bucket_prefixes, baseline_scan_plan_digest)
|
||||
}
|
||||
|
||||
fn scanner_segment_reuse_activated() -> bool {
|
||||
fn scanner_segment_reuse_activation_preflight() -> ScannerSegmentReuseActivationPreflight {
|
||||
// Production segment reuse stays disabled until a durable mutation-stream
|
||||
// proof satisfies the segment invalidation contract.
|
||||
false
|
||||
scanner_segment_reuse_activation_preflight_from_proof(ScannerSegmentReuseActivationProof::default())
|
||||
}
|
||||
|
||||
fn scanner_segment_reuse_activation_preflight_from_proof(
|
||||
proof: ScannerSegmentReuseActivationProof,
|
||||
) -> ScannerSegmentReuseActivationPreflight {
|
||||
ScannerSegmentReuseActivationPreflight {
|
||||
production_activation: proof.production_activation,
|
||||
scanner_segment_reuse_activated: proof.production_activation
|
||||
&& proof.durable_producer_identity
|
||||
&& proof.restart_gap_absent
|
||||
&& proof.generation_window_bound
|
||||
&& proof.overflow_absent
|
||||
&& proof.cold_zero_walk_oracle
|
||||
&& proof.distributed_peer_invalidation,
|
||||
proof_inputs: &SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS,
|
||||
fail_closed_checks: &SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS,
|
||||
fail_closed_blockers: [
|
||||
(!proof.durable_producer_identity).then_some("missing_producer_identity"),
|
||||
(!proof.restart_gap_absent).then_some("restart_gap"),
|
||||
(!proof.generation_window_bound).then_some("generation_gap"),
|
||||
(!proof.overflow_absent).then_some("overflow"),
|
||||
(!proof.cold_zero_walk_oracle).then_some("missing_cold_zero_walk_oracle"),
|
||||
(!proof.distributed_peer_invalidation).then_some("distributed_without_peer_invalidation"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn scanner_segment_reuse_activation_preflight_for_cycle(
|
||||
dirty_usage_snapshot: &DirtyUsageSnapshot,
|
||||
distributed: bool,
|
||||
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
|
||||
cold_zero_walk_oracle: bool,
|
||||
) -> ScannerSegmentReuseActivationPreflight {
|
||||
scanner_segment_reuse_activation_preflight_from_proof(ScannerSegmentReuseActivationProof {
|
||||
production_activation: false,
|
||||
durable_producer_identity: false,
|
||||
restart_gap_absent: false,
|
||||
generation_window_bound: dirty_usage_snapshot.covers_all_pending
|
||||
&& dirty_usage_snapshot.generation != 0
|
||||
&& dirty_usage_snapshot.generation != u64::MAX,
|
||||
overflow_absent: dirty_usage_snapshot.covers_all_pending,
|
||||
cold_zero_walk_oracle,
|
||||
distributed_peer_invalidation: !distributed || distributed_segment_invalidation_evidence.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
fn scanner_segment_reuse_activated() -> bool {
|
||||
scanner_segment_reuse_activation_preflight().scanner_segment_reuse_activated
|
||||
}
|
||||
|
||||
pub(crate) fn is_scanner_metadata_corrupt_error(err: &StorageError) -> bool {
|
||||
@@ -471,6 +596,7 @@ pub struct ScannerBucketScanPlan {
|
||||
bucket_failures: ScannerBucketFailureState,
|
||||
pending_maintenance_work: Arc<AtomicBool>,
|
||||
cache_cycle_floor: Arc<AtomicU64>,
|
||||
cold_zero_walk_reuse_observed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -603,6 +729,25 @@ fn scanner_bucket_scan_status(has_failed: bool, has_partial: bool, has_namespace
|
||||
}
|
||||
}
|
||||
|
||||
fn scanner_cycle_cold_zero_walk_oracle(
|
||||
scan_scope: &ScannerBucketScanScope,
|
||||
all_buckets: &[BucketInfo],
|
||||
completed_all_sets: bool,
|
||||
scan_scope_matches: bool,
|
||||
bucket_scan_status: ScannerBucketScanStatus,
|
||||
cold_zero_walk_reuse_observed: bool,
|
||||
) -> bool {
|
||||
let Some(selected_buckets) = scan_scope.selected_buckets.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
cold_zero_walk_reuse_observed
|
||||
&& !selected_buckets.is_empty()
|
||||
&& completed_all_sets
|
||||
&& scan_scope_matches
|
||||
&& bucket_scan_status == ScannerBucketScanStatus::Complete
|
||||
&& all_buckets.iter().any(|bucket| !selected_buckets.contains(&bucket.name))
|
||||
}
|
||||
|
||||
fn classify_nsscanner_cycle(
|
||||
completed_all_sets: bool,
|
||||
budget_elapsed: bool,
|
||||
@@ -1056,6 +1201,8 @@ pub(crate) struct ScannerCycleResult {
|
||||
observational_snapshot_published: bool,
|
||||
dirty_usage_clear: Option<DirtyUsageBuckets>,
|
||||
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
|
||||
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
|
||||
segment_reuse_activation_preflight: ScannerSegmentReuseActivationPreflight,
|
||||
remote_publication_lease_targets: Vec<(String, String, u64)>,
|
||||
failed_dirty_usage: bool,
|
||||
pending_maintenance_work: bool,
|
||||
@@ -1072,6 +1219,8 @@ impl ScannerCycleResult {
|
||||
observational_snapshot_published: false,
|
||||
dirty_usage_clear,
|
||||
remote_dirty_usage_acknowledgements: Vec::new(),
|
||||
distributed_segment_invalidation_evidence: None,
|
||||
segment_reuse_activation_preflight: scanner_segment_reuse_activation_preflight(),
|
||||
remote_publication_lease_targets: Vec::new(),
|
||||
failed_dirty_usage: false,
|
||||
pending_maintenance_work: false,
|
||||
@@ -1137,6 +1286,21 @@ impl ScannerCycleResult {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_distributed_segment_invalidation_evidence(
|
||||
mut self,
|
||||
evidence: Option<DistributedSegmentInvalidationEvidence>,
|
||||
) -> Self {
|
||||
self.publication_expectation = None;
|
||||
self.distributed_segment_invalidation_evidence = evidence;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_segment_reuse_activation_preflight(mut self, preflight: ScannerSegmentReuseActivationPreflight) -> Self {
|
||||
self.publication_expectation = None;
|
||||
self.segment_reuse_activation_preflight = preflight;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_remote_publication_lease_targets(mut self, targets: Vec<(String, String, u64)>) -> Self {
|
||||
self.publication_expectation = None;
|
||||
self.remote_publication_lease_targets = targets;
|
||||
@@ -1224,9 +1388,9 @@ pub(crate) use cache::{
|
||||
pub use dirty_usage::{
|
||||
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshot, ScannerDirtyUsageState,
|
||||
acknowledge_dirty_usage_generation, acknowledge_scoped_dirty_usage, clear_dirty_usage_bucket, record_dirty_usage_bucket,
|
||||
record_dirty_usage_bucket_from_producer, record_dirty_usage_object, record_dirty_usage_object_from_producer,
|
||||
record_scanner_maintenance_change, scanner_activity_epoch, scanner_dirty_usage_snapshot, scanner_dirty_usage_state,
|
||||
scanner_maintenance_generation,
|
||||
record_dirty_usage_bucket_from_producer, record_dirty_usage_bucket_from_producers, record_dirty_usage_object,
|
||||
record_dirty_usage_object_from_producer, record_scanner_maintenance_change, scanner_activity_epoch,
|
||||
scanner_dirty_usage_snapshot, scanner_dirty_usage_state, scanner_maintenance_generation,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use dirty_usage::{clear_dirty_usage_buckets_for_tests, dirty_usage_buckets_for_tests};
|
||||
|
||||
@@ -226,12 +226,21 @@ mod scoped_dirty_usage_tests {
|
||||
record_dirty_usage_object_from_producer("photos", "hot/object", SegmentInvalidationProducerIdentity::PutObject);
|
||||
record_dirty_usage_object_from_producer("photos", "archive/object", SegmentInvalidationProducerIdentity::DeleteObject);
|
||||
record_dirty_usage_bucket_from_producer("photos", SegmentInvalidationProducerIdentity::Unknown);
|
||||
record_dirty_usage_bucket_from_producers(
|
||||
"photos",
|
||||
[
|
||||
SegmentInvalidationProducerIdentity::DeleteMarker,
|
||||
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
dirty_usage_producer_identities_for_tests(),
|
||||
BTreeSet::from([
|
||||
SegmentInvalidationProducerIdentity::PutObject,
|
||||
SegmentInvalidationProducerIdentity::DeleteObject
|
||||
SegmentInvalidationProducerIdentity::DeleteObject,
|
||||
SegmentInvalidationProducerIdentity::DeleteMarker,
|
||||
SegmentInvalidationProducerIdentity::AbortMultipartUpload
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -292,6 +301,18 @@ pub fn record_dirty_usage_bucket_from_producer(
|
||||
record_dirty_usage_bucket_inner(bucket);
|
||||
}
|
||||
|
||||
pub fn record_dirty_usage_bucket_from_producers<I>(bucket: &str, producers: I)
|
||||
where
|
||||
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
|
||||
{
|
||||
if bucket.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
record_segment_invalidation_producer_identities(producers);
|
||||
record_dirty_usage_bucket_inner(bucket);
|
||||
}
|
||||
|
||||
fn record_dirty_usage_bucket_inner(bucket: &str) {
|
||||
let pending_buckets = {
|
||||
let mut dirty_buckets = dirty_usage_buckets();
|
||||
@@ -367,8 +388,18 @@ fn record_dirty_usage_object_inner(bucket: &str, object: &str) {
|
||||
}
|
||||
|
||||
fn record_segment_invalidation_producer_identity(producer: crate::segment_invalidation::SegmentInvalidationProducerIdentity) {
|
||||
if producer.producer().is_some() {
|
||||
dirty_usage_producer_identities().insert(producer);
|
||||
record_segment_invalidation_producer_identities([producer]);
|
||||
}
|
||||
|
||||
fn record_segment_invalidation_producer_identities<I>(producers: I)
|
||||
where
|
||||
I: IntoIterator<Item = crate::segment_invalidation::SegmentInvalidationProducerIdentity>,
|
||||
{
|
||||
let mut identities = dirty_usage_producer_identities();
|
||||
for producer in producers {
|
||||
if producer.producer().is_some() {
|
||||
identities.insert(producer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ impl ScannerIOCache for SetDisks {
|
||||
bucket_failures,
|
||||
pending_maintenance_work,
|
||||
cache_cycle_floor,
|
||||
cold_zero_walk_reuse_observed,
|
||||
} = scan_plan;
|
||||
let scan_plan_digest = scanner_bucket_work_digest(scan_plan_digest, scan_mode, requires_full_scan);
|
||||
let bucket_work_digest = scanner_bucket_work_digest(bucket_coverage_digest, scan_mode, requires_full_scan);
|
||||
@@ -219,6 +220,9 @@ impl ScannerIOCache for SetDisks {
|
||||
},
|
||||
current_bucket_incarnations.as_ref(),
|
||||
);
|
||||
let cold_zero_walk_reuse_candidate = scoped_scan.as_ref().is_some_and(|prepared| {
|
||||
old_cache.info.next_cycle < want_cycle && !prepared.buckets.is_empty() && prepared.buckets.len() < all_buckets.len()
|
||||
});
|
||||
let mut scoped_cache = scoped_scan.map(|mut prepared| {
|
||||
buckets = prepared.buckets;
|
||||
prepared.cache.info.scan_coverage_digest = Some(bucket_coverage_digest);
|
||||
@@ -1466,6 +1470,9 @@ impl ScannerIOCache for SetDisks {
|
||||
cache.info.lkg_last_update = None;
|
||||
cache.info.lkg_leader_epoch = None;
|
||||
cache.info.lkg_scan_plan_digest = None;
|
||||
if cold_zero_walk_reuse_candidate {
|
||||
cold_zero_walk_reuse_observed.store(true, Ordering::Release);
|
||||
}
|
||||
cache.clone()
|
||||
};
|
||||
let _ = persist_and_publish_cache_snapshot(
|
||||
|
||||
@@ -122,6 +122,7 @@ where
|
||||
let default_result = |scope: ScannerBucketScanScope| ScannerBucketScopeResolutionResult {
|
||||
scope,
|
||||
remote_dirty_usage_acknowledgements: Vec::new(),
|
||||
distributed_segment_invalidation_evidence: None,
|
||||
};
|
||||
if resolution.requires_full_scan {
|
||||
return default_result(ScannerBucketScanScope::default());
|
||||
@@ -408,6 +409,7 @@ where
|
||||
)
|
||||
.await;
|
||||
let remote_dirty_usage_acknowledgements = scope_resolution.remote_dirty_usage_acknowledgements;
|
||||
let distributed_segment_invalidation_evidence = scope_resolution.distributed_segment_invalidation_evidence;
|
||||
let scan_scope = scope_resolution.scope;
|
||||
#[cfg(test)]
|
||||
if let Some(observer) = resolved_scope_observer {
|
||||
@@ -465,12 +467,15 @@ where
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let segment_reuse_activation_preflight =
|
||||
scanner_segment_reuse_activation_preflight_for_cycle(&dirty_usage_snapshot, distributed, None, false);
|
||||
return Ok(ScannerCycleResult::new(status, dirty_usage_clear)
|
||||
.with_publication_epoch(publication_epoch)
|
||||
.with_activity_digest(activity_digest)
|
||||
.with_observational_snapshot_published(observational_snapshot_published)
|
||||
.with_remote_publication_lease_targets(remote_publication_lease_targets)
|
||||
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
|
||||
.with_segment_reuse_activation_preflight(segment_reuse_activation_preflight)
|
||||
.with_publication_expectation(publication_expectation));
|
||||
}
|
||||
|
||||
@@ -495,6 +500,7 @@ where
|
||||
);
|
||||
let bucket_failures = ScannerBucketFailureState::default();
|
||||
let pending_maintenance_work = Arc::new(AtomicBool::new(false));
|
||||
let cold_zero_walk_reuse_observed = Arc::new(AtomicBool::new(false));
|
||||
record_set_scan_concurrency_limit(set_scan_limit);
|
||||
debug!(
|
||||
target: "rustfs::scanner::io",
|
||||
@@ -588,6 +594,7 @@ where
|
||||
bucket_failures: bucket_failures.clone(),
|
||||
pending_maintenance_work: pending_maintenance_work.clone(),
|
||||
cache_cycle_floor: cache_cycle_floor.clone(),
|
||||
cold_zero_walk_reuse_observed: cold_zero_walk_reuse_observed.clone(),
|
||||
};
|
||||
// Spawn task to run the scanner
|
||||
let scanner_fut = tokio::spawn(async move {
|
||||
@@ -691,6 +698,20 @@ where
|
||||
scan_scope_matches && !partial_buckets.is_empty(),
|
||||
scan_scope_matches && !namespace_not_found_buckets.is_empty(),
|
||||
);
|
||||
let cold_zero_walk_oracle = scanner_cycle_cold_zero_walk_oracle(
|
||||
&scan_scope,
|
||||
&all_buckets,
|
||||
completed_all_sets,
|
||||
scan_scope_matches,
|
||||
bucket_scan_status,
|
||||
cold_zero_walk_reuse_observed.load(Ordering::Acquire),
|
||||
);
|
||||
let segment_reuse_activation_preflight = scanner_segment_reuse_activation_preflight_for_cycle(
|
||||
&dirty_usage_snapshot,
|
||||
distributed,
|
||||
distributed_segment_invalidation_evidence,
|
||||
cold_zero_walk_oracle,
|
||||
);
|
||||
let pending_maintenance_work = pending_maintenance_work_for_cycle(&pending_maintenance_work, &results);
|
||||
let observed_cycle_floor = cache_cycle_floor.load(Ordering::Acquire);
|
||||
let required_cycle_floor = (observed_cycle_floor > want_cycle).then_some(observed_cycle_floor);
|
||||
@@ -783,6 +804,8 @@ where
|
||||
.with_observational_snapshot_published(observational_snapshot_published)
|
||||
.with_remote_publication_lease_targets(remote_publication_lease_targets)
|
||||
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
|
||||
.with_distributed_segment_invalidation_evidence(distributed_segment_invalidation_evidence)
|
||||
.with_segment_reuse_activation_preflight(segment_reuse_activation_preflight)
|
||||
.with_failed_dirty_usage(!failed_buckets.is_empty())
|
||||
.with_pending_maintenance_work(pending_maintenance_work)
|
||||
.with_required_cycle_floor(required_cycle_floor)
|
||||
|
||||
@@ -85,6 +85,162 @@ fn scanner_activity_preflight_defers_a_temporarily_offline_peer() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_segment_reuse_activation_preflight_reports_release_gate_inputs() {
|
||||
let preflight = scanner_segment_reuse_activation_preflight();
|
||||
|
||||
assert!(!preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert!(!scanner_segment_reuse_activated());
|
||||
assert_eq!(preflight.proof_inputs, SCANNER_SEGMENT_ACTIVATION_PROOF_INPUTS);
|
||||
assert_eq!(preflight.fail_closed_checks, SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS);
|
||||
assert_eq!(
|
||||
preflight.fail_closed_blockers().collect::<Vec<_>>(),
|
||||
SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_segment_reuse_activation_requires_every_preflight_proof() {
|
||||
let complete_proof = ScannerSegmentReuseActivationProof {
|
||||
production_activation: true,
|
||||
durable_producer_identity: true,
|
||||
restart_gap_absent: true,
|
||||
generation_window_bound: true,
|
||||
overflow_absent: true,
|
||||
cold_zero_walk_oracle: true,
|
||||
distributed_peer_invalidation: true,
|
||||
};
|
||||
|
||||
let mut production_disabled = complete_proof;
|
||||
production_disabled.production_activation = false;
|
||||
let preflight = scanner_segment_reuse_activation_preflight_from_proof(production_disabled);
|
||||
assert!(!preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
|
||||
|
||||
let preflight = scanner_segment_reuse_activation_preflight_from_proof(complete_proof);
|
||||
assert!(preflight.production_activation);
|
||||
assert!(preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), Vec::<&str>::new());
|
||||
|
||||
let mut missing_identity = complete_proof;
|
||||
missing_identity.durable_producer_identity = false;
|
||||
assert_segment_reuse_activation_blocked_by(missing_identity, "missing_producer_identity");
|
||||
|
||||
let mut restart_gap = complete_proof;
|
||||
restart_gap.restart_gap_absent = false;
|
||||
assert_segment_reuse_activation_blocked_by(restart_gap, "restart_gap");
|
||||
|
||||
let mut generation_gap = complete_proof;
|
||||
generation_gap.generation_window_bound = false;
|
||||
assert_segment_reuse_activation_blocked_by(generation_gap, "generation_gap");
|
||||
|
||||
let mut overflow = complete_proof;
|
||||
overflow.overflow_absent = false;
|
||||
assert_segment_reuse_activation_blocked_by(overflow, "overflow");
|
||||
|
||||
let mut missing_cold_oracle = complete_proof;
|
||||
missing_cold_oracle.cold_zero_walk_oracle = false;
|
||||
assert_segment_reuse_activation_blocked_by(missing_cold_oracle, "missing_cold_zero_walk_oracle");
|
||||
|
||||
let mut missing_distributed_invalidation = complete_proof;
|
||||
missing_distributed_invalidation.distributed_peer_invalidation = false;
|
||||
assert_segment_reuse_activation_blocked_by(missing_distributed_invalidation, "distributed_without_peer_invalidation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_segment_reuse_activation_preflight_for_cycle_reports_cycle_inputs_without_activation() {
|
||||
let dirty_usage_snapshot = DirtyUsageSnapshot {
|
||||
buckets: Arc::new(DirtyUsageBuckets::from([("photos".to_string(), 7)])),
|
||||
scopes: Arc::new(DirtyUsageBucketScopes::default()),
|
||||
generation: 7,
|
||||
covers_all_pending: true,
|
||||
};
|
||||
let distributed_evidence = DistributedSegmentInvalidationEvidence {
|
||||
invalidation_domain: crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc,
|
||||
distributed_ec_invalidation: true,
|
||||
peer_count: 2,
|
||||
dirty_peer_count: 1,
|
||||
same_window_remote_proof: true,
|
||||
all_peers_bound_to_generation_window: true,
|
||||
};
|
||||
|
||||
let preflight =
|
||||
scanner_segment_reuse_activation_preflight_for_cycle(&dirty_usage_snapshot, true, Some(distributed_evidence), true);
|
||||
|
||||
assert!(!preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(
|
||||
preflight.fail_closed_blockers().collect::<Vec<_>>(),
|
||||
vec!["missing_producer_identity", "restart_gap"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_segment_reuse_activation_preflight_for_cycle_blocks_unbounded_inputs() {
|
||||
let dirty_usage_snapshot = DirtyUsageSnapshot {
|
||||
buckets: Arc::new(DirtyUsageBuckets::default()),
|
||||
scopes: Arc::new(DirtyUsageBucketScopes::default()),
|
||||
generation: u64::MAX,
|
||||
covers_all_pending: false,
|
||||
};
|
||||
|
||||
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(&dirty_usage_snapshot, true, None, false);
|
||||
|
||||
assert!(!preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(
|
||||
preflight.fail_closed_blockers().collect::<Vec<_>>(),
|
||||
SCANNER_SEGMENT_ACTIVATION_FAIL_CLOSED_CHECKS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_segment_reuse_activation_preflight_for_cycle_skips_distributed_blocker_for_local_scan() {
|
||||
let dirty_usage_snapshot = DirtyUsageSnapshot {
|
||||
buckets: Arc::new(DirtyUsageBuckets::from([("photos".to_string(), 7)])),
|
||||
scopes: Arc::new(DirtyUsageBucketScopes::default()),
|
||||
generation: 7,
|
||||
covers_all_pending: true,
|
||||
};
|
||||
|
||||
let preflight = scanner_segment_reuse_activation_preflight_for_cycle(&dirty_usage_snapshot, false, None, true);
|
||||
|
||||
assert!(!preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(
|
||||
preflight.fail_closed_blockers().collect::<Vec<_>>(),
|
||||
vec!["missing_producer_identity", "restart_gap"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_cycle_result_returns_segment_reuse_activation_preflight() {
|
||||
let proof = ScannerSegmentReuseActivationProof {
|
||||
production_activation: true,
|
||||
durable_producer_identity: true,
|
||||
restart_gap_absent: true,
|
||||
generation_window_bound: true,
|
||||
overflow_absent: true,
|
||||
cold_zero_walk_oracle: true,
|
||||
distributed_peer_invalidation: true,
|
||||
};
|
||||
let preflight = scanner_segment_reuse_activation_preflight_from_proof(proof);
|
||||
|
||||
let result = ScannerCycleResult::new(ScannerCycleStatus::Complete, None).with_segment_reuse_activation_preflight(preflight);
|
||||
|
||||
assert_eq!(result.segment_reuse_activation_preflight, preflight);
|
||||
}
|
||||
|
||||
fn assert_segment_reuse_activation_blocked_by(proof: ScannerSegmentReuseActivationProof, blocker: &'static str) {
|
||||
let preflight = scanner_segment_reuse_activation_preflight_from_proof(proof);
|
||||
|
||||
assert!(preflight.production_activation);
|
||||
assert!(!preflight.scanner_segment_reuse_activated);
|
||||
assert_eq!(preflight.fail_closed_blockers().collect::<Vec<_>>(), vec![blocker]);
|
||||
}
|
||||
|
||||
async fn setup_two_pool_scanner_store() -> (tempfile::TempDir, Arc<ECStore>) {
|
||||
init_ecstore_config_for_scanner_tests();
|
||||
let temp_dir = tempfile::tempdir().expect("multi-pool scanner test directory should be created");
|
||||
@@ -1365,6 +1521,7 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
|
||||
bucket_failures: ScannerBucketFailureState::default(),
|
||||
pending_maintenance_work: Arc::new(AtomicBool::new(false)),
|
||||
cache_cycle_floor: Arc::new(AtomicU64::new(8)),
|
||||
cold_zero_walk_reuse_observed: Arc::new(AtomicBool::new(false)),
|
||||
},
|
||||
tx,
|
||||
8,
|
||||
@@ -1781,6 +1938,18 @@ fn remote_dirty_usage_invalidates_local_prefix_hints_until_distributed_proof_exi
|
||||
"peer dirty state is not a distributed segment invalidation proof"
|
||||
);
|
||||
assert_eq!(distributed.remote_dirty_usage_acknowledgements.len(), 1);
|
||||
let evidence = distributed
|
||||
.distributed_segment_invalidation_evidence
|
||||
.expect("same-window peer snapshot and scoped ACK capability form distributed evidence");
|
||||
assert_eq!(evidence.peer_count, 1);
|
||||
assert_eq!(evidence.dirty_peer_count, 1);
|
||||
assert_eq!(
|
||||
evidence.invalidation_domain,
|
||||
crate::segment_invalidation::SegmentInvalidationDomain::DistributedEc
|
||||
);
|
||||
assert!(evidence.distributed_ec_invalidation);
|
||||
assert!(evidence.same_window_remote_proof);
|
||||
assert!(evidence.all_peers_bound_to_generation_window);
|
||||
}
|
||||
|
||||
fn peer_dirty_usage_snapshot(
|
||||
@@ -1827,7 +1996,7 @@ fn verified_remote_dirty_usage_buckets_merges_only_complete_current_snapshots()
|
||||
ScannerPeerDirtyUsageExpectation {
|
||||
instance_id: "instance-b".to_string(),
|
||||
generation: 3,
|
||||
pending: false,
|
||||
pending: true,
|
||||
},
|
||||
),
|
||||
]);
|
||||
@@ -1874,10 +2043,36 @@ fn verified_remote_dirty_usage_buckets_merges_only_complete_current_snapshots()
|
||||
},
|
||||
},
|
||||
],
|
||||
peer_count: 2,
|
||||
dirty_peer_count: 2,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_remote_dirty_usage_rejects_peer_snapshot_that_contradicts_activity_pending_state() {
|
||||
let expected_peers = HashMap::from([(
|
||||
"node-a:9000".to_string(),
|
||||
ScannerPeerDirtyUsageExpectation {
|
||||
instance_id: "instance-a".to_string(),
|
||||
generation: 7,
|
||||
pending: false,
|
||||
},
|
||||
)]);
|
||||
|
||||
assert!(
|
||||
verified_remote_dirty_usage(
|
||||
&expected_peers,
|
||||
vec![(
|
||||
"node-a:9000".to_string(),
|
||||
peer_dirty_usage_snapshot("instance-a", 7, true, &[("photos", 7)]),
|
||||
)],
|
||||
)
|
||||
.is_none(),
|
||||
"a clean activity window cannot authorize a dirty peer snapshot or scoped ACK"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_scoped_dirty_usage_ack_cost_threshold_is_single_protocol_batch() {
|
||||
let acknowledgement = |entry_count: usize| crate::scanner::ScannerDirtyUsageAcknowledgement {
|
||||
@@ -1951,6 +2146,7 @@ fn remote_dirty_usage_scope_resolution_falls_back_when_ack_batch_exceeds_thresho
|
||||
result.remote_dirty_usage_acknowledgements.is_empty(),
|
||||
"full-scan fallback must not send a scoped ACK that peers would reject or split"
|
||||
);
|
||||
assert!(result.distributed_segment_invalidation_evidence.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2092,6 +2288,11 @@ async fn distributed_scoped_scan_falls_back_when_remote_scoped_ack_capability_is
|
||||
|
||||
assert_eq!(result.scope.selected_buckets.as_deref(), expected_buckets.as_ref());
|
||||
assert_eq!(result.remote_dirty_usage_acknowledgements.len(), expected_ack_count);
|
||||
assert_eq!(
|
||||
result.distributed_segment_invalidation_evidence.is_some(),
|
||||
capability,
|
||||
"distributed evidence requires an authenticated scoped ACK capability probe"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, exp
|
||||
.expect("entry cycle should finish within the fixture deadline")
|
||||
.expect("entry cycle should succeed");
|
||||
assert_eq!(result.status, ScannerCycleStatus::Complete);
|
||||
let activation_preflight = result.segment_reuse_activation_preflight;
|
||||
let scope = observed.await.expect("production resolver should report its decision");
|
||||
assert_eq!(
|
||||
scope.selected_buckets.as_deref(),
|
||||
@@ -174,6 +175,20 @@ async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, exp
|
||||
actual, expected_walks,
|
||||
"each listed source/bucket must have exactly the expected real walks"
|
||||
);
|
||||
assert!(!activation_preflight.production_activation);
|
||||
assert!(!activation_preflight.scanner_segment_reuse_activated);
|
||||
let activation_blockers = activation_preflight.fail_closed_blockers().collect::<Vec<_>>();
|
||||
if selected.is_some() && expect_walks {
|
||||
assert!(
|
||||
!activation_blockers.contains(&"missing_cold_zero_walk_oracle"),
|
||||
"a complete scoped reuse cycle must carry the cold zero-walk oracle: cycle={cycle} selected={selected:?} blockers={activation_blockers:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
activation_blockers.contains(&"missing_cold_zero_walk_oracle"),
|
||||
"unscoped or same-cycle cache reuse must not claim the cold zero-walk oracle: cycle={cycle} selected={selected:?} expect_walks={expect_walks} blockers={activation_blockers:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
read_config_with_revision(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
|
||||
.await
|
||||
|
||||
@@ -57,6 +57,9 @@ pub enum SegmentInvalidationProducerIdentity {
|
||||
DeleteObject,
|
||||
DeleteMarker,
|
||||
CompleteMultipartUpload,
|
||||
AbortMultipartUpload,
|
||||
ObjectMetadata,
|
||||
BucketMetadata,
|
||||
Replication,
|
||||
TierTransition,
|
||||
TierExpiration,
|
||||
@@ -66,11 +69,14 @@ pub enum SegmentInvalidationProducerIdentity {
|
||||
}
|
||||
|
||||
impl SegmentInvalidationProducerIdentity {
|
||||
pub const REQUIRED_PRODUCTION: [Self; 8] = [
|
||||
pub const REQUIRED_PRODUCTION: [Self; 11] = [
|
||||
Self::PutObject,
|
||||
Self::DeleteObject,
|
||||
Self::DeleteMarker,
|
||||
Self::CompleteMultipartUpload,
|
||||
Self::AbortMultipartUpload,
|
||||
Self::ObjectMetadata,
|
||||
Self::BucketMetadata,
|
||||
Self::Replication,
|
||||
Self::TierTransition,
|
||||
Self::TierExpiration,
|
||||
@@ -82,7 +88,9 @@ impl SegmentInvalidationProducerIdentity {
|
||||
Self::PutObject => Some(SegmentInvalidationProducer::Put),
|
||||
Self::DeleteObject => Some(SegmentInvalidationProducer::Delete),
|
||||
Self::DeleteMarker => Some(SegmentInvalidationProducer::DeleteMarker),
|
||||
Self::CompleteMultipartUpload => Some(SegmentInvalidationProducer::Multipart),
|
||||
Self::CompleteMultipartUpload | Self::AbortMultipartUpload => Some(SegmentInvalidationProducer::Multipart),
|
||||
Self::ObjectMetadata => Some(SegmentInvalidationProducer::Put),
|
||||
Self::BucketMetadata => Some(SegmentInvalidationProducer::DirectoryObject),
|
||||
Self::Replication => Some(SegmentInvalidationProducer::Replication),
|
||||
Self::TierTransition | Self::TierExpiration => Some(SegmentInvalidationProducer::Tier),
|
||||
Self::DirectoryObject => Some(SegmentInvalidationProducer::DirectoryObject),
|
||||
@@ -402,8 +410,12 @@ mod tests {
|
||||
SegmentInvalidationProducerIdentity::DeleteObject,
|
||||
SegmentInvalidationProducerIdentity::DeleteMarker,
|
||||
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
|
||||
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
|
||||
SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
SegmentInvalidationProducerIdentity::Replication,
|
||||
SegmentInvalidationProducerIdentity::TierTransition,
|
||||
SegmentInvalidationProducerIdentity::TierExpiration,
|
||||
SegmentInvalidationProducerIdentity::DirectoryObject,
|
||||
SegmentInvalidationProducerIdentity::Unknown,
|
||||
]),
|
||||
@@ -415,8 +427,12 @@ mod tests {
|
||||
SegmentInvalidationProducerIdentity::DeleteObject,
|
||||
SegmentInvalidationProducerIdentity::DeleteMarker,
|
||||
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
|
||||
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
|
||||
SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
SegmentInvalidationProducerIdentity::Replication,
|
||||
SegmentInvalidationProducerIdentity::TierTransition,
|
||||
SegmentInvalidationProducerIdentity::TierExpiration,
|
||||
SegmentInvalidationProducerIdentity::DirectoryObject,
|
||||
SegmentInvalidationProducerIdentity::TestFixture,
|
||||
]),
|
||||
@@ -427,6 +443,7 @@ mod tests {
|
||||
SegmentInvalidationProducerIdentity::PutObject,
|
||||
SegmentInvalidationProducerIdentity::DeleteObject,
|
||||
SegmentInvalidationProducerIdentity::DeleteMarker,
|
||||
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
|
||||
SegmentInvalidationProducerIdentity::Replication,
|
||||
SegmentInvalidationProducerIdentity::TierTransition,
|
||||
SegmentInvalidationProducerIdentity::DirectoryObject,
|
||||
@@ -439,6 +456,9 @@ mod tests {
|
||||
SegmentInvalidationProducerIdentity::DeleteObject,
|
||||
SegmentInvalidationProducerIdentity::DeleteMarker,
|
||||
SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
|
||||
SegmentInvalidationProducerIdentity::AbortMultipartUpload,
|
||||
SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
SegmentInvalidationProducerIdentity::Replication,
|
||||
SegmentInvalidationProducerIdentity::TierTransition,
|
||||
SegmentInvalidationProducerIdentity::DirectoryObject,
|
||||
|
||||
@@ -919,6 +919,22 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
// The filename's item count is untrusted. Reject a payload that contains
|
||||
// more items than advertised instead of returning success and allowing the
|
||||
// caller to delete the entry with trailing events still in the file.
|
||||
match deserializer.next() {
|
||||
None => {}
|
||||
Some(Ok(_)) => {
|
||||
return Err(StoreError::Deserialization(format!(
|
||||
"Batch for key {key} contains more than {} items",
|
||||
key.item_count
|
||||
)));
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
return Err(StoreError::Deserialization(format!("Failed to deserialize trailing batch item: {e}")));
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() && key.item_count > 0 {
|
||||
return Err(StoreError::Deserialization("No items found".to_string()));
|
||||
}
|
||||
@@ -1381,6 +1397,39 @@ mod tests {
|
||||
let _ = store.delete();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_multiple_errors_on_batch_with_trailing_items_instead_of_partial_success() {
|
||||
let dir = temp_store_dir("trailing-batch-items");
|
||||
let store = QueueStore::<String>::new_with_compression(&dir, 8, ".test", false);
|
||||
store.open().unwrap();
|
||||
|
||||
let items = vec!["aa".to_string(), "bb".to_string(), "cc".to_string()];
|
||||
let original_key = store.put_multiple(items).unwrap();
|
||||
assert_eq!(original_key.item_count, 3);
|
||||
|
||||
// Keep the three-item payload but make its filename claim that it contains
|
||||
// only two items, simulating a corrupt or otherwise untrusted queue key.
|
||||
let original_path = store.file_path(&original_key);
|
||||
let advertised_key = Key {
|
||||
item_count: 2,
|
||||
..original_key
|
||||
};
|
||||
let advertised_path = store.file_path(&advertised_key);
|
||||
std::fs::rename(&original_path, &advertised_path).unwrap();
|
||||
|
||||
let err = store.get_multiple(&advertised_key).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, StoreError::Deserialization(_)),
|
||||
"expected Deserialization error, got {err:?}"
|
||||
);
|
||||
|
||||
// Because get_multiple failed, the batch entry remains available for
|
||||
// inspection or recovery instead of being silently discarded.
|
||||
assert!(advertised_path.exists());
|
||||
|
||||
let _ = store.delete();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_put_raw_respects_entry_limit() {
|
||||
let dir = temp_store_dir("concurrent-limit");
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Documentation
|
||||
|
||||
Use the focused indexes rather than treating this directory as an unordered
|
||||
collection:
|
||||
|
||||
- [Architecture knowledge base](architecture/README.md)
|
||||
- [Testing references](testing/README.md)
|
||||
|
||||
## Operations
|
||||
|
||||
Operational runbooks live under [`operations/`](operations/). Replication
|
||||
operators should start with:
|
||||
|
||||
| Runbook | Use it for |
|
||||
|---|---|
|
||||
| [Site replication operations](operations/site-replication-operations.md) | Health fields, pending operations, outage recovery, re-pair admission, IAM/SSE boundaries, and upgrades. |
|
||||
| [Replication target check](operations/replication-check.md) | Validating an S3 destination and version fidelity before enabling replication. |
|
||||
| [Replication object size limits](operations/replication-object-size-limits.md) | Multipart routing, large-object limits, and retry characteristics. |
|
||||
| [Replication outbound transport](operations/replication-outbound-transport.md) | Integrity headers, generic target behavior, and transport knobs. |
|
||||
|
||||
Other runbooks remain grouped by filename in [`operations/`](operations/);
|
||||
architecture pages link to the relevant runbook where a cross-boundary
|
||||
procedure is required.
|
||||
@@ -60,6 +60,8 @@ Required headings and strings in these files are asserted by `scripts/check_arch
|
||||
| [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md) | a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different |
|
||||
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
|
||||
|
||||
Operations runbooks live in [../operations/](../operations/) and testing references in [../testing/README.md](../testing/README.md).
|
||||
Operations runbooks are registered in the [documentation operations index](../README.md#operations), and testing references live in [../testing/README.md](../testing/README.md).
|
||||
|
||||
For replication operations, start with [site replication operations](../operations/site-replication-operations.md), [replication target check](../operations/replication-check.md), [replication object size limits](../operations/replication-object-size-limits.md), and [replication outbound transport](../operations/replication-outbound-transport.md).
|
||||
|
||||
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).
|
||||
|
||||
@@ -38,9 +38,10 @@
|
||||
- `put-file-auth-epoch-strict` internode put_file epoch compatibility: rc.2 peers can cache a remote put_file capability before that remote node restarts, then continue sending v1 authenticated uploads with the old server epoch; those peers cannot recover from the 409 conflict used by newer clients to trigger a re-probe. Servers temporarily accept signed, non-nil stale put_file epochs while legacy put_file auth remains non-strict so mixed-version rolling upgrades can finish multipart/object writes. Remove the stale-epoch fallback after the minimum supported RustFS peer version re-probes put_file capability after server-epoch conflicts and legacy put_file auth is no longer accepted.
|
||||
- `disk-mutation-body-digest` internode mutating disk RPCs: servers temporarily accept mutating disk RPCs (RenameData, DeleteVersion, DeleteVersions, WriteMetadata, UpdateMetadata, WriteAll, Delete, DeletePaths, RenameFile, RenamePart, DeleteVolume, MakeVolume, MakeVolumes) that carry no signature-bound canonical body digest, so peers from releases that predate body-digest signing remain available during rolling upgrades. Accepted digestless mutations increment the internode body-digest fallback counter; that counter must read zero fleet-wide across a release window before RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT is enabled. Because body-bound requests now consume replay-cache nonces on the receiver, deploy the raised RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY default fleet-wide before enabling strict mode, and watch the internode replay-cache overflow counter for undersized capacity during the rollout. Remove the digestless fallback after the minimum supported RustFS peer version body-binds every mutating disk RPC.
|
||||
- `heal-status-rpc-v1` node heal status capability: new peers treat an unimplemented BackgroundHealStatus RPC as an explicitly incomplete rolling-upgrade response. Remove the fallback after the minimum supported RustFS peer version implements BackgroundHealStatus.
|
||||
- `backlog-1316` legacy encrypted multipart range seek: the feature remains opt-in until every server that can initiate, write, or complete multipart uploads supports the candidate-to-final marker protocol and uploadId commit lock, and pre-upgrade multipart uploads have drained. Remove the RUSTFS_ENCRYPTED_RANGE_SEEK switch after the minimum supported release does so; keep the quorum marker and malformed-layout full-read guards permanently.
|
||||
- `backlog-1316` legacy encrypted multipart range seek: the feature is on by default (RUSTFS_ENCRYPTED_RANGE_SEEK, default true) and the switch remains only as a kill switch until every server that can initiate, write, or complete multipart uploads supports the candidate-to-final marker protocol and uploadId commit lock, and pre-upgrade multipart uploads have drained. Remove the RUSTFS_ENCRYPTED_RANGE_SEEK switch after the minimum supported release does so; keep the quorum marker and malformed-layout full-read guards permanently.
|
||||
- `tonic-013-status-render` peer RPC failure classification: internode failures that reach a node only as text (a peer's error_info payload, a status flattened through format!) are classified by matching the rendering of an Unavailable gRPC status. Releases up to 1.0.0-alpha.38 shipped tonic 0.13, which rendered that status as "status: Unavailable, message: ..."; tonic 0.14 renders it as "code: 'The service is currently unavailable', message: ...". Both forms are matched so an older peer's relayed text still marks an unreachable peer offline. Remove the tonic 0.13 form after the minimum supported RustFS peer version ships tonic 0.14 or later.
|
||||
- `rustfs-5063` pre-beta.9 Local KMS recovery: persisted Local KMS configs from beta.8 and earlier predate the explicit insecure-development flag, and encrypted key files use the legacy SHA-256 KDF. Remove the config fallback after supported upgrades have rewritten or explicitly resaved all pre-beta.9 configs with the development-default field, and remove the legacy KDF after supported upgrades have rewritten all pre-beta.9 Local KMS key files with explicit at-rest protection.
|
||||
- `backlog-2369-legacy-nonce-fallback` pre-alpha.91 v1 segment nonce layout: releases before `1.0.0-alpha.91` reused a segment's part nonce for every block inside it, so the decrypt reader keeps that layout as its third and last v1 fallback. Because block zero's derived nonce equals that base nonce, the layout also lets a frame encrypted at index zero authenticate anywhere in its segment. Reads now lock a segment to whichever layout decoded its first non-zero block, which rejects a replay as soon as a later frame disagrees; a stream that is nothing but repeats of frame zero has no such later frame, so a deployment holding no pre-alpha.91 objects should set RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK=false to drop the layout outright. Remove the fallback (and the switch) after the minimum supported direct-upgrade release, and after migration tooling has rewritten every pre-alpha.91 encrypted object.
|
||||
- `sse-local-dek-json-v1` legacy local SSE DEK decoding: releases before the JSON envelope wrote wrapped DEKs as `base64(nonce):base64(ciphertext)`, so readers retain that decoder while all new writes use the versioned JSON envelope. Remove the colon decoder after the minimum supported direct-upgrade release writes JSON envelopes and migration tooling has rewritten every retained legacy object.
|
||||
- `not-initialized-error-code-v1` typed control-plane not-initialized wire code: control-plane RPC responses historically signaled an uninitialized peer only through the literal error_info string "errServerNotInitialized" (one drift site says "storage layer not initialized"). Responses now dual-carry a typed ControlPlaneErrorCode beside the legacy string, and clients prefer the code; the string stays populated and the client substring fallback (is_err_not_initialized, control_plane_failure) stays in place so mixed-version clusters keep classifying older peers' responses. Remove the substring fallback (and stop populating error_info for this case) after the minimum supported RustFS peer version always sends error_code.
|
||||
- `backlog-2097-tier-mutation-v4-error-text` tier-mutation Prepare rejection classification: a v3 server rejects a v4 request before store/runtime dispatch with the FailedPrecondition status and an authenticated, byte-exact unsupported-version message. A v4 coordinator recognizes only that exact code/message/requested-version tuple as definitely not persisted and fails the mutation without sending that peer an incompatible Abort; Unimplemented, near-text, missing/unknown failure classes, timeouts, and every other transport outcome remain ambiguous and stay in identity-bound Abort fanout. There is no automatic v3 retry. New servers retain v3 request/proof decoding for older coordinators, while operators must pause tier edit/remove/clear during a mixed v3/v4 rollout. Remove the text classifier after the minimum supported RustFS peer version returns the signed v4 PreDispatchRejected failure class.
|
||||
|
||||
@@ -38,6 +38,42 @@ Counts ignore blank lines and comments; compute them from the files. The lifecyc
|
||||
|
||||
"Supported" for the SSE row means RustFS encrypts and decrypts its own objects. MinIO SSE objects (SSE-S3, SSE-KMS, SSE-C) are not readable in default builds; see [minio-file-format-compat.md Part C](minio-file-format-compat.md#part-c--server-side-encryption-sse) for the `rio-v2` migration build.
|
||||
|
||||
## Replication Support Boundary
|
||||
|
||||
Site replication and bucket replication are not the same compatibility claim.
|
||||
Site replication requires RustFS-compatible peer admin APIs and coordinates
|
||||
IAM, topology, buckets, and metadata. A generic S3-compatible service can only
|
||||
be a bucket-replication data target.
|
||||
|
||||
For a generic S3 target, RustFS supports object PUT/HEAD/DELETE, multipart
|
||||
uploads, tags, version deletes, and Object Lock mutations when the target
|
||||
implements the corresponding S3 APIs and has versioning enabled. Targets that
|
||||
mint their own version IDs are supported through a per-target version ledger;
|
||||
pre-ledger replicas are adopted only when exact key and ETag identify one
|
||||
unambiguous target version. `NoSuchVersion` for an already absent addressed
|
||||
replica is treated as converged.
|
||||
|
||||
The following are capability boundaries, not universal S3 claims:
|
||||
|
||||
- `GET /BUCKET?replication-check` must pass the phases required by the intended
|
||||
workload. `VersionFidelity` may report a minting target as mismatched even
|
||||
though ledger-addressed delete and Object Lock phases succeed.
|
||||
- A target that rejects standard multipart constraints, required Object Lock
|
||||
integrity headers, or the configured checksum framing is unsupported until
|
||||
its transport settings are made compatible.
|
||||
- SSE-S3 and SSE-KMS are decrypted at the source and re-encrypted by the
|
||||
destination's KMS. SSE-C uses ciphertext passthrough and requires target
|
||||
evidence. Unsupported or ambiguous encryption metadata fails closed.
|
||||
- ACL authorization is intentionally unsupported, and generic targets never
|
||||
receive RustFS IAM/site-control-plane state.
|
||||
- RustFS does not guess between multiple target versions with the same key and
|
||||
ETag. The mutation remains failed and retryable until repair establishes an
|
||||
unambiguous mapping.
|
||||
|
||||
See [site replication operations](../operations/site-replication-operations.md)
|
||||
for health, recovery, and upgrade rules and [replication outbound transport](../operations/replication-outbound-transport.md)
|
||||
for the tested target classes and knobs.
|
||||
|
||||
## Not Yet Passing
|
||||
|
||||
Standard S3 areas that must not be described as complete:
|
||||
|
||||
@@ -15,6 +15,16 @@ RustFS ships several KMS backends. They differ not only in deployment effort but
|
||||
| Vault Transit | `VaultTransit` | Key-encryption keys never leave Vault; only Transit ciphertext is visible outside | Vault Transit engine (cryptographic isolation) | Delegated to Vault storage | Via Vault Transit key versioning | Deployments that need key material to be unreadable through storage APIs |
|
||||
| AWS KMS | `AWS` (alias `AwsKms`) | Key material never leaves AWS KMS; RustFS mirrors no key state | AWS KMS (cryptographic isolation) + IAM | Delegated to AWS | On-demand `RotateKeyOnDemand`; prior backing keys stay usable for decryption | Deployments rooted in AWS IAM — read [AWS KMS: deviations from the shared backend contract](#aws-kms-deviations-from-the-shared-backend-contract) first |
|
||||
|
||||
## No KMS configured: the SSE-S3 local master key
|
||||
|
||||
A deployment that never configures a KMS can still serve **SSE-S3** by setting `RUSTFS_SSE_S3_MASTER_KEY` to a base64-encoded 32-byte key. Data keys are then wrapped with that key using AES-256-GCM, on the node that serves the write. Understand three consequences before relying on it:
|
||||
|
||||
- **The key is the whole confidentiality boundary.** It lives in the process environment of every node, with no ACL, no audit trail and no policy engine in front of it.
|
||||
- **Objects written this way can never be rotated.** There is no key record to rotate and no rewrap path; changing the value makes every object written under the old one unreadable. Migrating to a KMS later means rewriting those objects (for example with CopyObject), not reconfiguring.
|
||||
- **It does not serve SSE-KMS.** A request for `x-amz-server-side-encryption: aws:kms` on a node with no running KMS is refused — `400 InvalidRequest` when KMS was never configured, `503` when a configured service is not running. Earlier releases silently wrapped the data key with the local master key while still stamping `aws:kms` and the requested key id into the object metadata; that metadata claimed a KMS protection the object never had. If a deployment depended on that, either configure a KMS or ask for `AES256`.
|
||||
|
||||
The value is unset by default, and a deployment that neither configures a KMS nor sets it simply cannot serve SSE-S3 (the write is refused, never silently downgraded to plaintext).
|
||||
|
||||
## Migrating from MinIO: encrypted objects do not carry over
|
||||
|
||||
> **Warning: default RustFS builds fail closed on objects that MinIO encrypted.** This applies to SSE-S3, SSE-KMS, and SSE-C, whichever KMS backend you configure; configuring `Static` with MinIO's key material does not make them readable. Such objects list and HEAD normally (their `xl.meta` parses), and only the payload read fails — with S3 `InvalidObjectState`, never plaintext. Read a sample of encrypted objects, not just their listings, before decommissioning the MinIO deployment.
|
||||
@@ -117,6 +127,37 @@ Decryption loads exactly the version recorded in the envelope and fails closed w
|
||||
|
||||
Do not rotate any key until **every** RustFS node runs a build that understands the `master_key_version` envelope field. Older binaries ignore the field and always decrypt with the current material: harmless while nothing has been rotated, but after a rotation they fail to decrypt every object wrapped by an earlier key version. Complete the rolling upgrade of the entire cluster first, then rotate. The rest of this constraint class is collected in [Mixed-version clusters during a rolling upgrade](#mixed-version-clusters-during-a-rolling-upgrade).
|
||||
|
||||
## SSE-C requires a secure transport
|
||||
|
||||
An SSE-C request carries the customer's AES key in a request header, so AWS S3 and MinIO both refuse one that did not arrive over TLS. A plaintext hop hands that key to anyone on the path, and because the object cannot be read without the same key, the exposure lasts as long as the object does.
|
||||
|
||||
This release reports rather than refuses, because flipping straight to a rejection would break every plaintext staging and test deployment inside a release window:
|
||||
|
||||
- Every SSE-C request on a plaintext transport increments `rustfs_ssec_plaintext_requests_total` and logs one `ssec_request_without_tls` warning per process.
|
||||
- `RUSTFS_SSE_C_REQUIRE_TLS=true` (default `false`) refuses those requests now, with the same `400 InvalidRequest` wording AWS uses. Confirm the counter reads zero before enabling it.
|
||||
- The default is expected to flip in a later release.
|
||||
|
||||
The verdict is per connection: a listener that terminates TLS satisfies it, and so does an `https` protocol forwarded by a proxy the trusted-proxy configuration accepts. A direct plaintext client asserts nothing, and a forwarded protocol from an untrusted peer is not consulted.
|
||||
|
||||
## Object ciphertext format: what the v1 frame layout does and does not authenticate
|
||||
|
||||
Every object RustFS writes today uses the **v1** frame layout (the v2 layout exists and is read automatically, but its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is off by default). Each frame is authenticated with AES-256-GCM under a nonce derived from the object's base nonce and the frame's index. Three properties do **not** follow from that, and an operator's threat model has to account for them:
|
||||
|
||||
- **No frame-index binding.** A frame's index is not part of its associated data. Authentication proves a frame was produced under this object's key; it does not by itself prove the frame belongs at the position it occupies.
|
||||
- **No final-frame authentication.** Nothing in a v1 stream marks the last frame, so a stream that has been cut short is not distinguishable from a shorter object by cryptographic means.
|
||||
- **Truncation is not detected server-side.** A full GET is cut off by the length gate mid-stream and surfaces as `IncompleteBody` — after the response headers have already gone out. A ranged GET that ends early looks like an ordinary EOF and is not reported at all. A client that needs a truncation signal must compare the delivered length against `Content-Length` itself.
|
||||
|
||||
These matter only to an attacker who can already rewrite the underlying shards. Shard integrity uses a keyed-hash-free checksum (HighwayHash), which such an attacker can recompute, so it is not a barrier.
|
||||
|
||||
Two historical shapes additionally reuse a GCM nonce and cannot be repaired by any read-side change:
|
||||
|
||||
| Shape | Written by | Consequence | Migration |
|
||||
| --- | --- | --- | --- |
|
||||
| Multipart objects written before `1.0.0-alpha.91` | The pre-alpha.91 writer reused a segment's part nonce for every block in it | The whole segment shares one nonce; a frame from index zero authenticates anywhere in that segment | Rewrite in place with CopyObject; then set `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK=false` |
|
||||
| SSE-C objects written before `1.0.0-beta.9` that carry no stored IV | The nonce was derived deterministically from bucket and key | Historical versions of the same key share a nonce, which affects confidentiality as well as forgeability | Rewrite in place with CopyObject |
|
||||
|
||||
The decrypt reader locks each segment to whichever nonce layout decoded its first non-zero-index frame, so a replayed frame is rejected as soon as any later frame disagrees. A stream that is nothing but repeats of frame zero has no later frame to disagree, so a deployment that holds no pre-alpha.91 objects should set `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK=false` (default `true`) to drop that layout entirely. Turning it off refuses to decrypt pre-alpha.91 objects, so migrate first.
|
||||
|
||||
## Mixed-version clusters during a rolling upgrade
|
||||
|
||||
During a rolling upgrade KMS state is shared three ways: **Vault** holds key records and Transit metadata, **cluster storage** holds the persisted KMS configuration, and **each node's process memory** holds caches and the live backend instance. Nodes on different builds agree on the first, may disagree on the third, and can disagree on configuration for as long as the operator leaves them running, because the reload broadcast that converges configuration is one of the things an older build rejects. This section is written for the KV2 and Transit backends; the Local backend is unsupported for multi-node deployments regardless of version (see the [deployment support matrix](#deployment-support-matrix)).
|
||||
@@ -237,6 +278,7 @@ The Local backend stores one JSON record per key (`<key_id>.key`) plus an Argon2
|
||||
- `Local` is the default backend (`kms_backend` defaults to `local`) and is a development, testing and demo backend; it is not supported for production. Activating a backend whose capabilities report `production_supported: false` logs a `kms_backend_positioning` warning on every start, restart and reconfigure, and the `kms/status` capability matrix carries the same flag. The positioning is a warning, not a gate.
|
||||
- Configuration validation enforces stricter rules outside explicit development mode: a master key is required and `key_dir` must not live under the process temp directory.
|
||||
- The RustFS Kubernetes operator places the key directory on a PersistentVolumeClaim, so keys survive pod rescheduling.
|
||||
- **A multi-node deployment cannot share it.** Key material lives on each node's own disk and the Argon2id salt is generated per node, so two nodes derive different keys from the same `master_key`. An object encrypted on node A cannot be decrypted on node B; behind a load balancer that appears as intermittent 500s on reads that succeeded a moment earlier. Configuring `Local` while the deployment is distributed logs `kms_node_local_backend_in_distributed_deployment` and appends the same warning to the `kms/configure` response. This stays a warning, not a gate.
|
||||
- Production multi-node deployments should use the Vault Transit backend.
|
||||
|
||||
### Deployment support matrix
|
||||
|
||||
@@ -9,12 +9,14 @@ The drill rehearses the complete loop — back up, lose the persistence layer, p
|
||||
|
||||
The drill covers the **Local** backend, the only backend RustFS produces a full-material bundle for. The responsibility split is described in `crates/kms/src/backup/capability.rs`:
|
||||
|
||||
| Backend | What a RustFS bundle carries | What restores it |
|
||||
| Backend | RustFS bundle export | What restores it |
|
||||
| --- | --- | --- |
|
||||
| Local | Key records, all stored versions, the KDF salt, sanitized configuration | The RustFS restore in this runbook |
|
||||
| Static | Non-sensitive references only | The operator re-supplies the secret out of band |
|
||||
| Vault KV2 + Transit | KV metadata and Transit ciphertext references | Vault's native snapshot restore, then the RustFS orchestration |
|
||||
| Vault Transit | Metadata, configuration references, verification data | Vault's native snapshot restore, then the RustFS orchestration |
|
||||
| Static | Refused with `501`; RustFS holds no material to export | The operator re-supplies the secret out of band |
|
||||
| Vault KV2 | Refused with `501` | Vault's native snapshot restore, then the RustFS orchestration |
|
||||
| Vault Transit | Refused with `501` | Vault's native snapshot restore, then the RustFS orchestration |
|
||||
|
||||
The `501` is not a gap in this runbook: `POST /rustfs/admin/v3/kms/backup` refuses any backend other than `Local` (`rustfs/src/admin/handlers/kms_backup.rs`, `execute_backup`), so no RustFS bundle exists to plan around for the other three. Note that `capability.rs` still *declares* `FullMaterial` responsibility for Vault KV2 in storage-only mode; no export path implements it, so treat the declaration as a reservation, not a capability.
|
||||
|
||||
For the Vault backends there is no RustFS-side export: the cryptographic root is non-exportable and comes back through Vault's own disaster-recovery flow. RustFS owns the refusal to proceed before that has happened and the ordering of everything after it — see the Vault section below.
|
||||
|
||||
|
||||
@@ -225,6 +225,10 @@ KMS configured through the admin API is persisted to cluster storage and restore
|
||||
|
||||
To recover from `load_failed` — or from any state where the server runs but its in-memory KMS lags the persisted configuration — call `POST /rustfs/admin/v3/kms/reload` (`kms:ServiceControl`). It re-reads the persisted configuration from cluster storage and reconfigures the service without resubmitting secrets, then broadcasts the reload to peer nodes. If reload keeps failing, check cluster storage health first (the read needs quorum), then `RUSTFS_KMS_CONFIG_SECRET`: an unseal error means the secret is missing or differs from the one that sealed the persisted copy — it must be identical on every node.
|
||||
|
||||
Reload short-circuits only when this node is **already running** the persisted configuration. A node whose KMS failed to start keeps that configuration and sits in `Error`, so reload reconfigures it — which starts the service — rather than reporting success while the node stays down. The same holds on every peer, which reaches the same path through the reload broadcast.
|
||||
|
||||
**Cluster-wide versus node-local routes.** `configure`, `reconfigure` and `reload` are cluster operations: the node that serves the request broadcasts to its peers. `start` and `stop` are node-local and are **not** broadcast. Calling `POST /rustfs/admin/v3/kms/stop` through a load balancer therefore stops whichever node answered and leaves the cluster in a mixed state; address a specific node directly when you mean node-local semantics, and expect a later cluster-wide `reload` to start a stopped node again.
|
||||
|
||||
A separate event, `kms_config_load_skipped` with `reason="storage_uninitialized"`, comes from the ambient loader used by the peer-reload RPC path; seeing it outside a peer reload indicates a request arrived before storage initialization finished.
|
||||
|
||||
## Threshold calibration
|
||||
|
||||
@@ -39,8 +39,8 @@ The `scanner` and `heal` subsystems are served by `GetConfigKVHandler` (`rustfs/
|
||||
The `--abba` mode runs five independent scenario cells: `cold-hot`, `fresh-hot`,
|
||||
`multi-hot-new`, `running-heal`, and `mrf-replay`. Each scenario runs at least
|
||||
three A1/B1/B2/A2 groups for both baseline/candidate with background work on,
|
||||
and candidate-only background off/on. A measured leg lasts at least 900
|
||||
seconds; the minimum matrix contains 120 legs (30 hours before setup/oracles).
|
||||
and candidate-only background off/on. A measured release leg lasts at least 7200
|
||||
seconds; the minimum matrix contains 120 legs (240 hours before setup/oracles).
|
||||
The existing `performance-ab.yml` supplies the pattern for immutable build
|
||||
provenance and failure propagation, but its short Warp workload is not this
|
||||
scanner gate. No scheduled workflow starts this matrix automatically.
|
||||
@@ -63,7 +63,7 @@ The manifest has the following JSON contract (all fields are required):
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `schema`, `evidence` | `1`, and `measured` or `synthetic`. |
|
||||
| `rounds`, `duration_seconds`, `min_free_bytes` | 3..10 groups, 900..86400 seconds for measured runs, and the independently estimated free-space reservation in bytes. Synthetic runs may use 1 second. |
|
||||
| `rounds`, `duration_seconds`, `min_free_bytes` | 3..10 groups, 7200..86400 seconds for measured release runs, and the independently estimated free-space reservation in bytes. Synthetic runs may use 1 second. |
|
||||
| `baseline`, `candidate` | Each contains executable `binary`, full 40-character `revision`, and verified `sha256`. The runner rehashes binaries before every leg. |
|
||||
| `fixed` | `config_sha256`, `dataset_sha256`, `release_flags`, `durability`, `disk_type`, `cache_state`, `load_command`, `resource_isolation`, `topology` (`EC8+4`), and positive `offered_load_ops`. Hashes use 64 lowercase hexadecimal characters. |
|
||||
| `release_evidence` | Required for `measured` runs. It binds the 3x4 EC8+4 topology, multi-pool/multi-set coverage, per-node metrics endpoints, same-window distributed sampling, process restart and crash-restart fault modes, mixed-version reader/writer/rollback participation, and allocation/flamegraph/RSS/save-frequency profile artifact requirements. Synthetic runs do not need this field and still cannot approve release evidence. |
|
||||
@@ -159,6 +159,14 @@ delay counts are both retained so an operator can reject unrelated or
|
||||
process-lifetime counter contamination. Correct repair oracles and the existing
|
||||
regression limits still apply in every case.
|
||||
|
||||
The `running-heal` build comparison also records a `w11` section for the bounded
|
||||
retry-window evidence. `status=observed` requires same-window healthy-page
|
||||
latency improvement, reduced heal lock-wait p99, bounded candidate RSS growth,
|
||||
and a candidate attempt-cost value. `rss_regression` means latency and lock-wait
|
||||
improved but RSS exceeded the allowed growth limit; `no_measured_benefit` means
|
||||
attempt-cost evidence exists without the full W11 benefit; `pending` means the
|
||||
attempt-cost evidence needed for the comparison is missing.
|
||||
|
||||
For P2, `measure.convergence` contains booleans `writes_stopped`,
|
||||
`last_mutation_observed`, `first_complete_publication`; numeric
|
||||
`last_mutation_time`, `last_mutation_observed_time`, `writes_stopped_time`, `window_start`, `window_end`,
|
||||
@@ -220,7 +228,10 @@ non-passing report.
|
||||
Measured passing reports must also retain the W10/W11 foreground-pressure,
|
||||
heal-lock-wait, and heal-attempt-cost fields emitted by the ABBA evaluator. If
|
||||
those fields are removed, empty, malformed, or length-mismatched, the quiet
|
||||
summary fails closed instead of treating the report as performance evidence.
|
||||
summary fails closed instead of treating the report as performance evidence. For
|
||||
`running-heal` build comparisons, the summary additionally requires the emitted
|
||||
W11 section to be `observed` and to retain the RSS-growth, lock-wait,
|
||||
healthy-page-latency, and candidate attempt-cost values.
|
||||
|
||||
They cover the complete 120-cell schedule, data isolation, missing builds and
|
||||
oracles, zero samples/requests, swallowed request errors, offered-load drift,
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Site Replication Operations
|
||||
|
||||
**Use this when:** operating a site-replication deployment, diagnosing a peer
|
||||
outage or incomplete topology change, pairing sites that already contain data,
|
||||
or planning an upgrade.
|
||||
|
||||
**Source of truth:** `rustfs/src/admin/handlers/site_replication.rs`,
|
||||
`rustfs/src/site_replication/`, and the bucket-replication worker under
|
||||
`crates/ecstore/src/bucket/replication/`.
|
||||
|
||||
Site replication combines two different convergence paths:
|
||||
|
||||
- the control plane replicates buckets, bucket metadata, IAM, and topology;
|
||||
- ordinary bucket replication moves object versions and delete operations.
|
||||
|
||||
An `enabled: true` response only says that a site has more than one configured
|
||||
peer. It does not prove that every peer is reachable or caught up. Always read
|
||||
`pendingOperation`, `retryStats`, `PeerErrors`, and `Metrics` as well.
|
||||
|
||||
## Routine checks
|
||||
|
||||
Run these commands from an admin workstation with one alias per site:
|
||||
|
||||
```console
|
||||
mc admin replicate info site-a
|
||||
mc admin replicate status site-a
|
||||
```
|
||||
|
||||
Check more than one site. A partition can leave each side with a different but
|
||||
locally valid view.
|
||||
|
||||
`replicate info` is the compact control-plane view:
|
||||
|
||||
| Field | Interpretation |
|
||||
|---|---|
|
||||
| `enabled` | More than one site is configured; this is not a health verdict. |
|
||||
| `sites` | The locally persisted topology. Compare deployment IDs and endpoints on every site. |
|
||||
| `retryStats.pending` | Collapsed peer deliveries waiting to be retried. |
|
||||
| `retryStats.failed` | Deliveries that crossed the escalation threshold and require attention. |
|
||||
| `retryStats.lastError` | A redacted summary of the most recent delivery failure. |
|
||||
| `pendingOperation` | A durable multi-step topology operation described below. Absence is the healthy steady state. |
|
||||
|
||||
`replicate status` adds detailed convergence state:
|
||||
|
||||
| Field | Interpretation |
|
||||
|---|---|
|
||||
| `Sites` / `PeerStates` | Configured peers and derived reachability/configuration state. |
|
||||
| `PeerErrors` | A peer could not be queried. Its detailed counters may be absent; do not read zeros as success. |
|
||||
| `BucketStats` | Per-bucket presence and versioning, replication, lifecycle, Object Lock, and metadata mismatches. |
|
||||
| `PolicyStats`, `UserStats`, `GroupStats` | IAM inventory mismatches. |
|
||||
| `RetryStats` | Durable control-plane retry backlog and escalation count. |
|
||||
| `Metrics.replMetrics` | Per-destination online state, downtime, replicated counts/bytes, and `failed` totals/windows. |
|
||||
| `Metrics.queued` / `Metrics.inProgress` | Object work waiting or active on the responding node. |
|
||||
| `Metrics.errors` | Node-level object-replication failures. When only queue statistics are available, RustFS synthesizes a node entry and preserves this counter rather than reporting zero. |
|
||||
| `Metrics.retries` | Redeliveries. Always zero today: a failed object is not retried by an event, it waits for the scanner pass described below. Read `errors` instead. |
|
||||
|
||||
Healthy means: the same topology is visible on all sites, no pending operation,
|
||||
no peer error, no failed retry escalation, required bucket/IAM state is in sync,
|
||||
and queue/error counters are stable or falling. Counters are cumulative; alert on
|
||||
their rate and on a backlog that does not drain, not merely on a non-zero total.
|
||||
|
||||
## Pending operations and recovery
|
||||
|
||||
`pendingOperation` contains `operation`, an opaque `id`, `pendingPeers`, and
|
||||
`ackedPeers`. Do not edit the site-replication state object by hand. The marker
|
||||
is the crash-recovery journal and removing it can make a partially applied
|
||||
operation look complete.
|
||||
|
||||
The heavyweight reconciler runs once at startup and every 600 seconds. The
|
||||
lightweight retry drain runs every 30 seconds. A restart is therefore a valid
|
||||
way to cause an immediate heavyweight pass after the underlying fault has been
|
||||
fixed, but it is not a substitute for fixing connectivity, credentials, TLS,
|
||||
or the remote endpoint.
|
||||
|
||||
### `remove`
|
||||
|
||||
The original topology and each peer acknowledgement are persisted before the
|
||||
operation finalizes. While peers remain in `pendingPeers`, restore access to
|
||||
them and wait for reconciliation. If a peer is permanently gone, a new remove
|
||||
request may remove all currently active unacknowledged peers; RustFS permits
|
||||
that request and then finalizes against the remaining topology. Removing the
|
||||
local site or all sites is also an explicit completion path.
|
||||
|
||||
Do not re-add a site merely to hide this marker. First compare the topology on
|
||||
all reachable peers. If the same operation ID makes no progress for more than
|
||||
one heavyweight interval, collect `PeerErrors`, `RetryStats`, and the
|
||||
site-replication logs before retrying the remove.
|
||||
|
||||
### `rotate-svc-acct`
|
||||
|
||||
Service-account rotation keeps the candidate secrets and peer acknowledgements
|
||||
until every current remote peer accepts the rotation. Restore the failing peer
|
||||
and allow the reconciler to resume it. Do not manually delete either candidate
|
||||
credential during this window: doing so can remove the only credential that a
|
||||
not-yet-acknowledged peer accepts.
|
||||
|
||||
After the marker clears, verify `replicate status` from every site, then retire
|
||||
any separately retained old credential material according to local policy.
|
||||
|
||||
### `endpoint-refresh`
|
||||
|
||||
An endpoint, CA, or TLS-verification edit first refreshes the replication
|
||||
target on every active peer and records acknowledgements. On startup and every
|
||||
heavyweight pass, RustFS probes peer capability, uses the endpoint-refresh API
|
||||
when supported (or the legacy peer-edit fallback), refreshes local bucket
|
||||
targets, and commits the edit only after every still-active peer acknowledges.
|
||||
|
||||
If this marker is stuck:
|
||||
|
||||
1. Confirm that the proposed endpoint and CA are correct and reachable from
|
||||
every site, not only from the admin workstation.
|
||||
2. Restore the site-replication service account and TLS trust path.
|
||||
3. Wait for one 600-second pass or restart one healthy node to trigger the
|
||||
startup pass.
|
||||
4. Re-run the identical edit only if the operation remains visible; a different
|
||||
endpoint edit is rejected while the existing refresh is pending. The journal
|
||||
pins the edit's payload, so a re-run without `--replicate-ilm-expiry` keeps
|
||||
the value the first attempt recorded, and a re-run asking for a different
|
||||
value is rejected. Finish or remove the pending refresh before changing it.
|
||||
|
||||
A peer removed from the topology no longer blocks completion. A remove request
|
||||
is accepted when it removes every active unacknowledged peer.
|
||||
|
||||
While this marker is present, control-plane retry replay to the other peers
|
||||
keeps running, but bucket wiring reconciliation waits: it rewrites the same
|
||||
targets the refresh is changing. Expect bucket-level drift on this site to
|
||||
persist until the refresh settles.
|
||||
|
||||
## Outage recovery and convergence time
|
||||
|
||||
Control-plane retry begins on the 30-second drain, while heavyweight snapshots,
|
||||
pending topology operations, and bucket wiring are revisited on the 600-second
|
||||
pass. Object MRF entries are persisted every 10 seconds by default and target
|
||||
health is probed every 5 seconds. These are scheduling bounds, not delivery
|
||||
SLAs: network timeouts and the amount of queued work add to them.
|
||||
|
||||
Objects that must be rediscovered by the scanner have this conservative upper
|
||||
bound before discovery:
|
||||
|
||||
```text
|
||||
RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES
|
||||
× max(RUSTFS_SCANNER_CYCLE, actual duration of one scanner cycle)
|
||||
```
|
||||
|
||||
The defaults re-descend a compacted directory every 16 cycles. A practical
|
||||
production starting point for a tighter recovery objective is
|
||||
`RUSTFS_DATA_USAGE_UPDATE_DIR_CYCLES=4`; `1` forces re-descent every cycle.
|
||||
Measure the additional disk and metadata load before lowering it further or
|
||||
tuning the scanner cadence. For an immediate operator-driven recovery, start a
|
||||
site resync with `mc admin replicate resync start` and monitor its status.
|
||||
Transfer time after discovery remains proportional to backlog size, bandwidth,
|
||||
worker capacity, and target latency. Use queue depth and the rate of
|
||||
`Metrics.errors` rather than the formula alone to decide whether convergence is
|
||||
progressing.
|
||||
|
||||
## Pairing sites that already contain data
|
||||
|
||||
When more than one requested site is non-empty, preflight considers each bucket
|
||||
name held by more than one site:
|
||||
|
||||
- versioning must be `Enabled` on every site holding the shared bucket;
|
||||
- Object Lock enablement must be identical on every holder.
|
||||
|
||||
A bucket present on only one site is safe: post-add backfill creates it on the
|
||||
other peers. A shared unversioned bucket is rejected because merging can
|
||||
overwrite the only copy of an object. An Object Lock mismatch is rejected
|
||||
because lock enablement cannot be changed after bucket creation and convergence
|
||||
could otherwise strip a WORM guarantee.
|
||||
|
||||
If preflight rejects the pair, keep the authoritative copy, delete the
|
||||
conflicting bucket (or its contents) from all other sites, run `replicate add`
|
||||
again, and then start `replicate resync` from the surviving site. Back up and
|
||||
validate the authoritative data before deleting anything.
|
||||
|
||||
## IAM convergence and repair boundary
|
||||
|
||||
Ordinary IAM changes are delivered to each peer. A successful bulk IAM import
|
||||
also schedules one collapsed full-IAM snapshot per remote peer. A failed IAM
|
||||
deletion is replayed before that snapshot so the snapshot cannot re-create a
|
||||
principal or grant that was already revoked.
|
||||
|
||||
The safety state has two bounds:
|
||||
|
||||
- deletion high-water marks are retained for 30 days;
|
||||
- deletion replay bodies are capped at 256 distinct entities per peer.
|
||||
|
||||
Repeated deletion of the same entity replaces its saved body. When the per-peer
|
||||
cap is exceeded or the body cannot be serialized, the retry entry remains
|
||||
escalated rather than pretending the deletion is replayable. An item from an
|
||||
older sender without a source timestamp cannot install the 30-day high-water
|
||||
mark, so verify it explicitly after a prolonged split. A successful drain
|
||||
clears replay bodies; removing the peer prunes its bodies. For an escalated IAM
|
||||
retry, use the site-replication repair workflow for the affected peer and IAM
|
||||
family, then verify users, service accounts, groups, policies, and mappings on
|
||||
both sides. Repair is the operator's explicit accountability transfer and
|
||||
clears the saved deletion bodies only after the IAM repair succeeds.
|
||||
|
||||
A group's status converges in one direction. An explicit disable is applied
|
||||
everywhere, including through a snapshot, but a membership change never
|
||||
carries an enable - it would otherwise re-enable a group frozen on the
|
||||
receiving site. If a group ended up disabled on one site only, re-enable it
|
||||
there explicitly with `mc admin group enable`; a snapshot or repair will not
|
||||
do it.
|
||||
|
||||
Treat IAM divergence as a security incident: a user deleted on one site can
|
||||
remain usable on an unreachable peer until replay or repair completes. A peer
|
||||
whose IAM entry is escalated does not receive scheduled snapshots either -
|
||||
including the one a bulk import schedules - until the repair settles it.
|
||||
|
||||
## Encrypted objects
|
||||
|
||||
| Source form | Replication behavior | Fail-closed condition |
|
||||
|---|---|---|
|
||||
| SSE-S3 | The source decrypts the object; the request sends only `AES256` intent; the destination encrypts with its own KMS. Source envelope material never leaves the site. | The destination cannot satisfy the encryption request, or the source metadata is incomplete/unsupported. The replica is `FAILED`; plaintext is not silently stored. |
|
||||
| SSE-KMS | The source decrypts the object; the request sends `aws:kms` intent without the source-local key ID; the destination selects its own configured KMS key. | Either side cannot decrypt/encrypt, or the metadata mixes incompatible encryption evidence. |
|
||||
| SSE-C | Stored ciphertext and the required SSE-C replication transport metadata pass through. RustFS verifies target evidence before accepting the replica. | The target does not echo the customer-algorithm evidence, required material/layout is absent, or the metadata is ambiguous. |
|
||||
|
||||
Unknown MinIO/RustFS encryption markers are never forwarded as ordinary user
|
||||
metadata. They fail replication so an operator must migrate or repair the
|
||||
object with a supported format.
|
||||
|
||||
## Rolling upgrades and rollback
|
||||
|
||||
Keep every node in one site on the same version whenever possible. Upgrade all
|
||||
nodes of one site consecutively, verify its startup reconciliation and status,
|
||||
then move to the next site. Do not intentionally leave a site mixed-version:
|
||||
admin requests can land on different nodes, and an older node may not resume a
|
||||
new pending-operation shape or expose its health fields.
|
||||
|
||||
Current state additions are optional and defaulted, so older readers ignore
|
||||
them. The target-version ledger is stored as dual-prefixed internal object
|
||||
metadata and is also ignored by older readers; rollback does not corrupt the
|
||||
object format, but older code loses the assigned-version routing improvement.
|
||||
|
||||
Before rolling back across the fix that retains the data directory of a version
|
||||
awaiting purge replication (rustfs/rustfs#7307), ensure no version purge is
|
||||
pending. Older code can free that retained version's data directory before the
|
||||
remote purge is acknowledged, leaving unreadable metadata and blocking bucket
|
||||
deletion. Drain or repair replication and take a metadata/data backup first.
|
||||
|
||||
## Runtime knobs
|
||||
|
||||
These values are read when the owning background task starts. Restart the
|
||||
server after changing them. The millisecond intervals have a 10 ms floor;
|
||||
invalid values fall back to the default with a warning.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
|---|---:|---|
|
||||
| `RUSTFS_REPL_HEALTH_CHECK_INTERVAL_MS` | `5000` | Remote-target health probe interval. Lowering it increases outbound probes. |
|
||||
| `RUSTFS_REPL_MRF_FLUSH_INTERVAL_MS` | `10000` | Maximum periodic interval between MRF persistence flushes; 1,000 new entries also trigger a flush. |
|
||||
| `RUSTFS_REPL_RESYNC_POLL_MAX_MS` | `60000` | Upper bound for randomized resync retry-poll sleep. |
|
||||
| `RUSTFS_REPL_RESYNC_MAX_JOBS` | `2` | Concurrent resync jobs; values are bounded to `1..=32`. |
|
||||
|
||||
Transport-specific controls and target behavior are documented in
|
||||
[Replication outbound transport](replication-outbound-transport.md). Validate a
|
||||
new destination with [Replication target check](replication-check.md), and read
|
||||
[Replication object size limits](replication-object-size-limits.md) before
|
||||
moving large objects.
|
||||
@@ -8,11 +8,21 @@
|
||||
|
||||
| Method | Config tag | Credential lifetime | Background renewal | Recommended for |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Static token | `Token` | Whatever the operator provisioned; RustFS never renews it | None | Development; short-lived experiments |
|
||||
| Static token | `Token` | Whatever the operator provisioned; read from Vault at login | Renewed at half TTL when Vault reports the token as renewable | Development; short-lived experiments |
|
||||
| AppRole | `AppRole` | Lease-bound token obtained by login; renewed by RustFS | Renew at half TTL, re-login on failure | Production without a Vault Agent sidecar |
|
||||
| Kubernetes | `Kubernetes` | Lease-bound token obtained by login; renewed by RustFS | Renew at half TTL, re-login on failure | Production on Kubernetes, with no credential to distribute |
|
||||
| Agent token file | `TokenFile` | Owned by Vault Agent; RustFS only re-reads the sink file | File re-read once per poll interval | Production with a Vault Agent (or equivalent) managing auth |
|
||||
|
||||
### Static token: what RustFS now knows about it
|
||||
|
||||
`vault token create` grants a 768-hour TTL by default, so a static token normally *does* expire. At login RustFS calls `auth/token/lookup-self` and adopts whatever Vault reports:
|
||||
|
||||
- **No expiry** (a root or periodic-root token, `ttl` 0): unchanged — no lease is tracked, no renewal task runs, and the token is never refused locally.
|
||||
- **Expiring and renewable:** the ordinary renewal loop takes over, renewing at half the remaining TTL and publishing the remaining-TTL gauge.
|
||||
- **Expiring but not renewable:** a `vault_static_token_not_renewable` warning is logged with the remaining TTL, the gauge is published, and requests fail closed inside the safety window rather than lapsing mid-flight against Vault. Rotate to a fresh token, or move to AppRole, Kubernetes, or an agent-managed token file.
|
||||
|
||||
The probe never fails the login. A token whose policy omits `lookup-self` (Vault's `default` policy grants it), or a Vault that is unreachable at that moment, logs `vault_static_token_lookup_failed` and falls back to the previous behaviour — no lease tracked, no renewal — so a deployment that works today keeps working. That fallback lasts for the life of the client generation, so treat the warning as something to fix rather than tolerate.
|
||||
|
||||
Exactly one method must be configured. Setting `RUSTFS_KMS_VAULT_TOKEN_FILE` together with any other method, or `RUSTFS_KMS_VAULT_KUBERNETES_ROLE` together with `RUSTFS_KMS_VAULT_APPROLE_ROLE_ID`, is rejected at startup with a configuration error because the effective identity would be ambiguous. A leftover `RUSTFS_KMS_VAULT_TOKEN` alongside a configured login method is tolerated and ignored, so a stale variable cannot silently downgrade the identity.
|
||||
|
||||
All of these are read the same way whether the service is started with `RUSTFS_KMS_ENABLE=true` or configured later through `POST /rustfs/admin/v3/kms/configure`.
|
||||
|
||||
@@ -43,18 +43,17 @@ Promotion rule: never promote a report-only lane to required from one green run.
|
||||
| PR, non-doc change | `End-to-End Tests` | `ci.yml` `e2e-tests` | Report-only | `cargo nextest run --profile e2e-smoke -p e2e_test`, then `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>`; membership guards `scripts/check_test_wiring.py --check-profile e2e-smoke <listing.json>` and `scripts/check_security_smoke_count.sh check <listing.json>` |
|
||||
| PR, non-doc change | `S3 Implemented Tests` | `ci.yml` `s3-implemented-tests` | Report-only | build `rustfs`, then `scripts/s3-tests/run.sh` with the job's `DEPLOY_MODE` / `TEST_MODE` / `MAXFAIL` env |
|
||||
| PR, non-doc change | `S3 Lifecycle Behavior Tests` | `ci.yml` `s3-lifecycle-behavior-tests` | Report-only | `scripts/s3-tests/run.sh` with the job's accelerated-scanner env |
|
||||
| PR to `main` or `release` touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||
| Push to `main` or `release` touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report` | `audit.yml` `cargo-deny`, `workflow-pin-report` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||
| PR touching `paths` in `audit.yml` | `Cargo Deny`, `Workflow Pin Report`, `Dependency Review` | `audit.yml` `cargo-deny`, `workflow-pin-report`, `dependency-review` | Report-only | `cargo deny check`; `scripts/security/check_workflow_pins.sh` |
|
||||
| PR touching `paths` in `architecture-migration-rules.yml` | `Architecture Migration Rules` | `architecture-migration-rules.yml` `architecture-migration-rules` | Report-only | `scripts/check_architecture_migration_rules.sh` |
|
||||
| PR touching `paths` in `nix.yml` | `Nix Build & Check` | `nix.yml` `nix-validation` | Report-only | `nix flake check` |
|
||||
| PR touching `paths` in `fuzz.yml` | `Build Fuzz Harness`, `Smoke / <target>` | `fuzz.yml` `fuzz-build`, `pr-fuzz-smoke` | Report-only | `MAX_TOTAL_TIME=60 ./scripts/fuzz/run.sh` |
|
||||
| PR touching `paths` in `windows-filesystem.yml` | `Rename Safety` | `windows-filesystem.yml` `rename-safety` | Report-only | the `cargo test -p rustfs-ecstore --lib <filter>` commands in the job, on Windows |
|
||||
| PR touching `paths` in `coverage.yml` | `Workspace line coverage` | `coverage.yml` `coverage` | Report-only | `make coverage`; `python3 scripts/check_security_coverage.py target/llvm-cov/coverage.json` |
|
||||
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata`, `ODM configuration recovery after rc.5 rollback`, `Multipart layouts survive the rc.5 upgrade`, `rc.5 multipart replication baseline` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
||||
| PR touching `paths` in `e2e-upgrade.yml` | `Direct upgrade from the previous release`, `Mixed-version rolling upgrade from the previous release`, `Bucket configuration survives the upgrade`, `Rollback reads current bucket metadata` | `e2e-upgrade.yml` `upgrade` matrix | Report-only | the `cargo test --locked -p e2e_test` command in the job with `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous release (`UPGRADE_SOURCE_VERSION`) |
|
||||
| PR touching `paths` in `oidc-keycloak.yml` | `OIDC Keycloak live gate` | `oidc-keycloak.yml` `oidc-keycloak-live` | Report-only | `cargo build --locked -p rustfs --bin rustfs`, then `bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs` |
|
||||
| PR touching `paths` in `targets-integration.yml` | `PostgreSQL, MySQL, AMQP, and NATS` | `targets-integration.yml` `targets-live` | Report-only | start the containers as in the job, export the `RUSTFS_TEST_*` DSNs, then the job's `cargo test --locked -p rustfs-targets --test <name> -- --ignored --test-threads=1` commands |
|
||||
| PR limited to main-CI-excluded paths | `Quick Checks`, `Test and Lint` | `ci-docs-only.yml` `quick-checks`, `test-and-lint` | Required | `git diff --check`; `make doc-paths-check`; `scripts/check_no_planning_docs.sh` |
|
||||
| `merge_group`; push to `main` or `release` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Report-only | `cargo nextest run --profile e2e-full -p e2e_test` |
|
||||
| `merge_group`; push to `main` | `End-to-End Tests (full merge gate)` | `ci.yml` `e2e-full` | Report-only | `cargo nextest run --profile e2e-full -p e2e_test` |
|
||||
|
||||
e2e filters live in `.config/nextest.toml`; extend a profile instead of adding a second selector. Before a profile runs, `scripts/check_test_wiring.py` compares its listing to the committed digest in `.config/e2e-<profile>-selection.txt`, so a silent test drop fails closed.
|
||||
|
||||
@@ -63,7 +62,7 @@ cost. `data_usage_test` runs in the PR `e2e-smoke` lane so changes that affect
|
||||
authoritative scanner usage publication, quota-visible usage, or admin usage
|
||||
snapshots get an end-to-end signal before merge review. `heal_erasure_disk_rebuild_test`
|
||||
runs in `e2e-full` so core erasure heal rebuild regressions are caught no later
|
||||
than the merge queue or `main`/`release` push lane; it also remains in `e2e-nightly` with
|
||||
than the merge queue or `main` push lane; it also remains in `e2e-nightly` with
|
||||
the serialized cluster fault-domain suites for scheduled soak signal.
|
||||
|
||||
## Scheduled validation
|
||||
@@ -86,7 +85,7 @@ Scheduled lanes never block a PR. Their workflow-local gate fails the run, sched
|
||||
| `mint.yml` (weekly) | `mint` | report-only by design; per-suite PASS/FAIL/NA and raw `log.json` | yes | pinned Docker sequence in the workflow |
|
||||
| `coverage.yml` (weekly) | `coverage` | report-only trend; lcov and JSON artifact | yes | `make coverage` |
|
||||
| `runner-hygiene.yml` (monthly) | `check-ephemerality` | runner ephemerality | yes | dispatch |
|
||||
| `e2e-upgrade.yml` (weekly) | `upgrade` (7-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
||||
| `e2e-upgrade.yml` (weekly) | `upgrade` (4-case matrix) | upgrade and rollback gate; server logs | no | see the PR row |
|
||||
| `oidc-keycloak.yml` (weekly) | `oidc-keycloak-live` | live OIDC gate | no | see the PR row |
|
||||
| `targets-integration.yml` (nightly) | `targets-live` | live target gate; container logs | no | see the PR row |
|
||||
| `scheduled-validation-freshness.yml` (nightly) | `check-freshness` | fails on a never-created or stale schedule | n/a | dispatch |
|
||||
@@ -263,6 +262,33 @@ mixed-peer fallback oracles; G09 keeps mixed-version reader, writer, and rollbac
|
||||
payload evidence explicit. These fields are part of the release contract, not
|
||||
evidence by themselves.
|
||||
|
||||
The upgrade compatibility E2E can emit raw G09 JSON artifacts when
|
||||
`RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR` points at a fresh, task-owned directory.
|
||||
The rolling mixed-version test writes `G09-mixed_version_reader_evidence.json`
|
||||
and `G09-mixed_version_writer_evidence.json` after the old/new reader and writer
|
||||
assertions pass. The bucket-metadata rollback test writes
|
||||
`G09-rollback_payload_evidence.json` after the current -> previous -> current
|
||||
round trip has read back the known bucket configuration and objects. These
|
||||
artifacts are measured inputs for a later release bundle; the bundle must still
|
||||
record their relative paths, hashes, command provenance, timestamps, roles,
|
||||
participating revisions, and case lists before
|
||||
`--check-scanner-heal-release-bundle` can validate them.
|
||||
|
||||
For a release-candidate or PR-head Linux x86_64 host, run the full raw G09
|
||||
artifact pass with:
|
||||
|
||||
```bash
|
||||
scripts/run_scanner_heal_g09_upgrade_evidence.sh
|
||||
```
|
||||
|
||||
The script mirrors the pinned previous-release asset used by the upgrade
|
||||
workflow, builds the current checkout, runs the mixed-version and rollback E2E
|
||||
lanes, and fails unless all three raw G09 artifacts are measured, revision-bound,
|
||||
and role-bound. Use `--source-binary` for a custom previous-release binary on
|
||||
another platform, or `--test mixed-version|rollback` while narrowing a failure.
|
||||
It performs a free-space preflight before building so a saturated validation
|
||||
host fails before producing partial evidence.
|
||||
|
||||
When the real release lanes have produced their dedicated artifacts, validate
|
||||
the complete hard-gate bundle with:
|
||||
|
||||
@@ -280,6 +306,12 @@ also binds each evidence field to its own run provenance: `source_revision`,
|
||||
`finished_at`, command arguments, and artifact format. The field
|
||||
`source_revision` must match the bundle revision, and measured performance
|
||||
duration cannot exceed the recorded run window.
|
||||
When an evidence or profile artifact declares a JSON format, the checker also
|
||||
opens that artifact and requires its payload to repeat the same measured
|
||||
`source_revision`, `run_id`, `measurement_window_id`, gate and field identity;
|
||||
profile sub-artifacts must additionally name their artifact kind. Updating only
|
||||
the outer bundle hash cannot turn a stale JSON summary into current release
|
||||
evidence.
|
||||
|
||||
The hard evidence shape remains claim-specific: mixed-version gates must name at
|
||||
least two participating versions, crash/durable replay gates must include
|
||||
@@ -297,6 +329,12 @@ per-artifact window is declared. Missing, synthetic, stale, tampered, undersized
|
||||
or topology-mismatched evidence returns a compact blocked or invalid JSON result
|
||||
and a nonzero exit.
|
||||
|
||||
The scheduler-pressure lane must also carry the numbers needed to close W09,
|
||||
W10, and W11: bounded deferred item/byte/age limits, zero duplicate tasks,
|
||||
pressure pacing engagement, recovery and lock-hold timings, fixed offered load,
|
||||
foreground p95/p99 latency, throughput, error count, attempt-cost samples, and
|
||||
completed heal object counts.
|
||||
|
||||
This command validates the evidence package; it does not create evidence. A
|
||||
handwritten JSON file, a synthetic harness pass, a single focused case, or a
|
||||
local unit fixture still cannot satisfy the distributed, mixed-version,
|
||||
@@ -305,3 +343,16 @@ crash-restart, durable MRF replay, EC8+4, ABBA, or profiling gates.
|
||||
Run parser/receipt regressions with
|
||||
`scripts/python_bin.sh scripts/check_test_wiring.py --self-test`. Those fixtures
|
||||
validate the checker only and produce no runtime or performance evidence.
|
||||
|
||||
For local bundle-shape dry runs, generate a task-owned fixture directory with:
|
||||
|
||||
```bash
|
||||
scripts/python_bin.sh scripts/check_test_wiring.py \
|
||||
--write-scanner-heal-release-bundle-fixture /path/to/fixture-dir
|
||||
```
|
||||
|
||||
The generated file is marked `fixture_only` and is intentionally rejected by the
|
||||
release bundle checker. Use it to rehearse field names, artifact paths, hashes,
|
||||
profile artifact membership, mixed-version roles, and same-window provenance
|
||||
before copying the shape into a real measured bundle. It is not ABBA, profile,
|
||||
mixed-version, crash-restart, or release approval evidence.
|
||||
|
||||
@@ -77,4 +77,18 @@ cargo nextest run --profile e2e-distributed -p e2e_test -E 'not test(/^distribut
|
||||
|
||||
The upgrade topology is `ClusterTopology::single_pool(4)` (4 nodes × 1 drive). That matches the proven mixed-version fixture in `upgrade_compatibility_test`; 4×4 localhost drives are rejected by the previous release's same-device disk check.
|
||||
|
||||
Scanner/Heal G09 release evidence can be collected on Linux x86_64 with the
|
||||
same pinned previous-release binary used by `e2e-upgrade.yml`:
|
||||
|
||||
```bash
|
||||
scripts/run_scanner_heal_g09_upgrade_evidence.sh
|
||||
```
|
||||
|
||||
The runner builds the current `rustfs` binary, downloads and verifies the
|
||||
pinned previous release, runs the mixed-version rolling-upgrade and rollback
|
||||
tests, and leaves the required raw G09 artifacts under
|
||||
`target/scanner-heal-g09-upgrade-evidence/<timestamp>/`. These artifacts are
|
||||
inputs for the Scanner/Heal release bundle gate; the runner does not mark the
|
||||
full release matrix complete by itself.
|
||||
|
||||
Membership is pinned by `.config/e2e-distributed-selection.txt`. Update the Linux and Darwin entries with `python3 ./scripts/check_test_wiring.py --update-profile e2e-distributed <listing.json> <platform>` after adding or renaming a case.
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ behavior.
|
||||
| config.rustfs.kms.vault.vault_backend | string | `""`| The vault backend, `vault-kv2` or `vault-transit`. |
|
||||
| config.rustfs.kms.vault.vault_address | string | `""`| The vault address. |
|
||||
| config.rustfs.kms.vault.vault_token | string | `""`| The vault token. Rendered into a dedicated Secret (`<fullname>-kms-secret`), never into the ConfigMap. |
|
||||
| config.rustfs.kms.vault.vault_mount_path | string | `"transit"`| The vault mount path, only works if `vault_backend` equals `vault-transit` . |
|
||||
| config.rustfs.kms.vault.vault_mount_path | string | `"transit"`| The vault mount path. Rendered as `RUSTFS_KMS_VAULT_MOUNT_PATH` for `vault-transit`, and as `RUSTFS_KMS_VAULT_KV_MOUNT` for `vault-kv2` (only when set; unset keeps the `secret` default). |
|
||||
| config.rustfs.kms.vault.default_key | string | `"transit"`| The master key id for RustFS. |
|
||||
| extraEnv | list | `[]` | Extra environment variables for the RustFS container. An explicit `RUSTFS_LOCAL_ENDPOINT_HOST` or `RUSTFS_VOLUMES`, or a bounded, dynamic, or unrecognized startup mode, disables generated anchor injection. `POD_NAME` and `RUSTFS_ADDRESS` remain independent overrides. |
|
||||
| extraVolumes | list | `[]` | Extra volumes to add to the pod spec. Supported in both standalone (Deployment) and distributed (StatefulSet) modes. |
|
||||
|
||||
@@ -12,52 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("git").args(args).current_dir(root).output().ok()?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
fn main() -> shadow_rs::SdResult<()> {
|
||||
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").ok_or("missing CARGO_MANIFEST_DIR")?;
|
||||
let root = Path::new(&manifest).join("..");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
// Match the E2E build identity's source and configuration inputs.
|
||||
for path in [
|
||||
"crates",
|
||||
"rustfs",
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
"rust-toolchain.toml",
|
||||
".cargo",
|
||||
".config",
|
||||
] {
|
||||
let path = root.join(path);
|
||||
if path.exists() {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
}
|
||||
}
|
||||
// Resolve worktree-local HEAD/index and common refs through Git. Watching
|
||||
// refs also covers tags and a new loose ref created from packed refs.
|
||||
for name in ["HEAD", "index", "refs", "packed-refs"] {
|
||||
if let Some(path) = git(&root, &["rev-parse", "--git-path", name]) {
|
||||
let path = Path::new(&path);
|
||||
let path = if path.is_absolute() {
|
||||
path.to_owned()
|
||||
} else {
|
||||
root.join(path)
|
||||
};
|
||||
if path.exists() {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-env-changed=RUSTFS_BUILD_VERSION");
|
||||
if let Ok(version) = std::env::var("RUSTFS_BUILD_VERSION")
|
||||
&& !version.is_empty()
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::admin::runtime_sources::{
|
||||
current_or_init_kms_runtime_service_manager,
|
||||
};
|
||||
use crate::admin::storage_api::config::{read_admin_config, save_admin_config};
|
||||
use crate::admin::storage_api::ecstore_topology::is_dist_erasure;
|
||||
use crate::admin::storage_api::error::StorageError;
|
||||
use crate::admin::storage_api::runtime::ECStore;
|
||||
use crate::admin::storage_api::s3::{S3ErrorCode, error as admin_s3_error};
|
||||
@@ -507,6 +508,21 @@ pub async fn reload_persisted_kms_config() -> Result<(), String> {
|
||||
reload_persisted_kms_config_from_store(store, kms_service_manager_from_context(), "peer_reload").await
|
||||
}
|
||||
|
||||
/// Whether a reload may return early because this node is already serving
|
||||
/// exactly the persisted configuration.
|
||||
///
|
||||
/// Byte-identical configuration is not sufficient on its own. A node whose KMS
|
||||
/// failed to start keeps its configuration and sits in `Error`, so comparing
|
||||
/// only the bytes turned the documented recovery call
|
||||
/// (`POST /rustfs/admin/v3/kms/reload`) into a no-op that reported success and
|
||||
/// left the node down — including on every peer, which reaches this same
|
||||
/// function through the reload broadcast (backlog#2369 P1). Any state other
|
||||
/// than `Running` falls through to `reconfigure`, which starts the service when
|
||||
/// none is running.
|
||||
fn kms_reload_is_already_current(status: rustfs_kms::KmsServiceStatus, config_is_unchanged: bool) -> bool {
|
||||
matches!(status, rustfs_kms::KmsServiceStatus::Running) && config_is_unchanged
|
||||
}
|
||||
|
||||
async fn reload_persisted_kms_config_from_store(
|
||||
store: Arc<ECStore>,
|
||||
service_manager: Arc<rustfs_kms::KmsServiceManager>,
|
||||
@@ -525,11 +541,11 @@ async fn reload_persisted_kms_config_from_store(
|
||||
return Err("no persisted KMS configuration is available".to_string());
|
||||
};
|
||||
|
||||
if service_manager
|
||||
let config_is_unchanged = service_manager
|
||||
.get_config()
|
||||
.await
|
||||
.is_some_and(|current| kms_config_is_unchanged(¤t, &config))
|
||||
{
|
||||
.is_some_and(|current| kms_config_is_unchanged(¤t, &config));
|
||||
if kms_reload_is_already_current(service_manager.get_status().await, config_is_unchanged) {
|
||||
info!(
|
||||
event = "kms_service_state",
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
@@ -624,6 +640,51 @@ fn local_success_with_peer_report(message: &str, unconverged: &[String]) -> (boo
|
||||
)
|
||||
}
|
||||
|
||||
/// What a node-local KMS backend means for a multi-node deployment
|
||||
/// (backlog#2369 P7.4).
|
||||
///
|
||||
/// The Local backend keeps key material on each node's own disk and generates
|
||||
/// its KDF salt per node, so two nodes derive different keys from the same
|
||||
/// `master_key`. An object encrypted on node A cannot be decrypted on node B:
|
||||
/// behind a load balancer that shows up as intermittent 500s on reads that
|
||||
/// worked a moment earlier. The product decision to warn rather than refuse
|
||||
/// stands; the generic "development only" warning simply never said what
|
||||
/// actually goes wrong, so an operator had no way to connect the symptom to
|
||||
/// the cause.
|
||||
///
|
||||
/// Returns the sentence to append to the configure response, or `None` when the
|
||||
/// combination does not apply.
|
||||
async fn node_local_backend_warning(backend: &rustfs_kms::KmsBackend) -> Option<&'static str> {
|
||||
if !matches!(backend, rustfs_kms::KmsBackend::Local) || !is_dist_erasure().await {
|
||||
return None;
|
||||
}
|
||||
|
||||
warn!(
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_KMS,
|
||||
event = "kms_node_local_backend_in_distributed_deployment",
|
||||
backend = rustfs_kms::KmsBackend::Local.as_str(),
|
||||
"The Local KMS backend stores key material on each node's own disk with a per-node salt, so objects \
|
||||
encrypted on one node cannot be decrypted on another. In a distributed deployment this surfaces as \
|
||||
intermittent 500s on reads behind a load balancer. Use Vault Transit, Vault KV2 or AWS KMS for a \
|
||||
multi-node deployment"
|
||||
);
|
||||
|
||||
Some(
|
||||
"Warning: the Local KMS backend is node-local. Key material and its salt live on each node's own disk, so \
|
||||
objects encrypted on one node cannot be decrypted on another and reads behind a load balancer will fail \
|
||||
intermittently. Use Vault Transit, Vault KV2 or AWS KMS for a distributed deployment",
|
||||
)
|
||||
}
|
||||
|
||||
/// Append the node-local backend warning to a successful configure message.
|
||||
fn with_node_local_backend_warning(message: String, warning: Option<&'static str>) -> String {
|
||||
match warning {
|
||||
Some(warning) => format!("{message}. {warning}"),
|
||||
None => message,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_kms_dynamic_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
||||
r.insert(
|
||||
Method::POST,
|
||||
@@ -746,6 +807,7 @@ impl Operation for ConfigureKmsHandler {
|
||||
let kms_config = configure_request.to_kms_config();
|
||||
|
||||
let persisted_config = kms_config.clone();
|
||||
let node_local_warning = node_local_backend_warning(&kms_config.backend).await;
|
||||
let (success, message, status) = match service_manager
|
||||
.configure_with_persistence(kms_config, || async move {
|
||||
save_kms_config(&persisted_config)
|
||||
@@ -768,7 +830,7 @@ impl Operation for ConfigureKmsHandler {
|
||||
let unconverged = broadcast_kms_config_reload().await;
|
||||
let (success, message) = local_success_with_peer_report("KMS configured successfully", &unconverged);
|
||||
audit.finish(KmsAdminOperation::Configure, None, None);
|
||||
(success, message, status)
|
||||
(success, with_node_local_backend_warning(message, node_local_warning), status)
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("Failed to configure KMS: {e}");
|
||||
@@ -1345,6 +1407,7 @@ impl Operation for ReconfigureKmsHandler {
|
||||
let kms_config = configure_request.to_kms_config();
|
||||
|
||||
let persisted_config = kms_config.clone();
|
||||
let node_local_warning = node_local_backend_warning(&kms_config.backend).await;
|
||||
let (success, message, status) = match service_manager
|
||||
.reconfigure_with_persistence(kms_config, || async move {
|
||||
save_kms_config(&persisted_config)
|
||||
@@ -1368,7 +1431,7 @@ impl Operation for ReconfigureKmsHandler {
|
||||
let (success, message) =
|
||||
local_success_with_peer_report("KMS reconfigured and restarted successfully", &unconverged);
|
||||
audit.finish(KmsAdminOperation::Reconfigure, None, None);
|
||||
(success, message, status)
|
||||
(success, with_node_local_backend_warning(message, node_local_warning), status)
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("Failed to reconfigure KMS: {e}");
|
||||
@@ -1420,9 +1483,10 @@ impl Operation for ReconfigureKmsHandler {
|
||||
mod tests {
|
||||
use super::{
|
||||
KmsConfigLoadError, decode_persisted_kms_config, ensure_kms_config_persistable, ensure_kms_request_persistable,
|
||||
kms_config_fingerprint, kms_config_is_unchanged, kms_configure_actions, kms_service_control_actions,
|
||||
load_kms_config_with, local_success_with_peer_report, normalize_configure_request_secrets, open_persisted_kms_config,
|
||||
redacted_canonical_config, register_kms_dynamic_route, seal_persisted_kms_config,
|
||||
kms_config_fingerprint, kms_config_is_unchanged, kms_configure_actions, kms_reload_is_already_current,
|
||||
kms_service_control_actions, load_kms_config_with, local_success_with_peer_report, normalize_configure_request_secrets,
|
||||
open_persisted_kms_config, redacted_canonical_config, register_kms_dynamic_route, seal_persisted_kms_config,
|
||||
with_node_local_backend_warning,
|
||||
};
|
||||
use crate::admin::router::{AdminOperation, S3Router};
|
||||
use crate::admin::storage_api::error::StorageError;
|
||||
@@ -1432,6 +1496,49 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// backlog#2369 P1: a node whose KMS failed to start keeps its persisted
|
||||
/// configuration, so an unchanged-bytes comparison made the documented
|
||||
/// recovery call a no-op that still reported success.
|
||||
#[test]
|
||||
fn kms_reload_only_short_circuits_for_a_running_service() {
|
||||
use rustfs_kms::KmsServiceStatus;
|
||||
|
||||
assert!(
|
||||
kms_reload_is_already_current(KmsServiceStatus::Running, true),
|
||||
"a running service on identical configuration has nothing to apply"
|
||||
);
|
||||
assert!(
|
||||
!kms_reload_is_already_current(KmsServiceStatus::Running, false),
|
||||
"changed configuration must always be applied"
|
||||
);
|
||||
|
||||
for status in [
|
||||
KmsServiceStatus::NotConfigured,
|
||||
KmsServiceStatus::Configured,
|
||||
KmsServiceStatus::Error("vault unreachable at startup".to_string()),
|
||||
] {
|
||||
assert!(
|
||||
!kms_reload_is_already_current(status.clone(), true),
|
||||
"reload must reconfigure instead of reporting success from {status:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// backlog#2369 P7.4: the operator has to learn the consequence from the
|
||||
/// response, not just from a log line the configuring client never sees.
|
||||
#[test]
|
||||
fn a_node_local_backend_warning_reaches_the_configure_response() {
|
||||
let plain = with_node_local_backend_warning("KMS configured successfully".to_string(), None);
|
||||
assert_eq!(plain, "KMS configured successfully");
|
||||
|
||||
let warned = with_node_local_backend_warning(
|
||||
"KMS configured successfully".to_string(),
|
||||
Some("Warning: the Local KMS backend is node-local"),
|
||||
);
|
||||
assert!(warned.starts_with("KMS configured successfully."), "{warned}");
|
||||
assert!(warned.contains("node-local"), "{warned}");
|
||||
}
|
||||
|
||||
fn assert_has_action(actions: &[Action], action: Action) {
|
||||
assert!(actions.contains(&action), "expected action list to contain {action:?}");
|
||||
}
|
||||
|
||||
@@ -627,6 +627,32 @@ pub(crate) async fn cluster_replication_stats(bucket: &str, context: Option<Arc<
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reload the bucket's metadata on every peer so a follow-up
|
||||
/// `put-bucket-replication` on another node does not read a stale target.
|
||||
///
|
||||
/// Best effort, like every S3 bucket-config write path
|
||||
/// (`app::bucket_usecase::notify_bucket_metadata_reload`): the target is
|
||||
/// already persisted and live on this node, and the 15-minute refresh closes
|
||||
/// the gap, so a peer that cannot be reached must not turn a completed write
|
||||
/// into a failed request.
|
||||
async fn notify_remote_target_metadata_reload(bucket: &str, context: Option<Arc<AppContext>>, action: &'static str) {
|
||||
let Some(notification_system) = current_notification_system_for_context(context.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = notification_system.load_bucket_metadata(bucket).await {
|
||||
warn!(
|
||||
event = EVENT_ADMIN_REMOTE_TARGET_STATE,
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION,
|
||||
action = action,
|
||||
result = "peer_metadata_reload_failed",
|
||||
bucket = %bucket,
|
||||
error = ?err,
|
||||
"admin remote target state"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_replication_peers(peer_clients: &[Option<PeerRestClient>]) -> (Vec<&PeerRestClient>, u32) {
|
||||
let mut seen_grid_hosts = HashSet::new();
|
||||
let peers: Vec<_> = peer_clients
|
||||
@@ -699,6 +725,7 @@ pub struct SetRemoteTargetHandler {}
|
||||
impl Operation for SetRemoteTargetHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let cred = validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
|
||||
let app_context = app_context_from_req(&req);
|
||||
|
||||
let queries = extract_query_params(&req.uri);
|
||||
|
||||
@@ -926,6 +953,8 @@ impl Operation for SetRemoteTargetHandler {
|
||||
.map_err(map_bucket_target_error)?;
|
||||
let _targets_guard = lock_bucket_targets_metadata(bucket).await;
|
||||
let arn = persist_remote_target_write(bucket, remote_target, incarnation, mode).await?;
|
||||
drop(_targets_guard);
|
||||
notify_remote_target_metadata_reload(bucket, app_context, "set_remote_target").await;
|
||||
let arn_str = serde_json::to_string(&arn)
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize target ARN"))?;
|
||||
|
||||
@@ -1006,6 +1035,7 @@ pub struct RemoveRemoteTargetHandler {}
|
||||
impl Operation for RemoveRemoteTargetHandler {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
validate_replication_admin_request(&req, AdminAction::SetBucketTargetAction).await?;
|
||||
let app_context = app_context_from_req(&req);
|
||||
|
||||
debug!("remove remote target called");
|
||||
let queries = extract_query_params(&req.uri);
|
||||
@@ -1081,6 +1111,7 @@ impl Operation for RemoveRemoteTargetHandler {
|
||||
}
|
||||
let json_targets = serde_json::to_vec(&targets)
|
||||
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "Failed to serialize targets"))?;
|
||||
let notification_bucket = bucket.clone();
|
||||
let bucket = bucket.clone();
|
||||
let arn = arn_str.clone();
|
||||
// The pool cancellation owns a detached task. Both outer guards must
|
||||
@@ -1101,6 +1132,8 @@ impl Operation for RemoveRemoteTargetHandler {
|
||||
S3Error::with_message(S3ErrorCode::InternalError, format!("remote target removal task failed: {error}"))
|
||||
})??;
|
||||
|
||||
notify_remote_target_metadata_reload(¬ification_bucket, app_context, "remove_remote_target").await;
|
||||
|
||||
Ok(S3Response::new((StatusCode::NO_CONTENT, Body::from("".to_string()))))
|
||||
}
|
||||
}
|
||||
@@ -1787,6 +1820,25 @@ mod tests {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_target_writes_notify_peer_metadata_caches() {
|
||||
let source = include_str!("replication.rs");
|
||||
for (start, end) in [
|
||||
("impl Operation for SetRemoteTargetHandler", "pub struct ListRemoteTargetHandler"),
|
||||
("impl Operation for RemoveRemoteTargetHandler", "async fn cancel_active_resync_intent"),
|
||||
] {
|
||||
let body = source
|
||||
.split(start)
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split(end).next())
|
||||
.expect(start);
|
||||
assert!(
|
||||
body.contains("notify_remote_target_metadata_reload"),
|
||||
"{start} must notify every node before returning success"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_ops_parse_minio_query_contract() {
|
||||
let ops = parse_remote_target_update_ops(&query_map(&[
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1318,6 +1318,24 @@ impl Operation for ImportIam {
|
||||
failed,
|
||||
};
|
||||
|
||||
// The entities are already imported locally. A snapshot that cannot be
|
||||
// scheduled is a convergence delay the reconcile pass still closes, so
|
||||
// it must not turn a completed import into a failed request - the same
|
||||
// best-effort contract every other site-replication hook here follows.
|
||||
if let Err(err) =
|
||||
crate::site_replication::enqueue_site_replication_iam_snapshot("iam import scheduled a full snapshot").await
|
||||
{
|
||||
warn!(
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_USER,
|
||||
event = EVENT_ADMIN_USER_STATE,
|
||||
action = "import_iam",
|
||||
result = "site_replication_snapshot_not_scheduled",
|
||||
error = ?err,
|
||||
"admin user state"
|
||||
);
|
||||
}
|
||||
|
||||
let body = serde_json::to_vec(&ret).map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, e.to_string()))?;
|
||||
|
||||
let mut header = HeaderMap::new();
|
||||
@@ -1424,6 +1442,16 @@ mod tests {
|
||||
assert!(include_str!("user.rs").contains(mapper_call));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_iam_enqueues_a_site_replication_snapshot() {
|
||||
let body = source_block(include_str!("user.rs"), "impl Operation for ImportIam");
|
||||
|
||||
assert!(
|
||||
body.contains("enqueue_site_replication_iam_snapshot"),
|
||||
"a successful IAM import must schedule a full IAM snapshot for every remote site"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_check_deny_only_for_regular_self_request() {
|
||||
let cred = Credentials {
|
||||
|
||||
@@ -409,6 +409,26 @@ fn transfer_summaries(stats: &InternalReplicationStats) -> (XferSummaryWire, Tar
|
||||
(summary, per_target)
|
||||
}
|
||||
|
||||
/// Node-level failure counters for `errors`. The sibling `retries` field
|
||||
/// stays zero on purpose: it means redeliveries in the minio-go shape, and a
|
||||
/// failed object is not retried by an event today (it waits for the scanner's
|
||||
/// heal pass), so reporting failures there would claim a redelivery that
|
||||
/// never happened.
|
||||
fn failure_counters(stats: &InternalReplicationStats) -> CounterSummaryWire {
|
||||
let (total, last1m, last1hr) = stats.stats.values().fold((0i64, 0i64, 0i64), |acc, stat| {
|
||||
(
|
||||
acc.0.saturating_add(stat.fail_stats.count),
|
||||
acc.1.saturating_add(stat.fail_stats.last_minute.count),
|
||||
acc.2.saturating_add(stat.fail_stats.last_hour.count),
|
||||
)
|
||||
});
|
||||
CounterSummaryWire {
|
||||
total: u64::try_from(total.max(0)).unwrap_or_default(),
|
||||
last1m: u64::try_from(last1m.max(0)).unwrap_or_default(),
|
||||
last1hr: u64::try_from(last1hr.max(0)).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
impl MetricsV2Wire {
|
||||
/// Project the aggregated internal stats onto the `MetricsV2` shape.
|
||||
///
|
||||
@@ -418,6 +438,7 @@ impl MetricsV2Wire {
|
||||
/// `queueStats.nodes` and treats an empty list as "no data".
|
||||
pub(crate) fn from_stats(bucket_stats: &BucketStats, node_name: &str) -> Self {
|
||||
let (xfer_stats, tgt_xfer_stats) = transfer_summaries(&bucket_stats.replication_stats);
|
||||
let failed = failure_counters(&bucket_stats.replication_stats);
|
||||
let mut nodes: Vec<ReplQNodeStatsWire> = bucket_stats
|
||||
.queue_stats
|
||||
.nodes
|
||||
@@ -436,6 +457,7 @@ impl MetricsV2Wire {
|
||||
q_stats: InQueueMetricWire::from(&bucket_stats.replication_stats.q_stat),
|
||||
xfer_stats: xfer_stats.clone(),
|
||||
tgt_xfer_stats: tgt_xfer_stats.clone(),
|
||||
errors: failed,
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
@@ -444,6 +466,7 @@ impl MetricsV2Wire {
|
||||
if let Some(first) = nodes.first_mut() {
|
||||
first.xfer_stats = xfer_stats.clone();
|
||||
first.tgt_xfer_stats = tgt_xfer_stats.clone();
|
||||
first.errors = failed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +501,12 @@ mod tests {
|
||||
target.replicated_size = 4096;
|
||||
target.failed.count = 3;
|
||||
target.failed.size = 900;
|
||||
target.fail_stats.count = 3;
|
||||
target.fail_stats.size = 900;
|
||||
target.fail_stats.last_minute.count = 2;
|
||||
target.fail_stats.last_minute.size = 600;
|
||||
target.fail_stats.last_hour.count = 3;
|
||||
target.fail_stats.last_hour.size = 900;
|
||||
target.bandwidth_limit_bytes_per_sec = 1024;
|
||||
target.current_bandwidth_bytes_per_sec = 512.5;
|
||||
stats
|
||||
@@ -537,6 +566,10 @@ mod tests {
|
||||
assert_eq!(node["queueStats"]["peak"], node["queueStats"]["max"]);
|
||||
assert!(node["activeWorkers"].get("curr").is_some());
|
||||
assert!(node["transferSummary"].get("Total").is_some());
|
||||
assert_eq!(node["errors"]["total"], 3);
|
||||
assert_eq!(node["errors"]["last1m"], 2);
|
||||
assert_eq!(node["errors"]["last1hr"], 3);
|
||||
assert_eq!(node["retries"]["total"], 0, "failures are not redeliveries; retries must not claim one");
|
||||
assert_eq!(json["downtimeInfo"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ pub(crate) mod ecstore_cluster {
|
||||
};
|
||||
}
|
||||
|
||||
/// Deployment topology. The KMS configure path needs it to tell an operator
|
||||
/// that a node-local backend cannot serve a multi-node deployment.
|
||||
pub(crate) mod ecstore_topology {
|
||||
pub(crate) use crate::storage::storage_api::is_dist_erasure;
|
||||
}
|
||||
|
||||
mod ecstore_config {
|
||||
pub(crate) use crate::storage::storage_api::ecstore_config::{com, init, storageclass};
|
||||
}
|
||||
|
||||
@@ -1389,7 +1389,10 @@ impl DefaultBucketUsecase {
|
||||
counter!("rustfs_create_bucket_total").increment(1);
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1781,7 +1784,10 @@ impl DefaultBucketUsecase {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket tagging delete hook failed");
|
||||
}
|
||||
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
Ok(S3Response::new(DeleteBucketTaggingOutput {}))
|
||||
}
|
||||
|
||||
@@ -2698,7 +2704,10 @@ impl DefaultBucketUsecase {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket tagging hook failed");
|
||||
}
|
||||
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
Ok(S3Response::new(PutBucketTaggingOutput::default()))
|
||||
}
|
||||
|
||||
@@ -2733,7 +2742,10 @@ impl DefaultBucketUsecase {
|
||||
warn!(bucket = %bucket, error = ?err, "site replication bucket versioning hook failed");
|
||||
}
|
||||
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
Ok(S3Response::new(PutBucketVersioningOutput {}))
|
||||
}
|
||||
|
||||
|
||||
@@ -532,7 +532,11 @@ impl DefaultMultipartUsecase {
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
rustfs_scanner::record_dirty_usage_object(&bucket, &key);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&key,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::AbortMultipartUpload,
|
||||
);
|
||||
Ok(S3Response::new(AbortMultipartUploadOutput { ..Default::default() }))
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -802,7 +806,11 @@ impl DefaultMultipartUsecase {
|
||||
schedule_object_replication(obj_info.clone(), store, completion_replication_decision).await;
|
||||
}
|
||||
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&key,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::CompleteMultipartUpload,
|
||||
);
|
||||
Ok::<_, S3Error>(obj_info)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -797,6 +797,18 @@ impl DefaultObjectUsecase {
|
||||
let resp_elements =
|
||||
build_event_resp_elements(&S3Response::new(DeleteObjectsOutput::default()), &request_context.request_id);
|
||||
let deleted_any = delete_results.iter().any(|result| result.delete_object.is_some());
|
||||
let delete_producers = delete_results
|
||||
.iter()
|
||||
.filter_map(|result| {
|
||||
result.delete_object.as_ref().map(|deleted_object| {
|
||||
if deleted_object.delete_marker && result.requested_version_id.is_none() {
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::DeleteMarker
|
||||
} else {
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::DeleteObject
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let notify_bucket = bucket.clone();
|
||||
spawn_background_with_context(Some(request_context), async move {
|
||||
let _activity_guard = DeleteTailActivityGuard::new(DeleteTailStage::Notify);
|
||||
@@ -838,7 +850,7 @@ impl DefaultObjectUsecase {
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
if deleted_any {
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producers(&bucket, delete_producers);
|
||||
}
|
||||
// Record write operation for capacity management (inline to avoid per-request tokio::spawn overhead)
|
||||
let manager = get_capacity_manager();
|
||||
@@ -1101,7 +1113,10 @@ impl DefaultObjectUsecase {
|
||||
let manager = get_capacity_manager();
|
||||
manager.record_write_operation().await;
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::DeleteObject,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -627,6 +627,7 @@ struct ExtractPreparedMember<R = ExtractStagedBody> {
|
||||
write_plan: WritePlan,
|
||||
opts: ObjectOptions,
|
||||
replication: ReplicateDecision,
|
||||
producer_identity: rustfs_scanner::SegmentInvalidationProducerIdentity,
|
||||
staging_permit: OwnedSemaphorePermit,
|
||||
member_permit: OwnedSemaphorePermit,
|
||||
}
|
||||
@@ -765,6 +766,7 @@ where
|
||||
write_plan,
|
||||
opts,
|
||||
replication,
|
||||
producer_identity,
|
||||
staging_permit,
|
||||
member_permit,
|
||||
} = member;
|
||||
@@ -793,7 +795,7 @@ where
|
||||
// the scanner before its post-store awaits, then retains the lifecycle slot
|
||||
// through quota, cache, replication, and event construction.
|
||||
if !context.wrote_any_entry.swap(true, Ordering::AcqRel) {
|
||||
rustfs_scanner::record_dirty_usage_bucket(&context.bucket);
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(&context.bucket, producer_identity);
|
||||
}
|
||||
let success =
|
||||
complete_extract_member_post_commit(context, key, opts, replication, obj_info, backfilled_old_current_size).await;
|
||||
@@ -2042,7 +2044,7 @@ impl DefaultObjectUsecase {
|
||||
bucket_sse_config.as_ref().map(|(config, _timestamp)| config),
|
||||
original_sse,
|
||||
ssekms_key_id,
|
||||
false,
|
||||
sse_customer_algorithm.is_some() || sse_customer_key.is_some() || sse_customer_key_md5.is_some(),
|
||||
);
|
||||
if effective_sse
|
||||
.as_ref()
|
||||
@@ -2603,6 +2605,7 @@ impl DefaultObjectUsecase {
|
||||
write_plan,
|
||||
opts,
|
||||
replication,
|
||||
producer_identity: rustfs_scanner::SegmentInvalidationProducerIdentity::DirectoryObject,
|
||||
staging_permit,
|
||||
member_permit,
|
||||
},
|
||||
@@ -2627,6 +2630,7 @@ impl DefaultObjectUsecase {
|
||||
write_plan,
|
||||
opts,
|
||||
replication,
|
||||
producer_identity: rustfs_scanner::SegmentInvalidationProducerIdentity::PutObject,
|
||||
staging_permit,
|
||||
member_permit,
|
||||
},
|
||||
@@ -2656,6 +2660,11 @@ impl DefaultObjectUsecase {
|
||||
write_plan,
|
||||
opts,
|
||||
replication,
|
||||
producer_identity: if is_dir {
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::DirectoryObject
|
||||
} else {
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::PutObject
|
||||
},
|
||||
staging_permit,
|
||||
member_permit,
|
||||
});
|
||||
|
||||
@@ -733,7 +733,11 @@ impl DefaultObjectUsecase {
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
rustfs_scanner::record_dirty_usage_bucket(bucket);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
bucket,
|
||||
key,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::AbortMultipartUpload,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1515,7 +1515,7 @@ impl DefaultObjectUsecase {
|
||||
bucket_sse_config.as_ref().map(|(config, _timestamp)| config),
|
||||
server_side_encryption,
|
||||
ssekms_key_id,
|
||||
false,
|
||||
sse_customer_algorithm.is_some() || sse_customer_key.is_some() || sse_customer_key_md5.is_some(),
|
||||
);
|
||||
debug!(
|
||||
target: "rustfs::app::object_usecase",
|
||||
@@ -3524,6 +3524,104 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn install_bucket_default_sse_for_test(bucket: &str, algorithm: &'static str, kms_key_id: Option<&str>) {
|
||||
use crate::app::storage_api::test::bucket::utils::serialize;
|
||||
use crate::app::storage_api::test::{get_global_bucket_metadata_sys, set_bucket_metadata};
|
||||
|
||||
let sys = get_global_bucket_metadata_sys().expect("bucket metadata system");
|
||||
let metadata = {
|
||||
let sys = sys.read().await;
|
||||
sys.get(bucket).await.expect("bucket metadata cached")
|
||||
};
|
||||
let mut metadata = (*metadata).clone();
|
||||
let config = ServerSideEncryptionConfiguration {
|
||||
rules: vec![ServerSideEncryptionRule {
|
||||
apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
|
||||
sse_algorithm: ServerSideEncryption::from_static(algorithm),
|
||||
kms_master_key_id: kms_key_id.map(|id| id.to_string()),
|
||||
}),
|
||||
blocked_encryption_types: None,
|
||||
bucket_key_enabled: None,
|
||||
}],
|
||||
};
|
||||
metadata.encryption_config_xml = serialize(&config).expect("sse config serializes");
|
||||
metadata.sse_config = Some(config);
|
||||
set_bucket_metadata(bucket.to_string(), metadata)
|
||||
.await
|
||||
.expect("install bucket default SSE");
|
||||
}
|
||||
|
||||
/// backlog#2368 B1: an SSE-C request suppresses the bucket default, the way
|
||||
/// COPY already did. PUT passed a hard-coded `has_explicit_ssec = false`,
|
||||
/// so the default filled in a managed algorithm and the request then failed
|
||||
/// its own mutual-exclusion check — every bucket with default encryption
|
||||
/// refused SSE-C outright.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn sse_c_put_is_accepted_on_a_bucket_with_default_encryption() {
|
||||
use md5::{Digest as _, Md5};
|
||||
|
||||
for (algorithm, kms_key_id, prefix) in [
|
||||
(ServerSideEncryption::AES256, None, "ssec-over-aes256-default"),
|
||||
(ServerSideEncryption::AWS_KMS, Some("bucket-key"), "ssec-over-kms-default"),
|
||||
] {
|
||||
let (store, bucket) = crate::app::gating_test_env::durable_quota_test_bucket(prefix, 1 << 20).await;
|
||||
install_bucket_default_sse_for_test(&bucket, algorithm, kms_key_id).await;
|
||||
|
||||
let customer_key = [0x2a_u8; 32];
|
||||
let key_md5 = {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(customer_key);
|
||||
base64_simd::STANDARD.encode_to_string(hasher.finalize())
|
||||
};
|
||||
let payload = Bytes::from_static(b"customer-key protected payload");
|
||||
let input = PutObjectInput::builder()
|
||||
.bucket(bucket.clone())
|
||||
.key("ledger.csv".to_string())
|
||||
.body(Some(StreamingBlob::from(s3s::Body::from(payload.clone()))))
|
||||
.content_length(Some(i64::try_from(payload.len()).expect("test payload length must fit i64")))
|
||||
.sse_customer_algorithm(Some("AES256".to_string()))
|
||||
.sse_customer_key(Some(base64_simd::STANDARD.encode_to_string(customer_key)))
|
||||
.sse_customer_key_md5(Some(key_md5))
|
||||
.build()
|
||||
.expect("SSE-C PUT input must build");
|
||||
|
||||
DefaultObjectUsecase::from_global()
|
||||
.execute_put_object(&FS::new(), build_request(input, Method::PUT))
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("a {algorithm} default bucket must accept an SSE-C PUT: {err:?}"));
|
||||
|
||||
let stored = store
|
||||
.get_object_info(&bucket, "ledger.csv", &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the SSE-C object should be readable");
|
||||
assert!(
|
||||
stored
|
||||
.user_defined
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case("x-amz-server-side-encryption-customer-algorithm")),
|
||||
"the object must be stored as SSE-C: {:?}",
|
||||
stored.user_defined
|
||||
);
|
||||
assert!(
|
||||
!stored
|
||||
.user_defined
|
||||
.keys()
|
||||
.any(|key| key.eq_ignore_ascii_case("x-amz-server-side-encryption-aws-kms-key-id")),
|
||||
"the bucket default must not attach a KMS key to an SSE-C object: {:?}",
|
||||
stored.user_defined
|
||||
);
|
||||
assert!(
|
||||
!stored
|
||||
.user_defined
|
||||
.values()
|
||||
.any(|value| value == ServerSideEncryption::AWS_KMS),
|
||||
"the bucket default must not claim managed encryption on an SSE-C object: {:?}",
|
||||
stored.user_defined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn quota_rejects_ciphertext_replication_before_polling_the_body() {
|
||||
|
||||
@@ -420,7 +420,11 @@ impl DefaultObjectUsecase {
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
rustfs_scanner::record_dirty_usage_object(&bucket, &object);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&object,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::TierTransition,
|
||||
);
|
||||
#[cfg(test)]
|
||||
maybe_pause_after_restore_status_commit(&bucket, &object).await;
|
||||
drop(superseded_worker_guard.take());
|
||||
@@ -494,7 +498,11 @@ impl DefaultObjectUsecase {
|
||||
err.to_string()
|
||||
);
|
||||
} else {
|
||||
rustfs_scanner::record_dirty_usage_object(&bucket_clone, &object_clone);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket_clone,
|
||||
&object_clone,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::TierTransition,
|
||||
);
|
||||
debug!(bucket = %bucket_clone, object = %object_clone, "Transitioned object restored");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -240,10 +240,8 @@ pub(super) fn has_put_sse_request_headers(headers: &HeaderMap) -> bool {
|
||||
/// A request-level value always wins; the bucket default only fills a gap, and
|
||||
/// the unknown-algorithm fallback lives once in [`bucket_default_write_sse`].
|
||||
///
|
||||
/// `has_explicit_ssec` suppresses the default entirely. Only COPY passes `true`
|
||||
/// today: its destination may carry SSE-C, which must not also be given managed
|
||||
/// encryption. PUT and extract pass `false`, matching their current behaviour —
|
||||
/// see backlog#1826 for the divergence that leaves.
|
||||
/// `has_explicit_ssec` suppresses the default entirely: an SSE-C destination
|
||||
/// must not also be given managed encryption.
|
||||
///
|
||||
/// Callers layering further overrides (PUT's `ciphertext_passthrough`) apply
|
||||
/// them to the returned pair.
|
||||
@@ -263,7 +261,14 @@ pub(super) fn resolve_bucket_default_sse(
|
||||
};
|
||||
|
||||
let effective_sse = requested_sse.or_else(|| bucket_default().map(bucket_default_write_sse));
|
||||
let effective_kms_key_id = requested_kms_key_id.or_else(|| bucket_default().and_then(|sse| sse.kms_master_key_id.clone()));
|
||||
let effective_kms_key_id = if effective_sse
|
||||
.as_ref()
|
||||
.is_some_and(|sse| sse.as_str() == ServerSideEncryption::AWS_KMS)
|
||||
{
|
||||
requested_kms_key_id.or_else(|| bucket_default().and_then(|sse| sse.kms_master_key_id.clone()))
|
||||
} else {
|
||||
requested_kms_key_id
|
||||
};
|
||||
(effective_sse, effective_kms_key_id)
|
||||
}
|
||||
|
||||
@@ -1187,6 +1192,21 @@ mod tests {
|
||||
assert_eq!(kms_key_id.as_deref(), Some("request-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_bucket_default_sse_does_not_inherit_a_kms_key_for_an_explicit_sse_s3_request() {
|
||||
let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key"));
|
||||
|
||||
let (sse, kms_key_id) = resolve_bucket_default_sse(
|
||||
Some(&config),
|
||||
Some(ServerSideEncryption::from_static(ServerSideEncryption::AES256)),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(sse.as_ref().map(|sse| sse.as_str()), Some(ServerSideEncryption::AES256));
|
||||
assert!(kms_key_id.is_none(), "an SSE-S3 request must not inherit the bucket KMS key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_bucket_default_sse_fills_gaps_from_the_bucket_default() {
|
||||
let config = bucket_sse_config_with(ServerSideEncryption::AWS_KMS, Some("bucket-key"));
|
||||
|
||||
+161
-13
@@ -24,6 +24,77 @@ const MAX_VERSIONS_EXCEEDED_MESSAGE: &str = "You've exceeded the limit on the nu
|
||||
/// S3 error code for a request that names a KMS key the KMS does not hold.
|
||||
pub const KMS_KEY_NOT_FOUND_ERROR_CODE: &str = "KMS.NotFoundException";
|
||||
|
||||
/// Map a KMS failure that surfaced on the S3 data path to its S3 error code.
|
||||
///
|
||||
/// The contract deliberately differs from the admin lifecycle handlers
|
||||
/// (`kms_key_lifecycle::lifecycle_error_status`): there a key id is the
|
||||
/// resource being addressed, so a missing key is `404`. Here the key id
|
||||
/// arrives inside a request header or a bucket default, so a key that is
|
||||
/// missing, disabled, or otherwise unusable is configuration the caller has to
|
||||
/// correct — AWS answers `400`, and reporting `500` instead both misfiles the
|
||||
/// failure as a server fault and makes SDKs back off and retry a request that
|
||||
/// cannot succeed.
|
||||
///
|
||||
/// `None` keeps the caller's fallthrough, which is the `500` that integrity
|
||||
/// faults — damaged, unreadable, or unknown-format key material — must keep.
|
||||
///
|
||||
/// Messages either name what the caller asked for or stay generic; detail that
|
||||
/// belongs to the deployment rather than the request stays in `source`, which
|
||||
/// the caller attaches and the logs retain.
|
||||
fn data_plane_kms_error(error: &rustfs_kms::KmsError) -> Option<(S3ErrorCode, String)> {
|
||||
use rustfs_kms::KmsError as Kms;
|
||||
|
||||
let generic = |code: S3ErrorCode| {
|
||||
let message = ApiError::error_code_to_message(&code);
|
||||
Some((code, message))
|
||||
};
|
||||
|
||||
match error {
|
||||
Kms::KeyNotFound { key_id } => Some((
|
||||
S3ErrorCode::Custom(KMS_KEY_NOT_FOUND_ERROR_CODE.into()),
|
||||
format!("KMS key not found: {key_id}"),
|
||||
)),
|
||||
Kms::KeyVersionNotFound { key_id, version } => Some((
|
||||
S3ErrorCode::Custom(KMS_KEY_NOT_FOUND_ERROR_CODE.into()),
|
||||
format!("KMS key version {version} not found for key {key_id}"),
|
||||
)),
|
||||
// The key exists but its state forbids the operation (disabled,
|
||||
// pending deletion). AWS treats an invalid key state as a request
|
||||
// error, not a server fault.
|
||||
Kms::InvalidOperation { .. } => Some((S3ErrorCode::InvalidRequest, error.to_string())),
|
||||
// A policy decision needs a human, so it must be distinguishable from
|
||||
// the transient classes an SDK retries.
|
||||
Kms::AccessDenied { .. } => generic(S3ErrorCode::AccessDenied),
|
||||
// Request-side faults: what was asked for cannot be served as asked.
|
||||
Kms::ContextMismatch { .. }
|
||||
| Kms::InvalidKey { .. }
|
||||
| Kms::ValidationError { .. }
|
||||
| Kms::UnsupportedAlgorithm { .. }
|
||||
| Kms::InvalidKeySize { .. } => Some((S3ErrorCode::InvalidRequest, error.to_string())),
|
||||
// A deployment whose KMS configuration cannot serve the request (for
|
||||
// example no key named and no default). Actionable, but the detail
|
||||
// describes the deployment, so it stays out of the response body.
|
||||
Kms::ConfigurationError { .. } => {
|
||||
Some((S3ErrorCode::InvalidRequest, "The KMS configuration cannot serve this request".to_string()))
|
||||
}
|
||||
// Transient: worth retrying, and must be counted against availability
|
||||
// rather than against the caller. `IoError` belongs here because it is
|
||||
// how a backend reports that its key store itself was unreachable —
|
||||
// rustfs/rustfs#7470 separated that from a missing key precisely so the
|
||||
// two stop looking alike, and leaving it on the 500 fallthrough would
|
||||
// erase that distinction again at the S3 boundary.
|
||||
Kms::BackendError { .. }
|
||||
| Kms::IoError { .. }
|
||||
| Kms::OperationTimedOut { .. }
|
||||
| Kms::OperationCancelled { .. }
|
||||
| Kms::CredentialsUnavailable { .. }
|
||||
| Kms::CacheError { .. } => generic(S3ErrorCode::ServiceUnavailable),
|
||||
// A permanent gap in the configured backend, never a missing resource.
|
||||
Kms::UnsupportedCapability { .. } => Some((S3ErrorCode::NotImplemented, error.to_string())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP status of the error codes s3s cannot derive on its own.
|
||||
///
|
||||
/// s3s answers `None` for every `Custom` code, which the response layer turns
|
||||
@@ -472,12 +543,7 @@ impl From<StorageError> for ApiError {
|
||||
};
|
||||
}
|
||||
|
||||
if inner.downcast_ref::<KmsUnavailableError>().is_some()
|
||||
|| matches!(
|
||||
inner.downcast_ref::<rustfs_kms::KmsError>(),
|
||||
Some(rustfs_kms::KmsError::BackendError { .. })
|
||||
)
|
||||
{
|
||||
if inner.downcast_ref::<KmsUnavailableError>().is_some() {
|
||||
return ApiError {
|
||||
code: S3ErrorCode::ServiceUnavailable,
|
||||
message: ApiError::error_code_to_message(&S3ErrorCode::ServiceUnavailable),
|
||||
@@ -485,14 +551,11 @@ impl From<StorageError> for ApiError {
|
||||
};
|
||||
}
|
||||
|
||||
// A request header or bucket default naming a key the KMS does not
|
||||
// hold is the caller's mistake to correct, and S3 reports it as
|
||||
// 400 `KMS.NotFoundException`. Left to the fallthrough it became a
|
||||
// 500 whose generic message hid which key was missing.
|
||||
if let Some(rustfs_kms::KmsError::KeyNotFound { key_id }) = inner.downcast_ref::<rustfs_kms::KmsError>() {
|
||||
let message = format!("KMS key not found: {key_id}");
|
||||
if let Some(kms_error) = inner.downcast_ref::<rustfs_kms::KmsError>()
|
||||
&& let Some((code, message)) = data_plane_kms_error(kms_error)
|
||||
{
|
||||
return ApiError {
|
||||
code: S3ErrorCode::Custom(KMS_KEY_NOT_FOUND_ERROR_CODE.into()),
|
||||
code,
|
||||
message,
|
||||
source: Some(Box::new(err)),
|
||||
};
|
||||
@@ -1060,6 +1123,91 @@ mod tests {
|
||||
assert_eq!(s3_error.status_code(), Some(StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
/// backlog#2368 B6: every KMS failure class that is not an integrity fault
|
||||
/// carries a status that says whether retrying, fixing the request, or
|
||||
/// calling a human is the right response. Collapsing them onto 500 made
|
||||
/// SDKs back off on unfixable configuration errors and filed every one of
|
||||
/// them as a server fault.
|
||||
#[test]
|
||||
fn kms_data_plane_errors_are_classified_by_what_the_caller_should_do() {
|
||||
let cases: Vec<(rustfs_kms::KmsError, S3ErrorCode)> = vec![
|
||||
// Names something the KMS does not hold.
|
||||
(
|
||||
rustfs_kms::KmsError::key_version_not_found("finance-key", 7),
|
||||
S3ErrorCode::Custom(KMS_KEY_NOT_FOUND_ERROR_CODE.into()),
|
||||
),
|
||||
// The key exists but its state forbids the operation.
|
||||
(rustfs_kms::KmsError::invalid_key_state("disabled"), S3ErrorCode::InvalidRequest),
|
||||
// Request-side faults.
|
||||
(rustfs_kms::KmsError::context_mismatch("bucket differs"), S3ErrorCode::InvalidRequest),
|
||||
(rustfs_kms::KmsError::invalid_key("malformed key id"), S3ErrorCode::InvalidRequest),
|
||||
(rustfs_kms::KmsError::validation_error("empty key id"), S3ErrorCode::InvalidRequest),
|
||||
(rustfs_kms::KmsError::unsupported_algorithm("aes-999"), S3ErrorCode::InvalidRequest),
|
||||
(rustfs_kms::KmsError::invalid_key_size(32, 16), S3ErrorCode::InvalidRequest),
|
||||
(
|
||||
rustfs_kms::KmsError::configuration_error("no default key configured"),
|
||||
S3ErrorCode::InvalidRequest,
|
||||
),
|
||||
// A policy decision that needs a human, never retried by an SDK.
|
||||
(rustfs_kms::KmsError::access_denied("no kms:Decrypt grant"), S3ErrorCode::AccessDenied),
|
||||
// Transient, worth retrying, counted against availability.
|
||||
(rustfs_kms::KmsError::backend_error("vault refused"), S3ErrorCode::ServiceUnavailable),
|
||||
(
|
||||
rustfs_kms::KmsError::operation_timed_out("attempt deadline"),
|
||||
S3ErrorCode::ServiceUnavailable,
|
||||
),
|
||||
(
|
||||
rustfs_kms::KmsError::operation_cancelled("shutting down"),
|
||||
S3ErrorCode::ServiceUnavailable,
|
||||
),
|
||||
(
|
||||
rustfs_kms::KmsError::credentials_unavailable("approle login failed"),
|
||||
S3ErrorCode::ServiceUnavailable,
|
||||
),
|
||||
(rustfs_kms::KmsError::cache_error("poisoned"), S3ErrorCode::ServiceUnavailable),
|
||||
// A key store that cannot be read is an outage, not a missing key:
|
||||
// rustfs/rustfs#7470 made the backend say so, and the S3 boundary
|
||||
// has to keep the two apart.
|
||||
(
|
||||
rustfs_kms::KmsError::io_error("No such file or directory (os error 2)"),
|
||||
S3ErrorCode::ServiceUnavailable,
|
||||
),
|
||||
// A permanent gap in the configured backend, never a missing resource.
|
||||
(
|
||||
rustfs_kms::KmsError::unsupported_capability("local", "rewrap"),
|
||||
S3ErrorCode::NotImplemented,
|
||||
),
|
||||
];
|
||||
|
||||
for (error, expected) in cases {
|
||||
let description = error.to_string();
|
||||
let api_error = ApiError::from(StorageError::other(error));
|
||||
assert_eq!(api_error.code, expected, "wrong code for: {description}");
|
||||
assert_ne!(api_error.code, S3ErrorCode::InternalError, "must not be a server fault: {description}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A deployment-side configuration message describes the server, not the
|
||||
/// request, so it stays in `source` the way the storage-IO mapping does.
|
||||
#[test]
|
||||
fn kms_configuration_errors_do_not_echo_deployment_detail() {
|
||||
let detail = "vault mount /secret/rustfs-prod has no default key";
|
||||
let api_error = ApiError::from(StorageError::other(rustfs_kms::KmsError::configuration_error(detail)));
|
||||
|
||||
assert_eq!(api_error.code, S3ErrorCode::InvalidRequest);
|
||||
assert!(
|
||||
!api_error.message.contains(detail),
|
||||
"message leaked deployment detail: {}",
|
||||
api_error.message
|
||||
);
|
||||
let source = api_error
|
||||
.source
|
||||
.as_deref()
|
||||
.and_then(|source| source.downcast_ref::<StorageError>())
|
||||
.expect("API error should retain the storage error source");
|
||||
assert!(source.to_string().contains(detail), "the detail must survive on the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_error_codes_keep_their_own_status() {
|
||||
let s3_error = S3Error::from(ApiError::from(StorageError::other(rustfs_kms::KmsError::backend_error("down"))));
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::server::{
|
||||
StsQueryApiCompatLayer, VirtualHostStyleHintLayer, redact_sensitive_uri_query,
|
||||
},
|
||||
rate_limit::{RateLimitLayer, api_rate_limit_layer_from_env},
|
||||
ssec_transport::SsecTransportLayer,
|
||||
strip_valid_port_suffix,
|
||||
tls_material::{
|
||||
TlsAcceptFailure, TlsAcceptorHolder, TlsHandshakeFailureKind, accept_tls_with_deadline, build_acceptor_from_loaded,
|
||||
@@ -1846,6 +1847,11 @@ fn process_connection(
|
||||
request_body_idle_timeout,
|
||||
} = context;
|
||||
|
||||
// Whether this listener terminated TLS for this connection; the SSE-C
|
||||
// transport policy needs the connection's own answer, not a
|
||||
// deployment-wide setting.
|
||||
let connection_is_tls = tls_acceptor.is_some();
|
||||
|
||||
// Build the hybrid service per-connection.
|
||||
// Note: NodeService is not Clone (holds LocalPeerS3Client), and the SwiftService
|
||||
// type is feature-gated, so we cannot pre-build the full hybrid service.
|
||||
@@ -1964,6 +1970,14 @@ fn process_connection(
|
||||
// a spoof-proof client IP. Absent (None) unless enabled via
|
||||
// RUSTFS_API_RATE_LIMIT_ENABLE with a non-zero RPM.
|
||||
.option_layer(rate_limit_layer.clone())
|
||||
// backlog#2369 P7.2: an SSE-C request carries the customer key
|
||||
// in a header, so a plaintext hop leaks it permanently. Sits
|
||||
// beside the rate limiter: after the trusted-proxy layer, which
|
||||
// is what makes a forwarded `https` protocol trustworthy, and
|
||||
// after the request context so a rejection can echo the request
|
||||
// id. Reports by default; refuses only under
|
||||
// RUSTFS_SSE_C_REQUIRE_TLS.
|
||||
.layer(SsecTransportLayer::new(connection_is_tls))
|
||||
// CRITICAL: Insert ReadinessGateLayer before business logic
|
||||
// This stops requests from hitting IAMAuth or Storage if they are not ready.
|
||||
.layer(ReadinessGateLayer::new(readiness.clone()))
|
||||
|
||||
@@ -27,6 +27,7 @@ mod readiness;
|
||||
mod runtime;
|
||||
pub(crate) mod runtime_sources;
|
||||
mod service_state;
|
||||
mod ssec_transport;
|
||||
pub mod tls_material;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// 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.
|
||||
|
||||
//! SSE-C transport policy (backlog#2369 P7.2).
|
||||
//!
|
||||
//! An SSE-C request carries the customer's AES key in a request header, so AWS
|
||||
//! S3 and MinIO both refuse one that did not arrive over TLS. RustFS accepted
|
||||
//! them on any transport, which means a plaintext hop hands the key to anyone
|
||||
//! on the path — and the object is then unreadable without that same key, so
|
||||
//! the exposure is permanent for as long as the object lives.
|
||||
//!
|
||||
//! Refusing outright is the correct end state but not a safe default to adopt
|
||||
//! inside a release window: the project's own s3-tests and e2e lanes, and most
|
||||
//! staging deployments, speak plain HTTP. This release therefore reports:
|
||||
//! every SSE-C request on a plaintext transport increments
|
||||
//! `rustfs_ssec_plaintext_requests_total` and logs one warning per process, so
|
||||
//! an operator can see whether anything would break before the default flips.
|
||||
//! `RUSTFS_SSE_C_REQUIRE_TLS=true` opts a deployment into the rejection now.
|
||||
//!
|
||||
//! The transport verdict is per connection, not per deployment: the layer is
|
||||
//! built with whether *this* listener terminates TLS, and additionally accepts
|
||||
//! a `https` forwarded protocol resolved by the trusted-proxy layer, which is
|
||||
//! the only spoof-resistant source for a TLS-terminating proxy in front.
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::future::{Either, Ready, ready};
|
||||
use http::{HeaderMap, HeaderValue, Request, Response, StatusCode};
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use metrics::counter;
|
||||
use rustfs_trusted_proxies::ClientInfo;
|
||||
use rustfs_utils::http::headers::{
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY,
|
||||
AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5,
|
||||
};
|
||||
use std::sync::Once;
|
||||
use std::task::{Context, Poll};
|
||||
use tower::{Layer, Service};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::storage_api::server::layer::request_context::RequestContext;
|
||||
|
||||
/// Opt in to refusing SSE-C on a plaintext transport. Default `false` for this
|
||||
/// release; the reporting path runs either way.
|
||||
pub(crate) const ENV_SSE_C_REQUIRE_TLS: &str = "RUSTFS_SSE_C_REQUIRE_TLS";
|
||||
pub(crate) const DEFAULT_SSE_C_REQUIRE_TLS: bool = false;
|
||||
|
||||
/// Counts SSE-C requests that arrived without TLS. A deployment planning to
|
||||
/// enable [`ENV_SSE_C_REQUIRE_TLS`] should see this at zero first.
|
||||
pub(crate) const METRIC_SSEC_PLAINTEXT_REQUESTS_TOTAL: &str = "rustfs_ssec_plaintext_requests_total";
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
type BoxBody = http_body_util::combinators::UnsyncBoxBody<Bytes, BoxError>;
|
||||
|
||||
/// Whether the request carries any SSE-C header.
|
||||
///
|
||||
/// Any one of the three is enough: an incomplete triple is still an attempt to
|
||||
/// use SSE-C, and it is rejected later for being incomplete — but the key may
|
||||
/// already have crossed the wire.
|
||||
fn carries_ssec_headers(headers: &HeaderMap) -> bool {
|
||||
headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM)
|
||||
|| headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY)
|
||||
|| headers.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5)
|
||||
}
|
||||
|
||||
/// Whether this request reached the server over TLS.
|
||||
///
|
||||
/// `connection_is_tls` is what this listener actually did. The forwarded
|
||||
/// protocol is only consulted as a second source because the trusted-proxy
|
||||
/// layer has already decided whether the peer is allowed to assert it; a
|
||||
/// request that arrives direct carries no such assertion.
|
||||
fn is_secure_transport(connection_is_tls: bool, client_info: Option<&ClientInfo>) -> bool {
|
||||
if connection_is_tls {
|
||||
return true;
|
||||
}
|
||||
client_info
|
||||
.and_then(|info| info.forwarded_proto.as_deref())
|
||||
.is_some_and(|proto| proto.eq_ignore_ascii_case("https"))
|
||||
}
|
||||
|
||||
fn require_tls() -> bool {
|
||||
rustfs_utils::get_env_bool(ENV_SSE_C_REQUIRE_TLS, DEFAULT_SSE_C_REQUIRE_TLS)
|
||||
}
|
||||
|
||||
/// One warning per process: plaintext SSE-C traffic is driven by clients, so a
|
||||
/// per-request warning would let a busy client flood the log. The counter
|
||||
/// carries the per-request volume.
|
||||
fn warn_once_about_plaintext_ssec() {
|
||||
static WARNED: Once = Once::new();
|
||||
WARNED.call_once(|| {
|
||||
warn!(
|
||||
event = "ssec_request_without_tls",
|
||||
require_tls = ENV_SSE_C_REQUIRE_TLS,
|
||||
metric = METRIC_SSEC_PLAINTEXT_REQUESTS_TOTAL,
|
||||
"SSE-C requests are arriving without TLS, so the customer key crosses the network in \
|
||||
cleartext. AWS S3 refuses these; RustFS will too in a later release. Terminate TLS on \
|
||||
this listener or on a trusted proxy, then set RUSTFS_SSE_C_REQUIRE_TLS=true. Reported \
|
||||
once per process; the counter carries the volume."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The S3 rejection AWS returns for SSE-C without TLS. Built by hand because
|
||||
/// the rejection short-circuits the inner response stack, mirroring the
|
||||
/// rate-limit layer.
|
||||
fn ssec_requires_tls_response(request_id: Option<&str>) -> Response<BoxBody> {
|
||||
let request_id_xml = request_id
|
||||
.filter(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'))
|
||||
.map(|id| format!("<RequestId>{id}</RequestId>"))
|
||||
.unwrap_or_default();
|
||||
let body = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<Error><Code>InvalidRequest</Code>\
|
||||
<Message>Requests specifying Server Side Encryption with Customer provided keys must be made over a secure connection.</Message>\
|
||||
{request_id_xml}</Error>"
|
||||
);
|
||||
let body: BoxBody = Full::new(Bytes::from(body))
|
||||
.map_err(|e| -> BoxError { Box::new(e) })
|
||||
.boxed_unsync();
|
||||
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = StatusCode::BAD_REQUEST;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(http::header::CONTENT_TYPE, HeaderValue::from_static("application/xml"));
|
||||
response
|
||||
}
|
||||
|
||||
/// Layer that reports — and optionally refuses — SSE-C over a plaintext
|
||||
/// transport. `connection_is_tls` is whether the listener that accepted this
|
||||
/// connection terminated TLS.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SsecTransportLayer {
|
||||
connection_is_tls: bool,
|
||||
}
|
||||
|
||||
impl SsecTransportLayer {
|
||||
pub(crate) fn new(connection_is_tls: bool) -> Self {
|
||||
Self { connection_is_tls }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for SsecTransportLayer {
|
||||
type Service = SsecTransportService<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
SsecTransportService {
|
||||
inner,
|
||||
connection_is_tls: self.connection_is_tls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SsecTransportService<S> {
|
||||
inner: S,
|
||||
connection_is_tls: bool,
|
||||
}
|
||||
|
||||
impl<S, ReqBody> Service<Request<ReqBody>> for SsecTransportService<S>
|
||||
where
|
||||
S: Service<Request<ReqBody>, Response = Response<BoxBody>>,
|
||||
{
|
||||
type Response = Response<BoxBody>;
|
||||
type Error = S::Error;
|
||||
type Future = Either<S::Future, Ready<Result<Response<BoxBody>, S::Error>>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
||||
if !carries_ssec_headers(req.headers())
|
||||
|| is_secure_transport(self.connection_is_tls, req.extensions().get::<ClientInfo>())
|
||||
{
|
||||
return Either::Left(self.inner.call(req));
|
||||
}
|
||||
|
||||
counter!(METRIC_SSEC_PLAINTEXT_REQUESTS_TOTAL).increment(1);
|
||||
warn_once_about_plaintext_ssec();
|
||||
|
||||
if !require_tls() {
|
||||
return Either::Left(self.inner.call(req));
|
||||
}
|
||||
|
||||
Either::Right(ready(Ok(ssec_requires_tls_response(
|
||||
req.extensions()
|
||||
.get::<RequestContext>()
|
||||
.map(|context| context.request_id.as_str()),
|
||||
))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
fn ssec_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM, HeaderValue::from_static("AES256"));
|
||||
headers
|
||||
}
|
||||
|
||||
fn proxied(proto: &str) -> ClientInfo {
|
||||
ClientInfo::from_trusted_proxy(
|
||||
IpAddr::from([203, 0, 113, 10]),
|
||||
None,
|
||||
Some(proto.to_string()),
|
||||
IpAddr::from([10, 0, 0, 1]),
|
||||
1,
|
||||
rustfs_trusted_proxies::ValidationMode::Lenient,
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn direct() -> ClientInfo {
|
||||
ClientInfo::direct(SocketAddr::new(IpAddr::from([203, 0, 113, 10]), 443))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_ssec_header_counts_as_an_ssec_request() {
|
||||
assert!(!carries_ssec_headers(&HeaderMap::new()));
|
||||
assert!(carries_ssec_headers(&ssec_headers()));
|
||||
|
||||
// An incomplete triple is still an attempt, and the key may already
|
||||
// have crossed the wire.
|
||||
let mut only_key = HeaderMap::new();
|
||||
only_key.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY, HeaderValue::from_static("a2V5"));
|
||||
assert!(carries_ssec_headers(&only_key));
|
||||
|
||||
let mut only_md5 = HeaderMap::new();
|
||||
only_md5.insert(AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_KEY_MD5, HeaderValue::from_static("bWQ1"));
|
||||
assert!(carries_ssec_headers(&only_md5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_is_secure_only_on_tls_or_a_resolved_https_proxy() {
|
||||
assert!(is_secure_transport(true, None), "a TLS listener needs no header to prove it");
|
||||
assert!(is_secure_transport(true, Some(&proxied("http"))), "the listener's own TLS wins");
|
||||
assert!(
|
||||
is_secure_transport(false, Some(&proxied("https"))),
|
||||
"a TLS-terminating trusted proxy is a secure transport"
|
||||
);
|
||||
assert!(
|
||||
!is_secure_transport(false, Some(&proxied("http"))),
|
||||
"a proxy that forwarded plain HTTP is not"
|
||||
);
|
||||
assert!(
|
||||
!is_secure_transport(false, Some(&direct())),
|
||||
"a direct plaintext client asserts no protocol"
|
||||
);
|
||||
assert!(!is_secure_transport(false, None), "no transport evidence means not secure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rejection_carries_the_aws_wording_and_a_safe_request_id() {
|
||||
let response = ssec_requires_tls_response(Some("abc-123"));
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// A request id that is not plain enough to embed must be dropped
|
||||
// rather than escaped into the XML body.
|
||||
let injected = ssec_requires_tls_response(Some("<injected>"));
|
||||
assert_eq!(injected.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,26 @@ pub(crate) fn settle_observed_site_replication_retry_event(
|
||||
before.saturating_sub(queue.len())
|
||||
}
|
||||
|
||||
/// Make sure `peer` has a collapsed entry for `path` without counting the
|
||||
/// call as a delivery failure. A bulk local mutation (`import-iam`) needs the
|
||||
/// entry to exist so the next drain sends the snapshot; routing it through
|
||||
/// [`upsert_site_replication_retry_event`] would raise `retry_count` on every
|
||||
/// import and escalate a healthy peer to `failed` after
|
||||
/// [`SITE_REPLICATION_RETRY_FAILED_AFTER`] of them, with the scheduling note
|
||||
/// shown to operators as `lastError`.
|
||||
pub(crate) fn ensure_site_replication_retry_event(
|
||||
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||
peer: &PeerInfo,
|
||||
path: &str,
|
||||
reason: &str,
|
||||
) -> S3Result<Vec<SiteReplicationRetryEvent>> {
|
||||
let path = collapsed_retry_queue_path(path).unwrap_or(path);
|
||||
if queue.iter().any(|event| retry_event_matches(event, peer, path)) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
push_site_replication_retry_event(queue, peer, path, summarize_peer_error_detail(reason), false, None)
|
||||
}
|
||||
|
||||
pub(crate) fn upsert_site_replication_retry_event(
|
||||
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||
peer: &PeerInfo,
|
||||
@@ -244,6 +264,17 @@ pub(crate) fn upsert_site_replication_retry_event(
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
push_site_replication_retry_event(queue, peer, path, detail, peer_unreachable, generation)
|
||||
}
|
||||
|
||||
fn push_site_replication_retry_event(
|
||||
queue: &mut Vec<SiteReplicationRetryEvent>,
|
||||
peer: &PeerInfo,
|
||||
path: &str,
|
||||
detail: String,
|
||||
peer_unreachable: bool,
|
||||
generation: Option<u64>,
|
||||
) -> S3Result<Vec<SiteReplicationRetryEvent>> {
|
||||
let slots_needed = queue
|
||||
.len()
|
||||
.saturating_add(1)
|
||||
@@ -274,7 +305,7 @@ pub(crate) fn upsert_site_replication_retry_event(
|
||||
retry_count: 1,
|
||||
failed: false,
|
||||
last_error: detail,
|
||||
updated_at: Some(now),
|
||||
updated_at: Some(OffsetDateTime::now_utc()),
|
||||
edit_generation: generation,
|
||||
peer_unreachable,
|
||||
deletions_recorded: false,
|
||||
@@ -365,6 +396,60 @@ pub(crate) async fn enqueue_site_replication_retry_event_for_generation(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of peers whose snapshot entry is escalated and therefore
|
||||
/// will not carry this scheduling: the marker records a deletion that a
|
||||
/// snapshot cannot replay, and only a repair settles it, so clearing it to make
|
||||
/// the entry drainable again would drop that liability.
|
||||
pub(crate) fn record_iam_snapshot_retries(
|
||||
state: &mut SiteReplicationState,
|
||||
local_peer: &PeerInfo,
|
||||
reason: &str,
|
||||
) -> S3Result<usize> {
|
||||
let peers = state
|
||||
.peers
|
||||
.values()
|
||||
.filter(|peer| {
|
||||
peer.deployment_id != local_peer.deployment_id && !same_identity_endpoint(&peer.endpoint, &local_peer.endpoint)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut escalated = 0usize;
|
||||
for peer in peers {
|
||||
if state.retry_queue.iter().any(|event| {
|
||||
retry_event_matches(event, &peer, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH)
|
||||
&& event.last_error == SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER
|
||||
}) {
|
||||
escalated += 1;
|
||||
continue;
|
||||
}
|
||||
ensure_site_replication_retry_event(&mut state.retry_queue, &peer, SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH, reason)?;
|
||||
}
|
||||
Ok(escalated)
|
||||
}
|
||||
|
||||
/// Schedule one collapsed full-IAM snapshot per remote peer after a bulk
|
||||
/// local mutation such as `import-iam`.
|
||||
pub(crate) async fn enqueue_site_replication_iam_snapshot(reason: &str) -> S3Result<()> {
|
||||
let state = load_site_replication_state().await?;
|
||||
if !state.enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let local_peer = current_local_runtime_peer(&state);
|
||||
let reason = reason.to_string();
|
||||
let escalated = update_site_replication_state(move |state| record_iam_snapshot_retries(state, &local_peer, &reason)).await?;
|
||||
if escalated > 0 {
|
||||
warn!(
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
||||
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
||||
escalated,
|
||||
result = "iam_snapshot_not_scheduled_for_escalated_peer",
|
||||
"site replication peers hold an escalated IAM entry; the snapshot waits for a repair"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) const SITE_REPLICATION_PEER_IAM_ITEM_WIRE_PATH: &str = "/rustfs/admin/v3/site-replication/peer/iam-item";
|
||||
|
||||
/// Per-peer cap on recorded deletion bodies. Beyond it the peer's collapsed
|
||||
|
||||
@@ -168,6 +168,8 @@ mod rfc3339_map {
|
||||
pub(crate) struct PendingEndpointRefresh {
|
||||
pub(crate) id: String,
|
||||
pub(crate) peer: PeerInfo,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) ilm_expiry_override: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub(crate) remote_peers: BTreeMap<String, PeerInfo>,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
|
||||
@@ -693,6 +693,122 @@ fn test_record_iam_deletion_marks_newest_wins_and_expires_by_age_only() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Scheduling a snapshot is not a delivery failure. Repeated imports - the
|
||||
/// normal way a bulk IAM migration is done, one archive at a time - must not
|
||||
/// walk the peer's entry up to the escalation threshold and report a healthy
|
||||
/// site as `retryStats.failed` with the scheduling note as its `lastError`.
|
||||
#[test]
|
||||
fn repeated_iam_import_snapshots_do_not_escalate_a_healthy_peer() {
|
||||
let local = PeerInfo {
|
||||
deployment_id: "local-dep".to_string(),
|
||||
..peer("local", "https://local.example.com")
|
||||
};
|
||||
let remote = PeerInfo {
|
||||
deployment_id: "remote-a".to_string(),
|
||||
..peer("remote-a", "https://a.example.com")
|
||||
};
|
||||
let mut state = SiteReplicationState {
|
||||
peers: BTreeMap::from([
|
||||
(local.deployment_id.clone(), local.clone()),
|
||||
(remote.deployment_id.clone(), remote),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for _ in 0..(SITE_REPLICATION_RETRY_FAILED_AFTER + 2) {
|
||||
record_iam_snapshot_retries(&mut state, &local, "iam import scheduled a full snapshot").expect("record snapshot");
|
||||
}
|
||||
|
||||
assert_eq!(state.retry_queue.len(), 1);
|
||||
let event = &state.retry_queue[0];
|
||||
assert_eq!(event.retry_count, 1, "a schedule must not count as a delivery attempt");
|
||||
assert!(!event.failed, "a scheduled snapshot must not report as an escalated failure");
|
||||
}
|
||||
|
||||
/// An escalated entry records a deletion a snapshot cannot replay: only a
|
||||
/// repair settles it. Scheduling an import snapshot must not clear that
|
||||
/// marker to make the entry drainable again, and the peer it skips has to be
|
||||
/// reported rather than silently left behind.
|
||||
#[test]
|
||||
fn an_escalated_peer_keeps_its_marker_and_is_reported() {
|
||||
let local = PeerInfo {
|
||||
deployment_id: "local-dep".to_string(),
|
||||
..peer("local", "https://local.example.com")
|
||||
};
|
||||
let remote = PeerInfo {
|
||||
deployment_id: "remote-a".to_string(),
|
||||
..peer("remote-a", "https://a.example.com")
|
||||
};
|
||||
let mut state = SiteReplicationState {
|
||||
peers: BTreeMap::from([
|
||||
(local.deployment_id.clone(), local.clone()),
|
||||
(remote.deployment_id.clone(), remote.clone()),
|
||||
]),
|
||||
retry_queue: vec![SiteReplicationRetryEvent {
|
||||
id: "escalated".to_string(),
|
||||
peer_deployment_id: remote.deployment_id.clone(),
|
||||
peer_endpoint: remote.endpoint,
|
||||
path: SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH.to_string(),
|
||||
retry_count: SITE_REPLICATION_RETRY_FAILED_AFTER,
|
||||
failed: true,
|
||||
last_error: SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER.to_string(),
|
||||
deletions_recorded: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let escalated =
|
||||
record_iam_snapshot_retries(&mut state, &local, "iam import scheduled a full snapshot").expect("record snapshot retries");
|
||||
|
||||
assert_eq!(escalated, 1);
|
||||
assert_eq!(state.retry_queue.len(), 1);
|
||||
assert_eq!(
|
||||
state.retry_queue[0].last_error, SITE_REPLICATION_RETRY_SNAPSHOT_REPLAYED_MARKER,
|
||||
"the unreplayable-deletion marker must survive a snapshot schedule"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iam_import_snapshot_retry_is_recorded_once_per_remote_peer() {
|
||||
let local = PeerInfo {
|
||||
deployment_id: "local-dep".to_string(),
|
||||
..peer("local", "https://local.example.com")
|
||||
};
|
||||
let remote_a = PeerInfo {
|
||||
deployment_id: "remote-a".to_string(),
|
||||
..peer("remote-a", "https://a.example.com")
|
||||
};
|
||||
let remote_b = PeerInfo {
|
||||
deployment_id: "remote-b".to_string(),
|
||||
..peer("remote-b", "https://b.example.com")
|
||||
};
|
||||
let mut state = SiteReplicationState {
|
||||
peers: BTreeMap::from([
|
||||
(local.deployment_id.clone(), local.clone()),
|
||||
(remote_a.deployment_id.clone(), remote_a),
|
||||
(remote_b.deployment_id.clone(), remote_b),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
record_iam_snapshot_retries(&mut state, &local, "IAM import snapshot pending").expect("record snapshot retries");
|
||||
|
||||
assert_eq!(state.retry_queue.len(), 2);
|
||||
assert!(
|
||||
state
|
||||
.retry_queue
|
||||
.iter()
|
||||
.all(|event| event.path == SITE_REPLICATION_RETRY_IAM_SNAPSHOT_PATH)
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.retry_queue
|
||||
.iter()
|
||||
.all(|event| event.peer_deployment_id != local.deployment_id)
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed deletion delivery persists a replay record next to the collapsed
|
||||
/// retry entry; a fresh entry is stamped `deletions_recorded` so a later
|
||||
/// replay can settle it, and a repeated deletion of the same entity keeps the
|
||||
|
||||
@@ -730,7 +730,11 @@ impl S3 for FS {
|
||||
|
||||
let result = Ok(S3Response::new(DeleteObjectTaggingOutput { version_id }));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&object,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
);
|
||||
let duration = start_time.elapsed();
|
||||
histogram!("rustfs_object_tagging_operation_duration_seconds", "operation" => "delete").record(duration.as_secs_f64());
|
||||
result
|
||||
@@ -1629,7 +1633,11 @@ impl S3 for FS {
|
||||
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&key,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1733,7 +1741,10 @@ impl S3 for FS {
|
||||
);
|
||||
}
|
||||
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_bucket_from_producer(
|
||||
&bucket,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
|
||||
);
|
||||
Ok(S3Response::new(PutObjectLockConfigurationOutput::default()))
|
||||
}
|
||||
|
||||
@@ -1849,7 +1860,11 @@ impl S3 for FS {
|
||||
|
||||
let result = Ok(S3Response::new(output));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&key,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1959,7 +1974,11 @@ impl S3 for FS {
|
||||
version_id: req.input.version_id.clone(),
|
||||
}));
|
||||
let _ = helper.complete(&result);
|
||||
rustfs_scanner::record_dirty_usage_bucket(&bucket);
|
||||
rustfs_scanner::record_dirty_usage_object_from_producer(
|
||||
&bucket,
|
||||
&object,
|
||||
rustfs_scanner::SegmentInvalidationProducerIdentity::ObjectMetadata,
|
||||
);
|
||||
let duration = start_time.elapsed();
|
||||
histogram!("rustfs_object_tagging_operation_duration_seconds", "operation" => "put").record(duration.as_secs_f64());
|
||||
result
|
||||
|
||||
+183
-1
@@ -74,6 +74,7 @@ use super::storage_api::ecstore_object::{
|
||||
EncryptionResolutionError, EncryptionResolutionErrorKind, ObjectEncryptionResolver, ReadEncryptionMaterial,
|
||||
ReadEncryptionMode, ReadEncryptionRequest,
|
||||
};
|
||||
use crate::runtime_sources::current_kms_runtime_service_manager;
|
||||
use crate::storage::access::{ReqInfo, request_context_from_req, resource_free_condition_values};
|
||||
use crate::storage::storage_api::runtime_sources_consumer::runtime_sources;
|
||||
#[cfg(feature = "rio-v2")]
|
||||
@@ -2758,7 +2759,32 @@ async fn apply_managed_encryption_material_inner(
|
||||
// key it will actually be encrypted under.
|
||||
authorize_sse_kms_key(principal, encryption_type, KmsAction::GenerateDataKeyAction, &kms_key_to_use).await?;
|
||||
|
||||
let provider = get_sse_dek_provider().await?;
|
||||
// A node-local master key is the explicit SSE-S3 fallback. Letting it serve
|
||||
// an SSE-KMS request would persist an `aws:kms` marker and a KMS key id
|
||||
// that never wrapped the data key, so refuse rather than downgrade.
|
||||
//
|
||||
// The refusal sits after the authorization gate so an unauthorized caller
|
||||
// still sees AccessDenied whatever the KMS runtime state is, and it asks
|
||||
// the resolved provider rather than a parallel availability signal,
|
||||
// because the provider is what actually wraps the DEK.
|
||||
let provider = match get_sse_dek_provider().await {
|
||||
Ok(provider) => {
|
||||
if matches!(encryption_type, SSEType::SseKms) && provider.wraps_dek_with_local_master_key() {
|
||||
return Err(sse_kms_unavailable_error(kms_configured_but_unavailable().await));
|
||||
}
|
||||
provider
|
||||
}
|
||||
// With no master key set the local fallback fails with an SSE-S3-worded
|
||||
// configuration error. An SSE-KMS request never asked for that provider,
|
||||
// so it gets the SSE-KMS refusal instead of a message naming the wrong
|
||||
// scheme.
|
||||
Err(err) => {
|
||||
if matches!(encryption_type, SSEType::SseKms) && runtime_sources::current_encryption_service().await.is_none() {
|
||||
return Err(sse_kms_unavailable_error(kms_configured_but_unavailable().await));
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let object_context = build_object_encryption_context(bucket, key, ssekms_context.as_ref());
|
||||
let (data_key, encrypted_data_key) = provider.generate_sse_dek(&object_context, &kms_key_to_use).await?;
|
||||
|
||||
@@ -2790,6 +2816,27 @@ async fn apply_managed_encryption_material_inner(
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this node has a KMS configured that is not currently serving, which
|
||||
/// separates a transient outage (retryable, 503) from a deployment that never
|
||||
/// configured KMS at all (a client-side configuration error, 400).
|
||||
async fn kms_configured_but_unavailable() -> bool {
|
||||
match current_kms_runtime_service_manager() {
|
||||
Some(manager) => !matches!(manager.get_status().await, rustfs_kms::KmsServiceStatus::NotConfigured),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sse_kms_unavailable_error(configured_but_unavailable: bool) -> ApiError {
|
||||
if configured_but_unavailable {
|
||||
return ApiError::from(StorageError::other(KmsUnavailableError));
|
||||
}
|
||||
ApiError {
|
||||
code: S3ErrorCode::InvalidRequest,
|
||||
message: "SSE-KMS requires a configured and running KMS service".to_string(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_managed_decryption_material(
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
@@ -3236,6 +3283,16 @@ pub trait SseDekProvider: Send + Sync {
|
||||
)))
|
||||
}
|
||||
|
||||
/// Whether this provider wraps data keys with a node-local master key
|
||||
/// instead of a KMS service.
|
||||
///
|
||||
/// SSE-KMS must never be served by such a provider: the stored object would
|
||||
/// claim `aws:kms` and name a KMS key id that never wrapped anything.
|
||||
/// Defaults to false so only the local fallback has to declare itself.
|
||||
fn wraps_dek_with_local_master_key(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Decrypt a DEK from positively identified legacy managed metadata.
|
||||
#[cfg(feature = "rio-v2")]
|
||||
async fn decrypt_legacy_sse_dek(
|
||||
@@ -3758,6 +3815,10 @@ impl LocalSseDekProvider {
|
||||
|
||||
#[async_trait]
|
||||
impl SseDekProvider for LocalSseDekProvider {
|
||||
fn wraps_dek_with_local_master_key(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn generate_sse_dek(
|
||||
&self,
|
||||
_context: &ObjectEncryptionContext,
|
||||
@@ -4344,6 +4405,127 @@ mod tests {
|
||||
assert_eq!(super::kms_data_plane_error_class(&missing), "key_not_found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_kms_never_falls_back_to_the_local_sse_s3_provider() {
|
||||
let unconfigured = super::sse_kms_unavailable_error(false);
|
||||
assert_eq!(unconfigured.code, S3ErrorCode::InvalidRequest);
|
||||
assert!(unconfigured.message.contains("SSE-KMS requires"));
|
||||
|
||||
let stopped = super::sse_kms_unavailable_error(true);
|
||||
assert_eq!(stopped.code, S3ErrorCode::ServiceUnavailable);
|
||||
}
|
||||
|
||||
fn managed_write(algorithm: &'static str, kms_key_id: Option<&str>) -> EncryptionRequest<'static> {
|
||||
EncryptionRequest {
|
||||
bucket: "finance",
|
||||
key: "ledger.csv",
|
||||
server_side_encryption: Some(ServerSideEncryption::from_static(algorithm)),
|
||||
ssekms_key_id: kms_key_id.map(str::to_string),
|
||||
ssekms_context: None,
|
||||
sse_customer_algorithm: None,
|
||||
sse_customer_key: None,
|
||||
sse_customer_key_md5: None,
|
||||
content_size: 128,
|
||||
principal: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A bucket default naming a KMS key, on a node with no KMS, used to write
|
||||
/// the object under the local master key while stamping `aws:kms` and that
|
||||
/// never-consulted key id into the metadata (backlog#2368 B4).
|
||||
#[tokio::test]
|
||||
async fn sse_kms_write_is_refused_when_only_a_local_master_key_is_available() {
|
||||
let _guard = lock_sse_test_state().await;
|
||||
reset_sse_dek_provider();
|
||||
|
||||
async_with_vars(
|
||||
[
|
||||
("__RUSTFS_SSE_SIMPLE_CMK", None::<String>),
|
||||
("RUSTFS_SSE_S3_MASTER_KEY", Some(BASE64_STANDARD.encode_to_string([9u8; 32]))),
|
||||
],
|
||||
async {
|
||||
let error = sse_encryption(managed_write(
|
||||
ServerSideEncryption::AWS_KMS,
|
||||
Some("arn:aws:kms:us-east-1:123:key/nonexistent"),
|
||||
))
|
||||
.await
|
||||
.expect_err("SSE-KMS must not be served by the local master key");
|
||||
|
||||
assert_eq!(error.code, S3ErrorCode::InvalidRequest);
|
||||
assert!(error.message.contains("SSE-KMS requires"), "message was {}", error.message);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
reset_sse_dek_provider();
|
||||
}
|
||||
|
||||
/// Without a master key the local fallback fails with an SSE-S3-worded
|
||||
/// configuration error. An SSE-KMS request must not be told to set
|
||||
/// `RUSTFS_SSE_S3_MASTER_KEY`.
|
||||
#[tokio::test]
|
||||
async fn sse_kms_refusal_names_sse_kms_rather_than_the_sse_s3_master_key() {
|
||||
let _guard = lock_sse_test_state().await;
|
||||
reset_sse_dek_provider();
|
||||
|
||||
async_with_vars(
|
||||
[
|
||||
("__RUSTFS_SSE_SIMPLE_CMK", None::<String>),
|
||||
("RUSTFS_SSE_S3_MASTER_KEY", None::<String>),
|
||||
],
|
||||
async {
|
||||
let error = sse_encryption(managed_write(ServerSideEncryption::AWS_KMS, Some("finance-key")))
|
||||
.await
|
||||
.expect_err("SSE-KMS must be refused when no KMS is configured");
|
||||
|
||||
assert_eq!(error.code, S3ErrorCode::InvalidRequest);
|
||||
assert!(error.message.contains("SSE-KMS requires"), "message was {}", error.message);
|
||||
assert!(
|
||||
!error.message.contains("RUSTFS_SSE_S3_MASTER_KEY"),
|
||||
"an SSE-KMS refusal must not name the SSE-S3 master key: {}",
|
||||
error.message
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
reset_sse_dek_provider();
|
||||
}
|
||||
|
||||
/// The SSE-S3 local fallback itself is unchanged: refusing SSE-KMS must not
|
||||
/// take the documented no-KMS deployment down with it.
|
||||
#[tokio::test]
|
||||
async fn sse_s3_write_still_uses_the_local_master_key_fallback() {
|
||||
let _guard = lock_sse_test_state().await;
|
||||
reset_sse_dek_provider();
|
||||
|
||||
async_with_vars(
|
||||
[
|
||||
("__RUSTFS_SSE_SIMPLE_CMK", None::<String>),
|
||||
("RUSTFS_SSE_S3_MASTER_KEY", Some(BASE64_STANDARD.encode_to_string([9u8; 32]))),
|
||||
],
|
||||
async {
|
||||
let material = sse_encryption(managed_write(ServerSideEncryption::AES256, None))
|
||||
.await
|
||||
.expect("SSE-S3 keeps its local master key fallback")
|
||||
.expect("managed sse-s3 material");
|
||||
|
||||
assert_eq!(material.sse_type, SSEType::SseS3);
|
||||
assert_eq!(material.algorithm, ServerSideEncryption::AES256);
|
||||
|
||||
// No object may claim aws:kms while its DEK is wrapped locally.
|
||||
let metadata = encryption_material_to_metadata(&material).expect("sse-s3 metadata should serialize");
|
||||
assert!(
|
||||
!metadata.iter().any(|(_, value)| value == ServerSideEncryption::AWS_KMS),
|
||||
"local-master-key material must never be stamped aws:kms: {metadata:?}"
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
reset_sse_dek_provider();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_simple_sse_cmk_accepts_valid_32_byte_key() {
|
||||
let mut key = [0u8; 32];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user