mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 03:59:14 +00:00
merge: resolve conflicts with main branch
- Merge origin/main into perf/fileinfo-optimization - Resolve conflicts in crates/ecstore/src/set_disk/ops/object.rs - Keep AHashMap import and main branch's detailed imports Co-Authored-By: heihutu <heihutu@gmail.com>
This commit is contained in:
+11
-6
@@ -138,7 +138,7 @@ test-group = 'e2e-inline-boundaries'
|
||||
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
||||
# one group.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||
test-group = 'e2e-vault'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -196,6 +196,15 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(rustfs-ecstore) & test(walk_dir_does_not_charge_consumer_backpressure_to_the_stall_budget)'
|
||||
retries = 2
|
||||
|
||||
# Serialize the relocated-pool GET resume regression under the ci profile too
|
||||
# (see the matching default-profile override near the top). No longer a
|
||||
# quarantine: the fixture race (rustfs#6701/rustfs#6703) was fixed by #6707,
|
||||
# which made the staging tolerate quorum-tolerated disk gaps; only the 8-disk
|
||||
# cross-disk-IO serialization remains.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the 4-disk reliability / degraded-read e2e tests under the ci
|
||||
# profile too (see the e2e-reliability test-group note near the top). Not a
|
||||
# quarantine: no retries, just single-threaded so several 4-disk servers never
|
||||
@@ -211,10 +220,6 @@ test-group = 'e2e-reliability'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Match the default-profile embedded test isolation without quarantining or
|
||||
# retrying failures in CI.
|
||||
[[profile.ci.overrides]]
|
||||
@@ -484,5 +489,5 @@ filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
[[profile.e2e-full.overrides]]
|
||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||
test-group = 'e2e-vault'
|
||||
|
||||
@@ -429,6 +429,11 @@ jobs:
|
||||
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
|
||||
# noncurrent transition/expiry after an immediate compensation transition.
|
||||
- name: Run ignored ILM integration tests serially
|
||||
env:
|
||||
# Match the measured Test and Lint link budget. The default exposed
|
||||
# all 14 pod CPUs and a cold cache spent the full 80m compiling
|
||||
# without starting one ILM test (main run 32982910990).
|
||||
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||
run: |
|
||||
mkdir -p artifacts/ilm-integration
|
||||
set +e
|
||||
@@ -492,9 +497,70 @@ jobs:
|
||||
# job's 90-minute budget before any test starts.
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo nextest run -p rustfs -p rustfs-ecstore --features rio-v2
|
||||
# --profile ci so the quarantine list (and its junit flaky markers)
|
||||
# covers this leg too; the default profile is the local no-retry
|
||||
# profile and silently ignored quarantined flakes here (rustfs#6703).
|
||||
cargo nextest run --profile ci -p rustfs -p rustfs-ecstore --features rio-v2
|
||||
cargo test -p rustfs --doc --features rio-v2
|
||||
|
||||
connect-short-credential-boundary:
|
||||
name: Connect Short Credential Boundary
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
needs: [ quick-checks ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
install-test-tools: 'false'
|
||||
|
||||
- name: Run short credential behavior tests
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo test -p rustfs --test connect_registration \
|
||||
--features connect-e2e-short-credentials \
|
||||
registration_enforces_build_profile_credential_lifetime -- --exact
|
||||
cargo test -p rustfs --test connect_registration \
|
||||
--features connect-e2e-short-credentials \
|
||||
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
|
||||
|
||||
- name: Reject short credentials in release builds
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
log="$(mktemp)"
|
||||
set +e
|
||||
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
|
||||
--features connect-e2e-short-credentials >"$log" 2>&1
|
||||
status=$?
|
||||
set -e
|
||||
cat "$log"
|
||||
expected='error: connect-e2e-short-credentials is restricted to debug builds'
|
||||
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
|
||||
expected_count="$(grep -Fxc "$expected" "$log" || true)"
|
||||
summary_count="$(grep -Fc "$summary" "$log" || true)"
|
||||
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
|
||||
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|
||||
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
|
||||
echo "release feature gate did not fail solely at the expected compile_error" >&2
|
||||
rm -f "$log"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$log"
|
||||
|
||||
test-and-lint-protocols:
|
||||
name: "Test and Lint (${{ matrix.features.name }})"
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
@@ -544,7 +610,10 @@ jobs:
|
||||
# main nextest lane; Clippy is metadata-only and needs no such limit.
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo nextest run -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
|
||||
# --profile ci so the quarantine list (and its junit flaky markers)
|
||||
# covers this leg too; the default profile is the local no-retry
|
||||
# profile and silently ignored quarantined flakes here (rustfs#6703).
|
||||
cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }}
|
||||
|
||||
build-rustfs-debug-binary:
|
||||
name: Build RustFS Debug Binary
|
||||
|
||||
@@ -58,9 +58,12 @@ defaults:
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
RUSTFS_API_ENDPOINT: ${{ vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||
RUSTFS_NODES: ${{ vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
# Package used by the scheduled run (workflow_dispatch inputs are empty for
|
||||
# schedule events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
|
||||
jobs:
|
||||
pool-expansion-test:
|
||||
@@ -81,18 +84,32 @@ jobs:
|
||||
df -h /data | tail -1
|
||||
|
||||
- name: Reset test environment (before)
|
||||
if: ${{ inputs.cleanup_before }}
|
||||
if: ${{ inputs.cleanup_before != 'false' }}
|
||||
run: |
|
||||
chmod +x scripts/test/rustfs_pool_expand.sh
|
||||
./scripts/test/rustfs_pool_expand.sh --reset -y
|
||||
|
||||
- name: Install RustFS package & start first pool
|
||||
run: |
|
||||
ARGS=(--steps 1,2,3 -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||
ARGS+=(--version "${{ inputs.rustfs_version }}")
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||
|
||||
- name: Preflight checks
|
||||
run: |
|
||||
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
else
|
||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||
ARGS+=(--version "${{ inputs.rustfs_version }}")
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||
|
||||
@@ -100,24 +117,19 @@ jobs:
|
||||
id: pool_test
|
||||
run: |
|
||||
set -o pipefail
|
||||
STEPS="1,2,3,4,5,6"
|
||||
if [ "${{ inputs.pools }}" = "3" ]; then
|
||||
STEPS="4,5,6"
|
||||
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
|
||||
STEPS="$STEPS,7,8"
|
||||
if [ "${{ inputs.run_decommission }}" = "true" ]; then
|
||||
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
|
||||
STEPS="$STEPS,9"
|
||||
fi
|
||||
fi
|
||||
ARGS=(--steps "$STEPS" --with-warp -y \
|
||||
./scripts/test/rustfs_pool_expand.sh \
|
||||
--steps "$STEPS" --with-warp -y \
|
||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||
--storage-threshold "${{ inputs.storage_threshold }}" \
|
||||
--warp-duration "${{ inputs.warp_duration }}" \
|
||||
--log-file /tmp/rustfs-pool-test.log)
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
else
|
||||
ARGS+=(--version "${{ inputs.rustfs_version }}")
|
||||
fi
|
||||
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||
--storage-threshold "${{ inputs.storage_threshold || '50' }}" \
|
||||
--warp-duration "${{ inputs.warp_duration || '10m' }}" \
|
||||
--log-file /tmp/rustfs-pool-test.log
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
@@ -130,7 +142,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Reset test environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after }}
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
run: |
|
||||
./scripts/test/rustfs_pool_expand.sh --reset -y
|
||||
|
||||
@@ -138,5 +150,5 @@ jobs:
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS pool expansion test failed"
|
||||
echo "Package source: ${{ inputs.package_url || format('release {0}', inputs.rustfs_version) }}"
|
||||
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded log artifact for details."
|
||||
|
||||
+26
-13
@@ -73,7 +73,7 @@ The main crate is organized in layers, top to bottom:
|
||||
|-------|-----------|----------------|
|
||||
| **Server** | `server/` | HTTP listener, TLS, CORS, compression, middleware, graceful shutdown |
|
||||
| **Admin** | `admin/` | Admin API routing, 30+ handler modules, web console |
|
||||
| **App** | `app/` | Use-case orchestration: object_usecase, bucket_usecase, multipart_usecase |
|
||||
| **App** | `app/` | Use-case orchestration: object (per-operation modules under `app/object/`, re-exported as `object_usecase`), bucket_usecase, multipart_usecase |
|
||||
| **Storage** | `storage/` | S3 API translation, erasure-coded FS, SSE encryption, RPC, concurrency |
|
||||
| **Auth** | `auth.rs` | S3 signature verification, credential validation |
|
||||
| **Config** | `config/` | CLI parsing, config struct, workload profiles |
|
||||
@@ -93,7 +93,7 @@ refactors.
|
||||
| Domain | Current workspace crates | Responsibility |
|
||||
|--------|--------------------------|----------------|
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. |
|
||||
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, and I/O pipelines. |
|
||||
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
|
||||
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
|
||||
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
|
||||
| Operations and integration | `audit`, `notify`, `obs`, `targets`, `zip` | Auditing, observability, event delivery, notification targets, and archive support. |
|
||||
@@ -115,8 +115,15 @@ default build (lifecycle:
|
||||
1. **Layers flow downward.** Server → Admin/App → Storage → ecstore → rio/io-core.
|
||||
No upward imports.
|
||||
|
||||
2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`,
|
||||
`io-metrics`, and `madmin` should depend only on external crates.
|
||||
2. **Leaf crates depend only on external crates, with adjudicated exceptions
|
||||
pinned by a guard.** `config`, `credentials`, and `crypto` take no internal
|
||||
dependency. `io-metrics` takes exactly `rustfs-s3-ops` (transitively
|
||||
`rustfs-s3-types`), a pure contract crate with no I/O and no global state —
|
||||
adjudicated in rustfs/backlog#1834. `madmin` left the leaf set when #6166 made
|
||||
it the SigV4-signed admin SDK client; its internal dependency surface is pinned
|
||||
to exactly `rustfs-signer`. Both pins live in the leaf allowlist in
|
||||
`scripts/check_architecture_migration_rules.sh`; any other internal dependency
|
||||
fails the guard ([crate boundaries](docs/architecture/crate-boundaries.md)).
|
||||
- ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin`
|
||||
edges were removed; do not reintroduce them (see Known Structural Issues).
|
||||
|
||||
@@ -138,15 +145,19 @@ default build (lifecycle:
|
||||
`BackpressureSettings` copy that lingered in io-metrics was removed
|
||||
(rustfs/backlog#1833).
|
||||
|
||||
4. **ecstore does not know about HTTP or S3 protocol details.** It operates on
|
||||
storage-level abstractions (objects, buckets, disks, pools).
|
||||
- ⚠️ VIOLATED: 58 files under `crates/ecstore/src` reference `s3s`
|
||||
(`rg -l 's3s' crates/ecstore/src | wc -l`), `crates/ecstore/src/client/`
|
||||
is a ~9.4K-line embedded S3 HTTP client, and `crates/ecstore/Cargo.toml`
|
||||
depends on `s3s`, `http`, `hyper`/`hyper-util`/`hyper-rustls`, and
|
||||
`reqwest`. Target state: the engine's need to act as an S3 client
|
||||
(tiering, replication targets) is served by an extracted client crate,
|
||||
and ecstore holds no wire or DTO types.
|
||||
4. **ecstore does not *serve* HTTP or the S3 wire protocol.** It operates on
|
||||
storage-level abstractions (objects, buckets, disks, pools) and holds no
|
||||
wire or DTO types of the serving surface. *Consuming* remote S3-compatible
|
||||
endpoints (ILM tier warm backends, transition targets) is a legitimate
|
||||
engine capability, but it lives in the dedicated `rustfs-s3-client` crate
|
||||
(`crates/s3-client`, extracted from the formerly embedded
|
||||
`crates/ecstore/src/client/` by rustfs/backlog#1842), not inside ecstore.
|
||||
- ⚠️ PARTIALLY VIOLATED: serving-side `s3s` references remain in ecstore
|
||||
(bucket metadata/replication/lifecycle DTOs and error mapping). The
|
||||
count is ratcheted shrink-only by `scripts/check_s3s_footprint.sh`
|
||||
(`S3S_ECSTORE_FILES_BASELINE`; the `object_lock` module was converted to
|
||||
storage-level types as the first ratchet step). Target state: the
|
||||
baseline reaches zero and ecstore's `Cargo.toml` drops `s3s`.
|
||||
|
||||
5. **The `rustfs` binary crate is the only place that wires everything together.**
|
||||
Individual crates should be testable in isolation.
|
||||
@@ -321,6 +332,8 @@ The binary (`main.rs`) boots in this order:
|
||||
|
||||
- **"Where is replication configured?"**
|
||||
`admin/handlers/replication.rs` and `admin/handlers/site_replication.rs` for API,
|
||||
`rustfs/src/site_replication/` for the site-replication service subsystem
|
||||
(state, peer transport, retry queue, repair, hooks),
|
||||
`ecstore/src/bucket/replication/` for engine
|
||||
|
||||
- **"Where do I add a new admin endpoint?"**
|
||||
|
||||
Generated
+258
-182
File diff suppressed because it is too large
Load Diff
+59
-62
@@ -72,7 +72,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.97.1"
|
||||
version = "1.0.0-rc.3"
|
||||
version = "1.0.0-rc.4"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -89,55 +89,55 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.3" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.3" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.3" }
|
||||
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.3" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.3" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.3" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.3" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.3" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.3" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.3" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.3" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.3" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.3" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.3" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.3" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.3" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.3" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.3" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.3" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.3" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.3" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.3" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.3" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.3" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.3", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.3" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.3" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.3" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.3" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.3" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.3" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.3" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.3" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.3" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.3" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.3" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.3" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.3" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.3" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.3" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.3" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.3" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.3" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.3" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.3" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.3" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.3" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.3" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.3" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.4" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.4" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.4" }
|
||||
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.4" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.4" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.4" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.4" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.4" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.4" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.4" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.4" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.4" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.4" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.4" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.4" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.4" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.4" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.4" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.4" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.4" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.4" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.4" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.4" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.4" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.4", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.4" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.4" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.4" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.4" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.4" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.4" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.4" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.4" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.4" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.4" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.4" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.4" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.4" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.4" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.4" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.4" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.4" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.4" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.4" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.4" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.4" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.4" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.4" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.4" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -199,7 +199,7 @@ serde_urlencoded = "0.7.1"
|
||||
# releases.
|
||||
aes-gcm = { version = "=0.11.1" }
|
||||
argon2 = { version = "=0.6.0-rc.8" }
|
||||
blake2 = "=0.11.0-rc.6"
|
||||
blake2 = "=0.11.0"
|
||||
chacha20poly1305 = { version = "=0.11.0" }
|
||||
crc-fast = "1.10.0"
|
||||
hmac = { version = "0.13.0" }
|
||||
@@ -237,13 +237,12 @@ atoi = "3.1.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.11.0" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.116.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.143.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.112.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.117.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.113.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
||||
aws-smithy-runtime-api = { version = "1.15.0" }
|
||||
aws-smithy-types = { version = "1.6.2" }
|
||||
base64 = "0.23.1"
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
clap = { version = "4.6.6" }
|
||||
@@ -260,13 +259,12 @@ enumset = "1.1.14"
|
||||
faster-hex = "0.10.0"
|
||||
flate2 = "1.1.9"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.17.0"
|
||||
google-cloud-auth = "1.15.0"
|
||||
google-cloud-storage = "1.18.0"
|
||||
google-cloud-auth = "1.16.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
|
||||
# every authenticator app expects). Already in the graph transitively.
|
||||
data-encoding = "2.11.1"
|
||||
hex = "0.4.3"
|
||||
hex-simd = "0.8.0"
|
||||
highway = { version = "1.3.0" }
|
||||
hostname = "0.4.2"
|
||||
@@ -306,7 +304,7 @@ rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.4" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "5f22e8d0a37e83f531f653024aac11c72586479a", version = "0.15.0", features = ["minio"] }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "0f6f83d98b37fd9edcaa3be573db4aa8f568e088", version = "0.15.0", features = ["minio"] }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.3"
|
||||
@@ -329,7 +327,7 @@ tracing-subscriber = { version = "0.3.23" }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.25.0" }
|
||||
uuid = { version = "1.26.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
@@ -343,7 +341,7 @@ zstd = "0.13.3"
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
metrics-util = "0.20"
|
||||
dial9-tokio-telemetry = "0.3"
|
||||
dial9-tokio-telemetry = "0.5.0"
|
||||
opentelemetry = { version = "0.32.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.32.0" }
|
||||
opentelemetry-otlp = { version = "0.32.0" }
|
||||
@@ -366,7 +364,6 @@ dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.5.1" }
|
||||
rustfs-mimalloc-sys = { version = "0.5.1" }
|
||||
hotpath = { version = "0.24.0", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
@@ -115,7 +115,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
|
||||
@@ -67,7 +67,7 @@ tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread", "time",
|
||||
tracing = { workspace = true, features = ["std", "attributes"] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
rustfs-targets = { workspace = true, features = ["test-support"] }
|
||||
temp-env = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
|
||||
@@ -564,88 +564,21 @@ impl AuditRuntimeFacade {
|
||||
mod tests {
|
||||
use super::AuditPipeline;
|
||||
use crate::{AuditEntry, AuditError, AuditRegistry};
|
||||
use async_trait::async_trait;
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::store::{Key, Store};
|
||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{StoreError, Target, TargetError};
|
||||
use rustfs_targets::testkit::MockTarget;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
|
||||
/// Mock target whose `save()` outcome is fixed at construction so tests can
|
||||
/// force full-success / full-failure / partial-failure fan-outs.
|
||||
#[derive(Clone)]
|
||||
struct MockTarget {
|
||||
id: TargetID,
|
||||
fail: bool,
|
||||
health_gate: Option<(Arc<Notify>, Arc<Notify>)>,
|
||||
}
|
||||
|
||||
impl MockTarget {
|
||||
fn new(id: &str, fail: bool) -> Self {
|
||||
Self {
|
||||
id: TargetID::new(id.to_string(), "webhook".to_string()),
|
||||
fail,
|
||||
health_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_health_gate(mut self, started: Arc<Notify>, release: Arc<Notify>) -> Self {
|
||||
self.health_gate = Some((started, release));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<E> Target<E> for MockTarget
|
||||
where
|
||||
E: rustfs_targets::PluginEvent,
|
||||
{
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
if let Some((started, release)) = &self.health_gate {
|
||||
started.notify_one();
|
||||
release.notified().await;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
||||
if self.fail {
|
||||
Err(TargetError::Configuration("forced save failure".to_string()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// Builds a mock target whose `save()` outcome is fixed at construction so tests can force
|
||||
/// full-success / full-failure / partial-failure fan-outs.
|
||||
fn mock_target(id: &str, fail: bool) -> MockTarget {
|
||||
let target = MockTarget::new(id, "webhook");
|
||||
if fail { target.with_save_failures(usize::MAX) } else { target }
|
||||
}
|
||||
|
||||
fn pipeline_with(targets: Vec<MockTarget>) -> AuditPipeline {
|
||||
let mut registry = AuditRegistry::new();
|
||||
for target in targets {
|
||||
registry.add_target(target.id.to_string(), Box::new(target));
|
||||
registry.add_target(target.target_id().to_string(), Box::new(target));
|
||||
}
|
||||
AuditPipeline::new(Arc::new(Mutex::new(registry)))
|
||||
}
|
||||
@@ -658,7 +591,7 @@ mod tests {
|
||||
// dispatch must return Err rather than swallowing the failures as Ok.
|
||||
#[tokio::test]
|
||||
async fn dispatch_returns_err_when_all_targets_fail() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true), MockTarget::new("b:webhook", true)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("a:webhook", true), mock_target("b:webhook", true)]);
|
||||
let result = pipeline.dispatch(entry()).await;
|
||||
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
|
||||
}
|
||||
@@ -667,13 +600,13 @@ mod tests {
|
||||
// so dispatch reports success (degradation is logged, not propagated).
|
||||
#[tokio::test]
|
||||
async fn dispatch_returns_ok_on_partial_failure() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("ok:webhook", false), MockTarget::new("bad:webhook", true)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("ok:webhook", false), mock_target("bad:webhook", true)]);
|
||||
pipeline.dispatch(entry()).await.expect("partial success should return Ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_returns_ok_when_all_targets_succeed() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]);
|
||||
pipeline.dispatch(entry()).await.expect("all-success should return Ok");
|
||||
}
|
||||
|
||||
@@ -686,9 +619,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_probe_does_not_hold_the_registry_lock() {
|
||||
let started = Arc::new(Notify::new());
|
||||
let release = Arc::new(Notify::new());
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("blocked", false).with_health_gate(started.clone(), release.clone())]);
|
||||
let target = mock_target("blocked", false).with_health_gate(release.clone());
|
||||
let started = target.health_started();
|
||||
let pipeline = pipeline_with(vec![target]);
|
||||
let registry = Arc::clone(&pipeline.registry);
|
||||
let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await });
|
||||
started.notified().await;
|
||||
@@ -706,14 +640,14 @@ mod tests {
|
||||
// whole-batch loss instead of returning Ok.
|
||||
#[tokio::test]
|
||||
async fn dispatch_batch_returns_err_when_all_targets_fail() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", true)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("a:webhook", true)]);
|
||||
let result = pipeline.dispatch_batch(vec![entry(), entry()]).await;
|
||||
assert!(matches!(result, Err(AuditError::Target(_))), "expected Err, got {result:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_batch_returns_ok_when_all_targets_succeed() {
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("a:webhook", false), MockTarget::new("b:webhook", false)]);
|
||||
let pipeline = pipeline_with(vec![mock_target("a:webhook", false), mock_target("b:webhook", false)]);
|
||||
pipeline
|
||||
.dispatch_batch(vec![entry(), entry()])
|
||||
.await
|
||||
|
||||
@@ -286,70 +286,10 @@ impl AuditRegistry {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AuditRegistry;
|
||||
use crate::{AuditEntry, AuditError};
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::store::{Key, Store};
|
||||
use rustfs_targets::target::{ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{StoreError, Target, TargetError};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CloseTestTarget {
|
||||
id: TargetID,
|
||||
close_calls: Arc<AtomicUsize>,
|
||||
fail_on_close: bool,
|
||||
}
|
||||
|
||||
impl CloseTestTarget {
|
||||
fn new(id: TargetID, close_calls: Arc<AtomicUsize>, fail_on_close: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
close_calls,
|
||||
fail_on_close,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Target<AuditEntry> for CloseTestTarget {
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<AuditEntry>>) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
self.close_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.fail_on_close {
|
||||
Err(TargetError::Unknown("close failed".to_string()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<AuditEntry> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
use crate::AuditError;
|
||||
use rustfs_targets::TargetError;
|
||||
use rustfs_targets::target::ChannelTargetType;
|
||||
use rustfs_targets::testkit::MockTarget;
|
||||
|
||||
#[test]
|
||||
fn registry_registers_amqp_factory() {
|
||||
@@ -361,23 +301,21 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn close_all_returns_first_error_and_clears_targets() {
|
||||
let mut registry = AuditRegistry::new();
|
||||
let ok_calls = Arc::new(AtomicUsize::new(0));
|
||||
let fail_calls = Arc::new(AtomicUsize::new(0));
|
||||
let ok = MockTarget::new("ok", "webhook");
|
||||
let ok_observer = ok.clone();
|
||||
let fail = MockTarget::new("fail", "webhook")
|
||||
.with_close_failures(usize::MAX)
|
||||
.with_close_failure_error(|| TargetError::Unknown("close failed".to_string()));
|
||||
let fail_observer = fail.clone();
|
||||
|
||||
let ok_id = TargetID::new("ok".to_string(), "webhook".to_string());
|
||||
let fail_id = TargetID::new("fail".to_string(), "webhook".to_string());
|
||||
|
||||
registry.add_target(ok_id.to_string(), Box::new(CloseTestTarget::new(ok_id, Arc::clone(&ok_calls), false)));
|
||||
registry.add_target(
|
||||
fail_id.to_string(),
|
||||
Box::new(CloseTestTarget::new(fail_id, Arc::clone(&fail_calls), true)),
|
||||
);
|
||||
registry.add_target(ok.target_id().to_string(), Box::new(ok));
|
||||
registry.add_target(fail.target_id().to_string(), Box::new(fail));
|
||||
|
||||
let result = registry.close_all().await;
|
||||
|
||||
assert!(matches!(result, Err(AuditError::Target(TargetError::Unknown(_)))));
|
||||
assert_eq!(ok_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(fail_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(ok_observer.close_call_count(), 1);
|
||||
assert_eq!(fail_observer.close_call_count(), 1);
|
||||
assert!(registry.list_targets().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+11
-70
@@ -577,76 +577,17 @@ fn warn_audit_state(state: &str, reason: Option<&str>) {
|
||||
mod tests {
|
||||
use super::{AuditSystem, AuditSystemState};
|
||||
use crate::{AuditEntry, AuditError};
|
||||
use async_trait::async_trait;
|
||||
use rustfs_targets::ReplayWorkerManager;
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::store::{Key, Store};
|
||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{StoreError, Target, TargetError};
|
||||
use rustfs_targets::testkit::MockTarget;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestTarget {
|
||||
close_calls: Arc<AtomicUsize>,
|
||||
id: TargetID,
|
||||
}
|
||||
|
||||
impl TestTarget {
|
||||
fn new(id: &str, name: &str) -> Self {
|
||||
Self {
|
||||
close_calls: Arc::new(AtomicUsize::new(0)),
|
||||
id: TargetID::new(id.to_string(), name.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<E> Target<E> for TestTarget
|
||||
where
|
||||
E: rustfs_targets::PluginEvent,
|
||||
{
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
self.close_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_with_empty_config_stops_existing_runtime() {
|
||||
let system = AuditSystem::new();
|
||||
let target = TestTarget::new("primary", "webhook");
|
||||
let close_calls = Arc::clone(&target.close_calls);
|
||||
let target = MockTarget::new("primary", "webhook");
|
||||
let observer = target.clone();
|
||||
|
||||
{
|
||||
let mut registry = system.registry.lock().await;
|
||||
@@ -671,7 +612,7 @@ mod tests {
|
||||
assert_eq!(system.get_state().await, AuditSystemState::Stopped);
|
||||
assert!(system.list_targets().await.is_empty());
|
||||
assert_eq!(system.runtime_status_snapshot().await, ReplayWorkerManager::new().snapshot(0));
|
||||
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(observer.close_call_count(), 1);
|
||||
assert_eq!(*system.config.read().await, Some(rustfs_config::server_config::Config(HashMap::new())));
|
||||
}
|
||||
|
||||
@@ -693,7 +634,7 @@ mod tests {
|
||||
// Seed a target + replay worker so both critical sections touch real state.
|
||||
{
|
||||
let mut registry = system.registry.lock().await;
|
||||
registry.add_target("primary:webhook".to_string(), Box::new(TestTarget::new("primary", "webhook")));
|
||||
registry.add_target("primary:webhook".to_string(), Box::new(MockTarget::new("primary", "webhook")));
|
||||
}
|
||||
{
|
||||
let mut replay_workers = system.stream_cancellers.write().await;
|
||||
@@ -793,8 +734,8 @@ mod tests {
|
||||
async fn commit_closes_old_targets_before_installing_new() {
|
||||
let system = AuditSystem::new();
|
||||
|
||||
let old = TestTarget::new("old", "webhook");
|
||||
let old_close = Arc::clone(&old.close_calls);
|
||||
let old = MockTarget::new("old", "webhook");
|
||||
let old_observer = old.clone();
|
||||
{
|
||||
let mut registry = system.registry.lock().await;
|
||||
registry.add_target("old:webhook".to_string(), Box::new(old));
|
||||
@@ -809,17 +750,17 @@ mod tests {
|
||||
*state = AuditSystemState::Running;
|
||||
}
|
||||
|
||||
let new = TestTarget::new("new", "webhook");
|
||||
let new_close = Arc::clone(&new.close_calls);
|
||||
let new = MockTarget::new("new", "webhook");
|
||||
let new_observer = new.clone();
|
||||
system
|
||||
.commit_runtime_targets(vec![Box::new(new)], AuditSystemState::Running)
|
||||
.await
|
||||
.expect("commit should succeed");
|
||||
|
||||
// Old target closed exactly once during the pre-install shutdown.
|
||||
assert_eq!(old_close.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(old_observer.close_call_count(), 1);
|
||||
// New target installed and left open.
|
||||
assert_eq!(new_close.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(new_observer.close_call_count(), 0);
|
||||
assert_eq!(system.list_targets().await, vec!["new:webhook".to_string()]);
|
||||
// Old replay worker stopped; the store-less new target adds none.
|
||||
assert_eq!(system.runtime_status_snapshot().await.replay_worker_count, 0);
|
||||
|
||||
@@ -12,136 +12,16 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rustfs_audit::{AuditEntry, AuditError, AuditPipeline, AuditRegistry, AuditRuntimeFacade, AuditRuntimeView};
|
||||
use rustfs_targets::arn::TargetID;
|
||||
use rustfs_targets::store::{Key, Store};
|
||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{SharedTarget, StoreError, Target, TargetError};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use rustfs_targets::SharedTarget;
|
||||
use rustfs_targets::testkit::MockTarget;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestTarget {
|
||||
close_calls: Arc<AtomicUsize>,
|
||||
id: TargetID,
|
||||
init_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl TestTarget {
|
||||
fn new(id: &str, name: &str) -> Self {
|
||||
Self {
|
||||
close_calls: Arc::new(AtomicUsize::new(0)),
|
||||
id: TargetID::new(id.to_string(), name.to_string()),
|
||||
init_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<E> Target<E> for TestTarget
|
||||
where
|
||||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
||||
{
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
self.close_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn init(&self) -> Result<(), TargetError> {
|
||||
self.init_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// A target whose `save()` always fails, used to exercise the dispatch
|
||||
/// Builds a target whose `save()` always fails, used to exercise the dispatch
|
||||
/// failure-propagation paths.
|
||||
#[derive(Clone)]
|
||||
struct FailingTarget {
|
||||
id: TargetID,
|
||||
save_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl FailingTarget {
|
||||
fn new(id: &str, name: &str) -> Self {
|
||||
Self {
|
||||
id: TargetID::new(id.to_string(), name.to_string()),
|
||||
save_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<E> Target<E> for FailingTarget
|
||||
where
|
||||
E: Send + Sync + 'static + Clone + Serialize + DeserializeOwned,
|
||||
{
|
||||
fn id(&self) -> TargetID {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
|
||||
self.save_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err(TargetError::Storage("disk full".to_string()))
|
||||
}
|
||||
|
||||
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn init(&self) -> Result<(), TargetError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn failing_target(id: &str, name: &str) -> MockTarget {
|
||||
MockTarget::new(id, name).with_save_failures(usize::MAX)
|
||||
}
|
||||
|
||||
fn pipeline_with_targets(targets: Vec<(&str, SharedTarget<AuditEntry>)>) -> AuditPipeline {
|
||||
@@ -154,8 +34,8 @@ fn pipeline_with_targets(targets: Vec<(&str, SharedTarget<AuditEntry>)>) -> Audi
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_pipeline_dispatch_propagates_total_failure() {
|
||||
let failing = FailingTarget::new("primary", "webhook");
|
||||
let save_calls = Arc::clone(&failing.save_calls);
|
||||
let failing = failing_target("primary", "webhook");
|
||||
let observer = failing.clone();
|
||||
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
|
||||
|
||||
let result = pipeline.dispatch(Arc::new(AuditEntry::default())).await;
|
||||
@@ -164,13 +44,13 @@ async fn audit_pipeline_dispatch_propagates_total_failure() {
|
||||
matches!(result, Err(AuditError::Target(_))),
|
||||
"dispatch must surface an error when every target fails, got {result:?}"
|
||||
);
|
||||
assert_eq!(save_calls.load(Ordering::SeqCst), 1, "the failing target should have been invoked");
|
||||
assert_eq!(observer.save_call_count(), 1, "the failing target should have been invoked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_pipeline_dispatch_tolerates_partial_failure() {
|
||||
let failing = FailingTarget::new("primary", "webhook");
|
||||
let healthy = TestTarget::new("secondary", "webhook");
|
||||
let failing = failing_target("primary", "webhook");
|
||||
let healthy = MockTarget::new("secondary", "webhook");
|
||||
let pipeline = pipeline_with_targets(vec![
|
||||
("primary:webhook", Arc::new(failing)),
|
||||
("secondary:webhook", Arc::new(healthy)),
|
||||
@@ -186,7 +66,7 @@ async fn audit_pipeline_dispatch_tolerates_partial_failure() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_pipeline_dispatch_batch_propagates_total_failure() {
|
||||
let failing = FailingTarget::new("primary", "webhook");
|
||||
let failing = failing_target("primary", "webhook");
|
||||
let pipeline = pipeline_with_targets(vec![("primary:webhook", Arc::new(failing))]);
|
||||
|
||||
let entries = vec![Arc::new(AuditEntry::default()), Arc::new(AuditEntry::default())];
|
||||
@@ -200,8 +80,8 @@ async fn audit_pipeline_dispatch_batch_propagates_total_failure() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_pipeline_dispatch_batch_tolerates_partial_failure() {
|
||||
let failing = FailingTarget::new("primary", "webhook");
|
||||
let healthy = TestTarget::new("secondary", "webhook");
|
||||
let failing = failing_target("primary", "webhook");
|
||||
let healthy = MockTarget::new("secondary", "webhook");
|
||||
let pipeline = pipeline_with_targets(vec![
|
||||
("primary:webhook", Arc::new(failing)),
|
||||
("secondary:webhook", Arc::new(healthy)),
|
||||
@@ -266,9 +146,8 @@ async fn audit_runtime_facade_activates_empty_target_list() {
|
||||
async fn audit_runtime_view_upsert_and_remove_target() {
|
||||
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
||||
let runtime_view = AuditRuntimeView::new(registry.clone());
|
||||
let target = TestTarget::new("primary", "webhook");
|
||||
let init_calls = Arc::clone(&target.init_calls);
|
||||
let close_calls = Arc::clone(&target.close_calls);
|
||||
let target = MockTarget::new("primary", "webhook");
|
||||
let observer = target.clone();
|
||||
|
||||
runtime_view
|
||||
.upsert_target("primary:webhook".to_string(), Box::new(target))
|
||||
@@ -276,7 +155,7 @@ async fn audit_runtime_view_upsert_and_remove_target() {
|
||||
.expect("upsert should succeed");
|
||||
|
||||
assert_eq!(runtime_view.list_targets().await, vec!["primary:webhook".to_string()]);
|
||||
assert_eq!(init_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(observer.init_call_count(), 1);
|
||||
|
||||
runtime_view
|
||||
.remove_target("primary:webhook")
|
||||
@@ -284,7 +163,7 @@ async fn audit_runtime_view_upsert_and_remove_target() {
|
||||
.expect("remove should succeed");
|
||||
|
||||
assert!(runtime_view.list_targets().await.is_empty());
|
||||
assert_eq!(close_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(observer.close_call_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -292,7 +171,7 @@ async fn audit_runtime_facade_replace_targets_commits_runtime_state() {
|
||||
let registry = Arc::new(Mutex::new(AuditRegistry::new()));
|
||||
let replay_workers = Arc::new(RwLock::new(rustfs_targets::ReplayWorkerManager::new()));
|
||||
let facade = AuditRuntimeFacade::new(registry.clone(), replay_workers.clone());
|
||||
let target = TestTarget::new("primary", "webhook");
|
||||
let target = MockTarget::new("primary", "webhook");
|
||||
let activation = rustfs_targets::RuntimeActivation {
|
||||
replay_workers: rustfs_targets::ReplayWorkerManager::new(),
|
||||
targets: vec![Arc::new(target) as rustfs_targets::SharedTarget<rustfs_audit::AuditEntry>],
|
||||
|
||||
+165
-8
@@ -41,14 +41,22 @@ pub const XXHASH_64_NAME: &str = "xxhash64";
|
||||
pub const XXHASH_128_NAME: &str = "xxhash128";
|
||||
pub const MD5_NAME: &str = "md5";
|
||||
|
||||
/// One of three deliberately separate checksum registries (backlog#1833):
|
||||
/// this enum owns the **streaming-hash algorithm registry**, including the
|
||||
/// RustFS extensions (sha512, xxhash3/64/128). The on-disk xl.meta bitset
|
||||
/// lives in `rustfs_rio::ChecksumType` (crates/rio/src/checksum.rs, varint
|
||||
/// bits are append-only), and the MinIO-port client keeps its own
|
||||
/// `ChecksumMode` (crates/ecstore/src/client/checksum.rs). When adding an
|
||||
/// algorithm, extend all three (or record why not) — they do not derive from
|
||||
/// each other.
|
||||
/// The canonical checksum-algorithm registry (backlog#1833, backlog#1844):
|
||||
/// this enum owns the streaming-hash implementations and, via the exhaustive
|
||||
/// per-algorithm metadata methods below, the wire names, header names, digest
|
||||
/// lengths, and checksum-type capabilities — including the RustFS extensions
|
||||
/// (sha512, xxhash3/64/128). The MinIO-port client's `ChecksumMode`
|
||||
/// (crates/s3-client/src/checksum.rs) delegates all per-algorithm dispatch
|
||||
/// here through its `algorithm()` bridge. The on-disk xl.meta bitset remains
|
||||
/// deliberately separate in `rustfs_rio::ChecksumType`
|
||||
/// (crates/rio/src/checksum.rs, varint bits are append-only), and rio also
|
||||
/// keeps its own hot-path hasher shells — equivalence with this crate's
|
||||
/// hashers is enforced by both test suites pinning the same official
|
||||
/// known-answer vectors (backlog#1844 PR3 verdict, recorded on
|
||||
/// `rustfs_rio::ChecksumType`). When adding an algorithm: add the variant
|
||||
/// here (the exhaustive matches force every metadata decision), bridge it in
|
||||
/// the client, and allocate an xl.meta bit + hasher + shared vector in rio
|
||||
/// (or record why not).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[non_exhaustive]
|
||||
pub enum ChecksumAlgorithm {
|
||||
@@ -120,6 +128,84 @@ impl ChecksumAlgorithm {
|
||||
Self::Xxhash128 => XXHASH_128_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
// Per-algorithm wire metadata. These matches are deliberately exhaustive
|
||||
// (no `_` arm): adding a ChecksumAlgorithm variant without deciding its
|
||||
// name, header, digest length, and checksum-type support must fail to
|
||||
// compile rather than silently inherit a default (backlog#1844).
|
||||
|
||||
/// The canonical `x-amz-checksum-algorithm` wire value (uppercase), as
|
||||
/// carried in S3 requests/responses and stored checksum maps.
|
||||
pub fn s3_algorithm_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Crc32 => "CRC32",
|
||||
Self::Crc32c => "CRC32C",
|
||||
Self::Crc64Nvme => "CRC64NVME",
|
||||
Self::Sha1 => "SHA1",
|
||||
Self::Sha256 => "SHA256",
|
||||
Self::Sha512 => "SHA512",
|
||||
Self::Xxhash3 => "XXHASH3",
|
||||
Self::Xxhash64 => "XXHASH64",
|
||||
Self::Xxhash128 => "XXHASH128",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `x-amz-checksum-*` HTTP header that carries this algorithm's
|
||||
/// base64-encoded digest.
|
||||
pub fn http_header_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Crc32 => http::CRC_32_HEADER_NAME,
|
||||
Self::Crc32c => http::CRC_32_C_HEADER_NAME,
|
||||
Self::Crc64Nvme => http::CRC_64_NVME_HEADER_NAME,
|
||||
Self::Sha1 => http::SHA_1_HEADER_NAME,
|
||||
Self::Sha256 => http::SHA_256_HEADER_NAME,
|
||||
Self::Sha512 => http::SHA_512_HEADER_NAME,
|
||||
Self::Xxhash3 => http::XXHASH_3_HEADER_NAME,
|
||||
Self::Xxhash64 => http::XXHASH_64_HEADER_NAME,
|
||||
Self::Xxhash128 => http::XXHASH_128_HEADER_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw (unencoded) digest length in bytes.
|
||||
pub fn raw_len(&self) -> usize {
|
||||
match self {
|
||||
Self::Crc32 | Self::Crc32c => 4,
|
||||
Self::Crc64Nvme => 8,
|
||||
Self::Sha1 => 20,
|
||||
Self::Sha256 => 32,
|
||||
Self::Sha512 => 64,
|
||||
Self::Xxhash3 | Self::Xxhash64 => 8,
|
||||
Self::Xxhash128 => 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the algorithm supports the S3 COMPOSITE multipart checksum
|
||||
/// type. Per the AWS registry, every algorithm does except CRC64NVME,
|
||||
/// which is FULL_OBJECT-only.
|
||||
pub fn supports_composite(&self) -> bool {
|
||||
match self {
|
||||
Self::Crc64Nvme => false,
|
||||
Self::Crc32
|
||||
| Self::Crc32c
|
||||
| Self::Sha1
|
||||
| Self::Sha256
|
||||
| Self::Sha512
|
||||
| Self::Xxhash3
|
||||
| Self::Xxhash64
|
||||
| Self::Xxhash128 => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the algorithm supports the S3 FULL_OBJECT checksum type, i.e.
|
||||
/// part digests can be linearly combined into the whole-object digest.
|
||||
/// Only the CRC family has this property; the hash algorithms are
|
||||
/// COMPOSITE-only.
|
||||
pub fn supports_full_object(&self) -> bool {
|
||||
match self {
|
||||
Self::Crc32 | Self::Crc32c | Self::Crc64Nvme => true,
|
||||
Self::Sha1 | Self::Sha256 | Self::Sha512 | Self::Xxhash3 | Self::Xxhash64 | Self::Xxhash128 => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Checksum: Send + Sync {
|
||||
@@ -731,6 +817,77 @@ mod tests {
|
||||
assert_eq!(&raw[..], reference.digest128().to_be_bytes().as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_algorithm_metadata_is_consistent_for_every_variant() {
|
||||
use crate::Checksum;
|
||||
|
||||
// Cross-checks the per-algorithm metadata methods against the hasher
|
||||
// implementations themselves, so the registry cannot drift from the
|
||||
// code that computes digests (backlog#1844). The list must cover every
|
||||
// variant; the metadata methods use exhaustive matches, so a new
|
||||
// variant that is missing here still fails to compile there first.
|
||||
let all = [
|
||||
ChecksumAlgorithm::Crc32,
|
||||
ChecksumAlgorithm::Crc32c,
|
||||
ChecksumAlgorithm::Crc64Nvme,
|
||||
ChecksumAlgorithm::Sha1,
|
||||
ChecksumAlgorithm::Sha256,
|
||||
ChecksumAlgorithm::Sha512,
|
||||
ChecksumAlgorithm::Xxhash3,
|
||||
ChecksumAlgorithm::Xxhash64,
|
||||
ChecksumAlgorithm::Xxhash128,
|
||||
];
|
||||
|
||||
for algorithm in all {
|
||||
// Digest length must match what the hasher actually produces.
|
||||
let mut hasher = algorithm.into_impl();
|
||||
hasher.update(b"metadata consistency probe");
|
||||
assert_eq!(
|
||||
algorithm.raw_len(),
|
||||
Checksum::size(&*algorithm.into_impl()) as usize,
|
||||
"{algorithm:?} raw_len() != hasher size()"
|
||||
);
|
||||
assert_eq!(hasher.finalize().len(), algorithm.raw_len(), "{algorithm:?} finalize length != raw_len()");
|
||||
|
||||
// Header name must match the hasher's own header binding.
|
||||
assert_eq!(
|
||||
algorithm.http_header_name(),
|
||||
algorithm.into_impl().header_name(),
|
||||
"{algorithm:?} http_header_name() != HttpChecksum::header_name()"
|
||||
);
|
||||
assert_eq!(
|
||||
algorithm.http_header_name(),
|
||||
format!("x-amz-checksum-{}", algorithm.as_str()),
|
||||
"{algorithm:?} header must be x-amz-checksum-<name>"
|
||||
);
|
||||
|
||||
// The uppercase wire name and the lowercase parse name must be the
|
||||
// same word, and the wire name must parse back to the variant.
|
||||
assert!(
|
||||
algorithm.s3_algorithm_name().eq_ignore_ascii_case(algorithm.as_str()),
|
||||
"{algorithm:?} s3_algorithm_name() and as_str() diverge"
|
||||
);
|
||||
assert_eq!(algorithm.s3_algorithm_name().parse::<ChecksumAlgorithm>().unwrap(), algorithm);
|
||||
}
|
||||
|
||||
// AWS checksum-type support table: CRC64NVME is FULL_OBJECT-only, the
|
||||
// CRC family supports FULL_OBJECT, everything else is COMPOSITE-only.
|
||||
for algorithm in all {
|
||||
let composite = algorithm.supports_composite();
|
||||
let full_object = algorithm.supports_full_object();
|
||||
assert!(composite || full_object, "{algorithm:?} supports no checksum type at all");
|
||||
match algorithm {
|
||||
ChecksumAlgorithm::Crc32 | ChecksumAlgorithm::Crc32c => {
|
||||
assert!(composite && full_object, "{algorithm:?} must support both checksum types")
|
||||
}
|
||||
ChecksumAlgorithm::Crc64Nvme => {
|
||||
assert!(!composite && full_object, "CRC64NVME must be FULL_OBJECT-only")
|
||||
}
|
||||
_ => assert!(composite && !full_object, "{algorithm:?} must be COMPOSITE-only"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_xxhash64_matches_direct_computation_big_endian_seed0() {
|
||||
use crate::Xxhash64;
|
||||
|
||||
@@ -39,10 +39,6 @@ tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
metrics = { workspace = true }
|
||||
# Transitional shim deps (backlog#1843): dropped with the re-export shims once
|
||||
# every consumer imports the contracts crates directly.
|
||||
rustfs-heal-contracts = { workspace = true }
|
||||
rustfs-scanner-contracts = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
|
||||
@@ -27,10 +27,6 @@ pub static GLOBAL_ROOT_CERT: LazyLock<RwLock<Option<Vec<u8>>>> = LazyLock::new(|
|
||||
pub static GLOBAL_MTLS_IDENTITY: LazyLock<RwLock<Option<MtlsIdentityPem>>> = LazyLock::new(|| RwLock::new(None));
|
||||
pub static GLOBAL_OUTBOUND_TLS_GENERATION: LazyLock<AtomicU64> = LazyLock::new(|| AtomicU64::new(0));
|
||||
|
||||
// Transitional re-export shim (backlog#1843): the node init-time global moved
|
||||
// to rustfs-scanner-contracts, whose metrics report reads it directly.
|
||||
pub use rustfs_scanner_contracts::{GLOBAL_INIT_TIME, get_global_init_time, set_global_init_time_now};
|
||||
|
||||
/// Log level to use when reporting cached gRPC connection eviction.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ConnectionEvictionLogLevel {
|
||||
|
||||
@@ -19,13 +19,6 @@ mod readiness;
|
||||
pub mod table_catalog;
|
||||
pub mod trace_bus;
|
||||
|
||||
// Transitional re-export shims (backlog#1843): these modules moved to the
|
||||
// rustfs-heal-contracts / rustfs-scanner-contracts crates. Consumers migrate
|
||||
// to the new paths crate by crate; the shims are deleted once
|
||||
// `rg 'rustfs_common::(metrics|heal_channel|last_minute)'` reports zero hits.
|
||||
pub use rustfs_heal_contracts::heal_channel;
|
||||
pub use rustfs_scanner_contracts::{last_minute, metrics};
|
||||
|
||||
pub use globals::*;
|
||||
pub use readiness::{GlobalReadiness, SystemStage};
|
||||
|
||||
|
||||
@@ -121,10 +121,10 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
urlencoding.workspace = true
|
||||
walkdir.workspace = true
|
||||
base64 = { workspace = true }
|
||||
base64-simd = { workspace = true }
|
||||
rand = { workspace = true, features = ["serde"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
hex = { workspace = true }
|
||||
hex-simd = { workspace = true }
|
||||
md-5 = { workspace = true }
|
||||
opentelemetry-proto = { workspace = true }
|
||||
prost.workspace = true
|
||||
|
||||
@@ -24,7 +24,6 @@ mod tests {
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use base64::Engine;
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||
use sha2::Sha256;
|
||||
@@ -74,12 +73,12 @@ mod tests {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(body);
|
||||
let digest = hasher.finalize();
|
||||
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
||||
base64_simd::STANDARD.encode_to_string(digest.as_slice())
|
||||
}
|
||||
|
||||
fn checksum_sha256_base64(body: &[u8]) -> String {
|
||||
let digest = Sha256::digest(body);
|
||||
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
||||
base64_simd::STANDARD.encode_to_string(digest.as_slice())
|
||||
}
|
||||
|
||||
fn checksum_crc64nvme_base64(body: &[u8]) -> String {
|
||||
|
||||
@@ -652,11 +652,10 @@ const MPU_SSE_COMPRESSION_BUCKET: &str = "compression-mpu-sse-bucket";
|
||||
async fn start_rustfs_with_compression_and_sse(
|
||||
env: &mut RustFSTestEnvironment,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use base64::Engine;
|
||||
env.cleanup_existing_processes().await?;
|
||||
|
||||
let binary_path = rustfs_binary_path();
|
||||
let master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
// Server output goes to a file inside the per-test temp dir so a failing
|
||||
// run can be diagnosed from the child's logs.
|
||||
let server_log = std::fs::File::create(format!("{}/server.log", env.temp_dir))?;
|
||||
|
||||
@@ -27,8 +27,7 @@ mod tests {
|
||||
VersioningConfiguration,
|
||||
};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64_simd::STANDARD as BASE64;
|
||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::info;
|
||||
@@ -465,7 +464,7 @@ mod tests {
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
let content = b"deterministic synthetic payload for copy-object checksum #4996";
|
||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
|
||||
|
||||
client
|
||||
.put_object()
|
||||
@@ -534,7 +533,7 @@ mod tests {
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
let content = b"another deterministic payload whose source checksum must survive the copy";
|
||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
|
||||
|
||||
// Store the source WITH a SHA-256 checksum so it has one to preserve.
|
||||
let put_src = client
|
||||
@@ -614,7 +613,7 @@ mod tests {
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
let content = b"payload whose copy must be re-checksummed with a different algorithm";
|
||||
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||
let expected_sha256 = BASE64.encode_to_string(Sha256::digest(content));
|
||||
|
||||
// Source is stored WITH a SHA-256 checksum.
|
||||
client
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
//! It was fixed in three layers on `main`, each with its own *unit* regression:
|
||||
//! * rustfs#4594 — `GetObjectStreamingReader::poll_read` now returns
|
||||
//! `UnexpectedEof` on a short body instead of a clean `Ok(())`
|
||||
//! (`rustfs/src/app/object_usecase.rs`,
|
||||
//! `app::object_usecase::tests::get_object_streaming_reader_errors_on_short_eof`).
|
||||
//! (`rustfs/src/app/object/get.rs`,
|
||||
//! `app::object::get::tests::get_object_streaming_reader_errors_on_short_eof`).
|
||||
//! * rustfs#4560 — the lazy multipart codec reader degrades a later part to
|
||||
//! the legacy per-part decode in place, and surfaces reconstruction errors
|
||||
//! instead of silently truncating
|
||||
|
||||
@@ -1113,7 +1113,7 @@ fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] {
|
||||
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(input.as_ref());
|
||||
hex::encode(hasher.finalize())
|
||||
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
|
||||
fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result {
|
||||
@@ -1375,7 +1375,7 @@ impl S3 for FakeBackend {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
||||
hex::encode(digest)
|
||||
hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
};
|
||||
let version = ObjectVersion {
|
||||
@@ -1660,7 +1660,7 @@ impl S3 for FakeBackend {
|
||||
}
|
||||
let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?;
|
||||
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
||||
let e_tag = hex::encode(digest);
|
||||
let e_tag = hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower);
|
||||
let mut state = lock(&self.store);
|
||||
let existing_bytes = state
|
||||
.uploads
|
||||
|
||||
@@ -28,7 +28,6 @@ use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ExpirationStatus,
|
||||
LifecycleRule, LifecycleRuleFilter, ServerSideEncryption, Transition, TransitionStorageClass, VersioningConfiguration,
|
||||
};
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use flate2::read::GzDecoder;
|
||||
use http::header::{CONTENT_ENCODING, HOST};
|
||||
@@ -1808,7 +1807,7 @@ async fn four_node_inline_fallback_controls() -> TestResult {
|
||||
let collector = OtlpMetricCollector::start().await?;
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
configure_reader_metric_cluster(&mut cluster, &collector);
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", &sse_master_key);
|
||||
cluster.start().await?;
|
||||
|
||||
@@ -2017,7 +2016,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls() -> Te
|
||||
|
||||
let collector = OtlpMetricCollector::start().await?;
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
cluster.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
|
||||
cluster.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
||||
cluster.set_env("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true");
|
||||
@@ -2489,7 +2488,7 @@ async fn four_node_mixed_msgpack_compat_mode_preserves_fallback_controls_during_
|
||||
hot.set_env("RUSTFS_SCANNER_CYCLE", "1");
|
||||
hot.set_env("RUSTFS_ILM_PROCESS_TIME", "1");
|
||||
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
hot.set_env("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key);
|
||||
hot.set_env("RUSTFS_COMPRESSION_ENABLED", "true");
|
||||
hot.start().await?;
|
||||
|
||||
@@ -27,7 +27,7 @@ use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use base64_simd::STANDARD as BASE64;
|
||||
use http::header::{CONTENT_TYPE, HOST};
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
@@ -64,7 +64,7 @@ pub fn init_logging() {
|
||||
pub fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(key.as_bytes());
|
||||
BASE64.encode(hasher.finalize())
|
||||
BASE64.encode_to_string(hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn assert_s3_error<T, E>(result: Result<T, SdkError<E>>, status: u16, code: &str, message: &str, context: &str)
|
||||
@@ -365,7 +365,7 @@ pub async fn create_key_with_specific_id(key_dir: &str, key_id: &str) -> Result<
|
||||
"created_at": format!("{}[UTC]", chrono::Utc::now().to_rfc3339()),
|
||||
"rotated_at": serde_json::Value::Null,
|
||||
"created_by": "e2e-test",
|
||||
"encrypted_key_material": BASE64.encode(key_data),
|
||||
"encrypted_key_material": BASE64.encode_to_string(key_data),
|
||||
"nonce": Vec::<u8>::new()
|
||||
});
|
||||
|
||||
@@ -383,7 +383,7 @@ pub async fn test_sse_c_encryption(s3_client: &Client, bucket: &str) -> Result<(
|
||||
info!("Testing SSE-C encryption");
|
||||
|
||||
let test_key = "01234567890123456789012345678901"; // 32-byte key
|
||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
||||
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||
let test_data = b"Hello, KMS SSE-C World!";
|
||||
let object_key = "test-sse-c-object";
|
||||
@@ -551,8 +551,8 @@ pub async fn test_error_scenarios(s3_client: &Client, bucket: &str) -> Result<()
|
||||
// Test SSE-C with wrong key for download
|
||||
let test_key = "01234567890123456789012345678901";
|
||||
let wrong_key = "98765432109876543210987654321098";
|
||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
||||
let wrong_key_b64 = base64::engine::general_purpose::STANDARD.encode(wrong_key);
|
||||
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||
let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key);
|
||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
|
||||
let test_data = b"Test data for error scenarios";
|
||||
@@ -807,7 +807,7 @@ pub async fn test_multipart_upload_with_config(
|
||||
// Prepare encryption parameters
|
||||
let (sse_c_key_b64, sse_c_key_md5) = match &config.encryption_type {
|
||||
EncryptionType::SSEC { key, key_md5 } => {
|
||||
let key_b64 = base64::engine::general_purpose::STANDARD.encode(key);
|
||||
let key_b64 = base64_simd::STANDARD.encode_to_string(key);
|
||||
(Some(key_b64), Some(key_md5.clone()))
|
||||
}
|
||||
_ => (None, None),
|
||||
|
||||
@@ -177,7 +177,7 @@ async fn test_comprehensive_key_isolation() -> Result<(), Box<dyn std::error::Er
|
||||
// Verify that files cannot be read with wrong keys
|
||||
info!("🔒 Verify key isolation");
|
||||
let wrong_key = "11111111111111111111111111111111";
|
||||
let wrong_key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, wrong_key);
|
||||
let wrong_key_b64 = base64_simd::STANDARD.encode_to_string(wrong_key);
|
||||
let wrong_key_md5 = sse_customer_key_md5_base64(wrong_key);
|
||||
|
||||
// Try to read file encrypted with key1 using wrong key
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
use super::common::{LocalKMSTestEnvironment, SSE_C_KEY_MISMATCH_MESSAGE, assert_s3_error, sse_customer_key_md5_base64};
|
||||
use crate::common::{TEST_BUCKET, init_logging};
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
use base64::Engine;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{info, warn};
|
||||
@@ -68,7 +67,7 @@ async fn test_kms_zero_byte_file_encryption() -> Result<(), Box<dyn std::error::
|
||||
// Test SSE-C with zero-byte file
|
||||
info!("📤 Testing SSE-C with zero-byte file");
|
||||
let test_key = "01234567890123456789012345678901";
|
||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
||||
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||
let object_key_c = "zero-byte-sse-c";
|
||||
|
||||
@@ -161,7 +160,7 @@ async fn test_kms_single_byte_file_encryption() -> Result<(), Box<dyn std::error
|
||||
// Test SSE-C with single byte
|
||||
info!("📤 Testing SSE-C with single-byte file");
|
||||
let test_key = "01234567890123456789012345678901";
|
||||
let test_key_b64 = base64::engine::general_purpose::STANDARD.encode(test_key);
|
||||
let test_key_b64 = base64_simd::STANDARD.encode_to_string(test_key);
|
||||
let test_key_md5 = sse_customer_key_md5_base64(test_key);
|
||||
let object_key_c = "single-byte-sse-c";
|
||||
|
||||
@@ -287,7 +286,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
||||
// Test 1: Invalid key length for SSE-C
|
||||
info!("🔍 Testing invalid SSE-C key length");
|
||||
let invalid_short_key = "short"; // Too short
|
||||
let invalid_key_b64 = base64::engine::general_purpose::STANDARD.encode(invalid_short_key);
|
||||
let invalid_key_b64 = base64_simd::STANDARD.encode_to_string(invalid_short_key);
|
||||
let invalid_key_md5 = sse_customer_key_md5_base64(invalid_short_key);
|
||||
|
||||
let invalid_key_result = s3_client
|
||||
@@ -325,7 +324,7 @@ async fn test_kms_invalid_key_scenarios() -> Result<(), Box<dyn std::error::Erro
|
||||
// Test 2: Mismatched MD5 for SSE-C
|
||||
info!("🔍 Testing mismatched MD5 for SSE-C key");
|
||||
let valid_key = "01234567890123456789012345678901";
|
||||
let valid_key_b64 = base64::engine::general_purpose::STANDARD.encode(valid_key);
|
||||
let valid_key_b64 = base64_simd::STANDARD.encode_to_string(valid_key);
|
||||
let wrong_md5 = sse_customer_key_md5_base64("98765432109876543210987654321098");
|
||||
|
||||
let wrong_md5_result = s3_client
|
||||
@@ -465,7 +464,7 @@ async fn test_kms_concurrent_encryption() -> Result<(), Box<dyn std::error::Erro
|
||||
2 => {
|
||||
// SSE-C
|
||||
let key = format!("testkey{i:026}"); // 32-byte key
|
||||
let key_b64 = base64::engine::general_purpose::STANDARD.encode(&key);
|
||||
let key_b64 = base64_simd::STANDARD.encode_to_string(&key);
|
||||
let key_md5 = sse_customer_key_md5_base64(&key);
|
||||
|
||||
client
|
||||
@@ -535,8 +534,8 @@ async fn test_kms_key_validation_security() -> Result<(), Box<dyn std::error::Er
|
||||
let key1 = "key1key1key1key1key1key1key1key1"; // 32 bytes
|
||||
let key2 = "key2key2key2key2key2key2key2key2"; // 32 bytes
|
||||
|
||||
let key1_b64 = base64::engine::general_purpose::STANDARD.encode(key1);
|
||||
let key2_b64 = base64::engine::general_purpose::STANDARD.encode(key2);
|
||||
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
|
||||
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
|
||||
let key1_md5 = sse_customer_key_md5_base64(key1);
|
||||
let key2_md5 = sse_customer_key_md5_base64(key2);
|
||||
|
||||
|
||||
@@ -138,8 +138,8 @@ async fn test_local_kms_key_isolation() {
|
||||
// Test that different SSE-C keys create isolated encrypted objects
|
||||
let key1 = "01234567890123456789012345678901";
|
||||
let key2 = "98765432109876543210987654321098";
|
||||
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
|
||||
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
|
||||
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
|
||||
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
|
||||
let key1_md5 = sse_customer_key_md5_base64(key1);
|
||||
let key2_md5 = sse_customer_key_md5_base64(key2);
|
||||
|
||||
@@ -565,7 +565,7 @@ async fn test_multipart_upload_with_sse_c(
|
||||
|
||||
// SSE-C encryption key
|
||||
let encryption_key = "01234567890123456789012345678901";
|
||||
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, encryption_key);
|
||||
let key_b64 = base64_simd::STANDARD.encode_to_string(encryption_key);
|
||||
let key_md5 = sse_customer_key_md5_base64(encryption_key);
|
||||
|
||||
// Generate test data
|
||||
|
||||
@@ -127,8 +127,8 @@ async fn test_vault_kms_key_isolation() -> Result<(), Box<dyn std::error::Error
|
||||
|
||||
let key1 = "01234567890123456789012345678901";
|
||||
let key2 = "98765432109876543210987654321098";
|
||||
let key1_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key1);
|
||||
let key2_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key2);
|
||||
let key1_b64 = base64_simd::STANDARD.encode_to_string(key1);
|
||||
let key2_b64 = base64_simd::STANDARD.encode_to_string(key2);
|
||||
let key1_md5 = sse_customer_key_md5_base64(key1);
|
||||
let key2_md5 = sse_customer_key_md5_base64(key2);
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ async fn test_multipart_encryption_type(
|
||||
// Prepare SSE-C keys when required
|
||||
let (sse_c_key, sse_c_md5) = if matches!(encryption_type, EncryptionType::SSEC) {
|
||||
let key = "01234567890123456789012345678901";
|
||||
let key_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key);
|
||||
let key_b64 = base64_simd::STANDARD.encode_to_string(key);
|
||||
let key_md5 = sse_customer_key_md5_base64(key);
|
||||
(Some(key_b64), Some(key_md5))
|
||||
} else {
|
||||
|
||||
@@ -22,7 +22,6 @@ use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
||||
};
|
||||
use base64::Engine;
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use flate2::{Compression, write::GzEncoder};
|
||||
use http::HeaderValue;
|
||||
@@ -47,19 +46,19 @@ fn encode_post_policy(conditions: Vec<serde_json::Value>) -> String {
|
||||
"conditions": conditions,
|
||||
});
|
||||
|
||||
base64::engine::general_purpose::STANDARD.encode(policy.to_string())
|
||||
base64_simd::STANDARD.encode_to_string(policy.to_string())
|
||||
}
|
||||
|
||||
fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(key.as_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
|
||||
base64_simd::STANDARD.encode_to_string(hasher.finalize())
|
||||
}
|
||||
|
||||
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(input.as_ref());
|
||||
hex::encode(hasher.finalize())
|
||||
hex_simd::encode_to_string(hasher.finalize(), hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
|
||||
async fn create_restricted_user(
|
||||
@@ -97,7 +96,7 @@ fn restricted_user_client(env: &RustFSTestEnvironment, username: &str, secret_ke
|
||||
const LOCAL_SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||
|
||||
fn local_sse_master_key_value() -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode([0x42u8; 32])
|
||||
base64_simd::STANDARD.encode_to_string([0x42u8; 32])
|
||||
}
|
||||
|
||||
async fn make_tar(files: &[(&str, &[u8])], dirs: &[&str]) -> Vec<u8> {
|
||||
@@ -1887,7 +1886,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition
|
||||
let object_key = "sse-c-object.txt";
|
||||
let expected_body = b"anonymous-post-sse-c".to_vec();
|
||||
let customer_key = "01234567890123456789012345678901";
|
||||
let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key);
|
||||
let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
@@ -1941,7 +1940,7 @@ async fn test_anonymous_post_object_allows_sse_c_fields_outside_policy_condition
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(base64::engine::general_purpose::STANDARD.encode(customer_key))
|
||||
.sse_customer_key(base64_simd::STANDARD.encode_to_string(customer_key))
|
||||
.sse_customer_key_md5(customer_key_md5)
|
||||
.send()
|
||||
.await?;
|
||||
@@ -1963,8 +1962,8 @@ async fn test_anonymous_post_object_rejects_sse_c_exact_policy_mismatch() -> Res
|
||||
let object_key = "sse-c-mismatch-object.txt";
|
||||
let policy_key = "01234567890123456789012345678901";
|
||||
let request_key = "abcdefghijklmnopqrstuvwxyzABCDEF";
|
||||
let policy_key_b64 = base64::engine::general_purpose::STANDARD.encode(policy_key);
|
||||
let request_key_b64 = base64::engine::general_purpose::STANDARD.encode(request_key);
|
||||
let policy_key_b64 = base64_simd::STANDARD.encode_to_string(policy_key);
|
||||
let request_key_b64 = base64_simd::STANDARD.encode_to_string(request_key);
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
@@ -3526,7 +3525,7 @@ async fn test_signed_put_object_extract_preserves_sse_s3_and_redirect() -> Resul
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
@@ -3799,7 +3798,7 @@ async fn test_signed_put_object_extract_uses_bucket_default_sse_s3() -> Result<(
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let sse_master_key = base64::engine::general_purpose::STANDARD.encode([0x42u8; 32]);
|
||||
let sse_master_key = base64_simd::STANDARD.encode_to_string([0x42u8; 32]);
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SSE_S3_MASTER_KEY", sse_master_key.as_str())])
|
||||
.await?;
|
||||
|
||||
@@ -3925,7 +3924,7 @@ async fn test_signed_put_object_extract_preserves_sse_c() -> Result<(), Box<dyn
|
||||
let extracted_key = "nested/file.txt";
|
||||
let expected_body = b"extract-sse-c-body".to_vec();
|
||||
let customer_key = "01234567890123456789012345678901";
|
||||
let customer_key_b64 = base64::engine::general_purpose::STANDARD.encode(customer_key);
|
||||
let customer_key_b64 = base64_simd::STANDARD.encode_to_string(customer_key);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
|
||||
|
||||
let client = env.create_s3_client();
|
||||
|
||||
@@ -87,7 +87,9 @@ fn valid_config() -> PresigningConfig {
|
||||
}
|
||||
|
||||
/// Flip bytes inside the `X-Amz-Signature=` query value without changing its
|
||||
/// length, producing a structurally valid but incorrect signature.
|
||||
/// length, producing a structurally valid but incorrect signature. Every hex
|
||||
/// digit is replaced by its complement (15 - v), which has no fixed point, so
|
||||
/// the tamper changes the value no matter which digits the signature contains.
|
||||
fn tamper_signature(uri: &str) -> String {
|
||||
let marker = "X-Amz-Signature=";
|
||||
let idx = uri.find(marker).expect("presigned uri must carry X-Amz-Signature") + marker.len();
|
||||
@@ -96,10 +98,9 @@ fn tamper_signature(uri: &str) -> String {
|
||||
let (sig, tail) = rest.split_at(end);
|
||||
let tampered: String = sig
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'0' => 'f',
|
||||
'a' => '0',
|
||||
other => other,
|
||||
.map(|c| {
|
||||
let v = c.to_digit(16).expect("X-Amz-Signature value must be hex");
|
||||
char::from_digit(15 - v, 16).expect("complement of a hex digit is a hex digit")
|
||||
})
|
||||
.collect();
|
||||
assert_ne!(sig, tampered, "tamper must actually change the signature hex");
|
||||
|
||||
@@ -35,7 +35,6 @@ use crate::common::local_http_client;
|
||||
use crate::common::rustfs_binary_path_with_features;
|
||||
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use http::header::{CONTENT_TYPE, HOST};
|
||||
use reqwest::Client;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
@@ -64,7 +63,7 @@ fn basic_auth_header() -> String {
|
||||
|
||||
fn basic_auth_header_for(access_key: &str, secret_key: &str) -> String {
|
||||
let credentials = format!("{}:{}", access_key, secret_key);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
|
||||
let encoded = base64_simd::STANDARD.encode_to_string(credentials);
|
||||
format!("Basic {}", encoded)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,9 @@ use rustfs_protos::{
|
||||
proto_gen::node_service::{
|
||||
BatchGenerallyLockRequest, BatchGenerallyLockResponse, BatchReadVersionRequest, BatchReadVersionResponse,
|
||||
GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult, PingRequest, PingResponse,
|
||||
SnapshotLeaseMutationResponse, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
|
||||
SnapshotLeaseResponse, node_service_server::NodeService,
|
||||
ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseReleaseResponse, ScannerPublicationLeaseRequest,
|
||||
ScannerPublicationLeaseResponse, SnapshotLeaseMutationResponse, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest,
|
||||
SnapshotLeaseRequest, SnapshotLeaseResponse, node_service_server::NodeService,
|
||||
},
|
||||
};
|
||||
use std::pin::Pin;
|
||||
@@ -126,6 +127,20 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||
}
|
||||
|
||||
async fn acquire_scanner_publication_lease(
|
||||
&self,
|
||||
_request: Request<ScannerPublicationLeaseRequest>,
|
||||
) -> Result<Response<ScannerPublicationLeaseResponse>, Status> {
|
||||
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||
}
|
||||
|
||||
async fn release_scanner_publication_lease(
|
||||
&self,
|
||||
_request: Request<ScannerPublicationLeaseReleaseRequest>,
|
||||
) -> Result<Response<ScannerPublicationLeaseReleaseResponse>, Status> {
|
||||
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
|
||||
}
|
||||
|
||||
async fn lock(&self, request: Request<GenerallyLockRequest>) -> Result<Response<GenerallyLockResponse>, Status> {
|
||||
let request = request.into_inner();
|
||||
let args: LockRequest = match serde_json::from_str(&request.args) {
|
||||
|
||||
@@ -34,7 +34,7 @@ use aws_sdk_s3::types::{
|
||||
VersioningConfiguration,
|
||||
};
|
||||
use aws_sdk_s3::{Client, Config};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use base64_simd::STANDARD as BASE64_STANDARD;
|
||||
use bytes::Bytes;
|
||||
use flate2::read::GzDecoder;
|
||||
use futures::{Stream, StreamExt};
|
||||
@@ -1244,7 +1244,7 @@ async fn wait_for_source_replication_pending_or_failed(
|
||||
}
|
||||
|
||||
async fn wait_for_source_replication_status(client: &Client, bucket: &str, key: &str, expected: &str, ssec: bool) -> TestResult {
|
||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
||||
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||
let wait = async {
|
||||
loop {
|
||||
@@ -1339,7 +1339,7 @@ async fn assert_failed_replication_stays_absent_for(
|
||||
ssec: bool,
|
||||
duration: Duration,
|
||||
) -> TestResult {
|
||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
||||
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||
let wait = async {
|
||||
let deadline = tokio::time::Instant::now() + duration;
|
||||
@@ -4332,7 +4332,7 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
||||
let target_client = target_env.create_s3_client();
|
||||
let key = "ssec-contract.txt";
|
||||
let body = b"repl-17 SSE-C payload";
|
||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
||||
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||
|
||||
source_client
|
||||
@@ -4387,7 +4387,7 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
||||
);
|
||||
|
||||
// A wrong customer key must fail too.
|
||||
let wrong_key = BASE64_STANDARD.encode("99999999999999999999999999999999");
|
||||
let wrong_key = BASE64_STANDARD.encode_to_string("99999999999999999999999999999999");
|
||||
let wrong_key_md5 = sse_customer_key_md5_base64("99999999999999999999999999999999");
|
||||
let wrong_read = target_client
|
||||
.get_object()
|
||||
@@ -4423,7 +4423,7 @@ async fn test_bucket_replication_sse_c_multipart_passthrough() -> TestResult {
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
let key = "ssec-mp-contract.bin";
|
||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
||||
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||
|
||||
let created = source_client
|
||||
@@ -4568,7 +4568,7 @@ async fn test_ssec_replication_fails_closed_when_target_drops_passthrough_header
|
||||
.await?;
|
||||
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||
|
||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
||||
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||
let put_ssec = |key: &'static str| {
|
||||
source_client
|
||||
@@ -4741,7 +4741,7 @@ async fn test_bucket_replication_sse_c_heals_after_target_outage() -> TestResult
|
||||
let source_client = source_env.create_s3_client();
|
||||
let key = "ssec-heal-contract.txt";
|
||||
let body = b"repl-22 ssec heal payload".to_vec();
|
||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
||||
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||
|
||||
// Target outage: the SSE-C write cannot replicate.
|
||||
@@ -4856,7 +4856,7 @@ async fn test_bucket_replication_sse_c_existing_object_resync() -> TestResult {
|
||||
// The SSE-C object exists before any replication wiring.
|
||||
let key = "ssec-existing-contract.txt";
|
||||
let body = b"repl-22 ssec existing-object payload".to_vec();
|
||||
let customer_key = BASE64_STANDARD.encode(REPL17_SSEC_KEY);
|
||||
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||
source_client
|
||||
.put_object()
|
||||
@@ -9152,7 +9152,7 @@ async fn test_get_and_head_proxy_unreplicated_object_to_replication_target() ->
|
||||
// the real SSE-C decryption; the plaintext fake simply ignores them).
|
||||
target.take_requests();
|
||||
let ssec_key = "01234567890123456789012345678901";
|
||||
let ssec_key_b64 = BASE64_STANDARD.encode(ssec_key);
|
||||
let ssec_key_b64 = BASE64_STANDARD.encode_to_string(ssec_key);
|
||||
let ssec_key_md5 = sse_customer_key_md5_base64(ssec_key);
|
||||
let _ = source_client
|
||||
.get_object()
|
||||
|
||||
@@ -21,7 +21,6 @@ use aws_sdk_s3::error::BoxError;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use base64::Engine;
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -107,8 +106,8 @@ fn customer_key(byte: u8) -> CustomerKey {
|
||||
hasher.update(raw);
|
||||
CustomerKey {
|
||||
raw: String::from_utf8_lossy(&raw).into_owned(),
|
||||
encoded: base64::engine::general_purpose::STANDARD.encode(raw),
|
||||
md5: base64::engine::general_purpose::STANDARD.encode(hasher.finalize()),
|
||||
encoded: base64_simd::STANDARD.encode_to_string(raw),
|
||||
md5: base64_simd::STANDARD.encode_to_string(hasher.finalize()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ keywords = ["erasure-coding", "storage", "rustfs", "Minio", "solomon"]
|
||||
categories = ["web-programming", "development-tools", "filesystem"]
|
||||
documentation = "https://docs.rs/rustfs-ecstore/latest/rustfs_ecstore/"
|
||||
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -45,7 +44,6 @@ hotpath = [
|
||||
"hotpath/async-channel",
|
||||
"hotpath/parking_lot",
|
||||
"hotpath/reqwest-0-13",
|
||||
"rustfs-checksums/hotpath",
|
||||
"rustfs-common/hotpath",
|
||||
"rustfs-concurrency/hotpath",
|
||||
"rustfs-config/hotpath",
|
||||
@@ -63,16 +61,13 @@ hotpath = [
|
||||
"rustfs-rio/hotpath",
|
||||
"rustfs-rio-v2?/hotpath",
|
||||
"rustfs-s3-types/hotpath",
|
||||
"rustfs-signer/hotpath",
|
||||
"rustfs-storage-api/hotpath",
|
||||
"rustfs-tls-runtime/hotpath",
|
||||
"rustfs-utils/hotpath",
|
||||
"rustfs-crypto/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-checksums/hotpath-alloc",
|
||||
"rustfs-common/hotpath-alloc",
|
||||
"rustfs-concurrency/hotpath-alloc",
|
||||
"rustfs-config/hotpath-alloc",
|
||||
@@ -90,16 +85,13 @@ hotpath-alloc = [
|
||||
"rustfs-rio/hotpath-alloc",
|
||||
"rustfs-rio-v2?/hotpath-alloc",
|
||||
"rustfs-s3-types/hotpath-alloc",
|
||||
"rustfs-signer/hotpath-alloc",
|
||||
"rustfs-storage-api/hotpath-alloc",
|
||||
"rustfs-tls-runtime/hotpath-alloc",
|
||||
"rustfs-utils/hotpath-alloc",
|
||||
"rustfs-crypto/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-cpu",
|
||||
"rustfs-checksums/hotpath-cpu",
|
||||
"rustfs-common/hotpath-cpu",
|
||||
"rustfs-concurrency/hotpath-cpu",
|
||||
"rustfs-config/hotpath-cpu",
|
||||
@@ -117,9 +109,7 @@ hotpath-cpu = [
|
||||
"rustfs-rio/hotpath-cpu",
|
||||
"rustfs-rio-v2?/hotpath-cpu",
|
||||
"rustfs-s3-types/hotpath-cpu",
|
||||
"rustfs-signer/hotpath-cpu",
|
||||
"rustfs-storage-api/hotpath-cpu",
|
||||
"rustfs-tls-runtime/hotpath-cpu",
|
||||
"rustfs-utils/hotpath-cpu",
|
||||
"rustfs-crypto/hotpath-cpu",
|
||||
]
|
||||
@@ -129,20 +119,18 @@ hotpath-cpu = [
|
||||
test-util = []
|
||||
|
||||
[dependencies]
|
||||
starshard = { workspace = true }
|
||||
hotpath.workspace = true
|
||||
rustfs-filemeta.workspace = true
|
||||
rustfs-utils = { workspace = true, features = ["full"] }
|
||||
rustfs-rio.workspace = true
|
||||
rustfs-rio-v2 = { workspace = true, optional = true }
|
||||
rustfs-signer.workspace = true
|
||||
rustfs-storage-api.workspace = true
|
||||
rustfs-tls-runtime.workspace = true
|
||||
rustfs-checksums.workspace = true
|
||||
rustfs-config = { workspace = true, features = ["notify", "audit", "server-config-model"] }
|
||||
rustfs-concurrency.workspace = true
|
||||
rustfs-credentials = { workspace = true }
|
||||
rustfs-common.workspace = true
|
||||
rustfs-heal-contracts.workspace = true
|
||||
rustfs-scanner-contracts.workspace = true
|
||||
rustfs-policy.workspace = true
|
||||
rustfs-protos.workspace = true
|
||||
rustfs-replication.workspace = true
|
||||
@@ -158,7 +146,6 @@ bytes = { workspace = true, features = ["serde"] }
|
||||
byteorder = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
jiff = { workspace = true, features = ["serde"] }
|
||||
glob = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
flatbuffers.workspace = true
|
||||
futures.workspace = true
|
||||
@@ -169,7 +156,6 @@ serde = { workspace = true, features = ["derive"] }
|
||||
time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] }
|
||||
bytesize.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
quick-xml = { workspace = true, features = ["serialize", "async-tokio"] }
|
||||
s3s = { workspace = true, features = ["minio"] }
|
||||
http.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
@@ -189,9 +175,7 @@ rmp.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
tokio-util = { workspace = true, features = ["io", "compat"] }
|
||||
tokio-stream = { workspace = true, features = ["sync"] }
|
||||
base64 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex-simd = { workspace = true }
|
||||
tempfile.workspace = true
|
||||
@@ -206,7 +190,6 @@ tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||
xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] }
|
||||
tower = { workspace = true, features = ["timeout"] }
|
||||
async-channel.workspace = true
|
||||
enumset = { workspace = true }
|
||||
num_cpus = { workspace = true }
|
||||
rand = { workspace = true, features = ["serde"] }
|
||||
pin-project-lite.workspace = true
|
||||
|
||||
@@ -154,6 +154,12 @@ pub mod bucket {
|
||||
pub mod object_lock {
|
||||
pub use crate::bucket::object_lock::{ObjectLockApi, ObjectLockStatusExt};
|
||||
|
||||
pub mod types {
|
||||
pub use crate::bucket::object_lock::types::{
|
||||
DefaultRetention, LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod objectlock {
|
||||
pub use crate::bucket::object_lock::objectlock::{get_object_legalhold_meta, get_object_retention_meta};
|
||||
}
|
||||
@@ -249,26 +255,8 @@ pub mod capacity {
|
||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||
}
|
||||
|
||||
pub mod client {
|
||||
pub mod admin_handler_utils {
|
||||
pub use crate::client::admin_handler_utils::AdminError;
|
||||
}
|
||||
|
||||
pub mod api_put_object {
|
||||
pub use crate::client::api_put_object::{AdvancedPutOptions, PutObjectOptions};
|
||||
}
|
||||
|
||||
pub mod object_api_utils {
|
||||
pub use crate::client::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag};
|
||||
}
|
||||
|
||||
pub mod transition_api {
|
||||
pub use crate::client::transition_api::{
|
||||
BucketLookupType, CreateBucketConfiguration, LocationConstraint, ObjectInfo, ObjectMultipartInfo, Options,
|
||||
PutObjectPartOptions, ReadCloser, ReaderImpl, RequestMetadata, RestoreInfo, SendRequest, TransitionClient,
|
||||
TransitionCore, UploadInfo, to_object_info,
|
||||
};
|
||||
}
|
||||
pub mod object_api_utils {
|
||||
pub use crate::object_api::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag};
|
||||
}
|
||||
|
||||
pub mod cluster {
|
||||
|
||||
@@ -16,8 +16,8 @@ use super::runtime_boundary as runtime_sources;
|
||||
use crate::bucket::lifecycle::lifecycle;
|
||||
use crate::object_api::ObjectInfo;
|
||||
use crate::services::event_notification::{EventArgs, send_event};
|
||||
use rustfs_common::metrics::IlmAction;
|
||||
use rustfs_s3_types::EventName;
|
||||
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||
|
||||
const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]";
|
||||
const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]";
|
||||
|
||||
@@ -73,9 +73,6 @@ use crate::store::ECStore;
|
||||
use async_channel::{Receiver as A_Receiver, Sender as A_Sender, bounded};
|
||||
use http::HeaderMap;
|
||||
use rand::RngExt as _;
|
||||
use rustfs_common::metrics::{
|
||||
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
|
||||
};
|
||||
use rustfs_config::{
|
||||
DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_QUEUE_SEND_TIMEOUT_MS, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
|
||||
DEFAULT_TRANSITION_WORKERS_CAP, ENV_MAX_EXPIRY_WORKERS, ENV_TRANSITION_QUEUE_CAPACITY, ENV_TRANSITION_QUEUE_SEND_TIMEOUT_MS,
|
||||
@@ -85,6 +82,9 @@ use rustfs_data_usage::TierStats;
|
||||
use rustfs_filemeta::{
|
||||
FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info, is_restored_object_on_disk,
|
||||
};
|
||||
use rustfs_scanner_contracts::metrics::{
|
||||
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
|
||||
};
|
||||
use rustfs_utils::{
|
||||
get_env_i64, get_env_usize,
|
||||
path::encode_dir_object,
|
||||
@@ -940,7 +940,7 @@ impl ExpiryState {
|
||||
let version_count = u64::try_from(v.versions.len()).unwrap_or(u64::MAX);
|
||||
let trace = LifecycleExpiryTrace::for_batch(&v.bucket, &v.event, &v.src, version_count);
|
||||
trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None);
|
||||
crate::client::object_handlers_common::delete_object_versions(
|
||||
crate::bucket::lifecycle::object_handlers_common::delete_object_versions(
|
||||
&api,
|
||||
&v.bucket,
|
||||
&v.versions,
|
||||
@@ -5432,8 +5432,6 @@ mod tests {
|
||||
use crate::bucket::lifecycle::tier_sweeper::Jentry;
|
||||
use crate::bucket::metadata::{BUCKET_LIFECYCLE_CONFIG, BUCKET_VERSIONING_CONFIG};
|
||||
use crate::bucket::metadata_sys;
|
||||
#[cfg(feature = "test-util")]
|
||||
use crate::client::transition_api::ReaderImpl;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::disk::{RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE};
|
||||
use crate::error::{Error, is_err_invalid_upload_id};
|
||||
@@ -5461,11 +5459,13 @@ mod tests {
|
||||
use futures::FutureExt;
|
||||
#[cfg(feature = "test-util")]
|
||||
use http::HeaderMap;
|
||||
use rustfs_common::metrics::{IlmAction, global_metrics};
|
||||
use rustfs_config::ENV_MAX_EXPIRY_WORKERS;
|
||||
use rustfs_config::ENV_TRANSITION_WORKERS_ABSOLUTE_MAX;
|
||||
use rustfs_data_usage::TierStats;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta};
|
||||
#[cfg(feature = "test-util")]
|
||||
use rustfs_s3_client::transition_api::ReaderImpl;
|
||||
use rustfs_scanner_contracts::metrics::{IlmAction, global_metrics};
|
||||
use s3s::dto::{
|
||||
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry,
|
||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, OutputLocation, RestoreRequest,
|
||||
@@ -6353,7 +6353,7 @@ mod tests {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
let admin_err = err
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<crate::client::admin_handler_utils::AdminError>())
|
||||
.and_then(|source| source.downcast_ref::<rustfs_s3_client::admin_handler_utils::AdminError>())
|
||||
.expect("identity mismatch should retain the typed tier error");
|
||||
assert_eq!(admin_err.code, crate::services::tier::tier::ERR_TIER_INVALID_CONFIG.code);
|
||||
assert_eq!(new_backend.get_count().await, 0);
|
||||
@@ -11564,7 +11564,7 @@ mod tests {
|
||||
lease
|
||||
.put(
|
||||
"remote/object",
|
||||
crate::client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")),
|
||||
rustfs_s3_client::transition_api::ReaderImpl::Body(bytes::Bytes::from_static(b"candidate")),
|
||||
9,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod evaluator;
|
||||
pub mod manual_transition_job;
|
||||
mod metadata_boundary;
|
||||
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs};
|
||||
mod object_handlers_common;
|
||||
mod object_lock_boundary;
|
||||
pub use self::core as lifecycle;
|
||||
mod replication_sink;
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped";
|
||||
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
|
||||
|
||||
use crate::bucket::lifecycle::lifecycle;
|
||||
use crate::bucket::replication::{ReplicationLifecycleBridge, ReplicationObjectBridge};
|
||||
use crate::bucket::lifecycle::replication_sink::{self, ReplicationObjectBridge};
|
||||
use crate::object_api::ObjectOptions;
|
||||
use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete};
|
||||
use crate::store::ECStore;
|
||||
@@ -84,7 +84,7 @@ pub async fn delete_object_versions(
|
||||
if deleted_obj.replication_state.is_none() {
|
||||
continue;
|
||||
}
|
||||
ReplicationLifecycleBridge::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
|
||||
replication_sink::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
|
||||
}
|
||||
|
||||
for (i, err) in errors.iter().enumerate() {
|
||||
@@ -26,7 +26,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config(
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> crate::error::Result<Option<ObjectLockBlockReason>> {
|
||||
objectlock_sys::check_object_lock_for_deletion_with_config(config, obj_info, bypass_governance)
|
||||
let default_retention = config.and_then(crate::bucket::metadata_sys::default_retention_from_object_lock_config);
|
||||
objectlock_sys::check_object_lock_for_deletion_with_default_retention(default_retention.as_ref(), obj_info, bypass_governance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_common::metrics::IlmAction;
|
||||
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||
|
||||
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
|
||||
use crate::bucket::replication::ReplicationLifecycleBridge;
|
||||
@@ -77,7 +77,7 @@ mod tests {
|
||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||
use crate::object_api::{ObjectInfo, ObjectOptions};
|
||||
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||
use rustfs_common::metrics::IlmAction;
|
||||
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||
use s3s::dto::{
|
||||
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
|
||||
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
|
||||
|
||||
@@ -22,11 +22,11 @@ use super::runtime_boundary as runtime_sources;
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
|
||||
use crate::bucket::lifecycle::lifecycle::{self, ObjectOpts};
|
||||
use crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry;
|
||||
use crate::client::signer_error::error_chain_contains_signer_header_marker;
|
||||
use crate::object_api::ObjectInfo;
|
||||
use crate::services::tier::tier::{TierConfigMgr, TierDestinationId, TierOperationLease};
|
||||
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
||||
use crate::store::ECStore;
|
||||
use rustfs_s3_client::signer_error::error_chain_contains_signer_header_marker;
|
||||
use rustfs_utils::get_env_usize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -670,7 +670,7 @@ pub(crate) fn transitioned_delete_journal_entry_for_source(
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::client::signer_error::invalid_utf8_header_error;
|
||||
use rustfs_s3_client::signer_error::invalid_utf8_header_error;
|
||||
|
||||
use super::{
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::msgp_decode::{read_msgp_ext8_time, skip_msgp_value, write_msgp_time};
|
||||
use super::object_lock::ObjectLockApi;
|
||||
use super::object_lock::{ObjectLockApi, ObjectLockStatusExt};
|
||||
use super::versioning::VersioningApi;
|
||||
use super::{quota::BucketQuota, target::BucketTargets};
|
||||
use crate::bucket::replication::invalid_replication_config_status_field;
|
||||
@@ -39,6 +39,26 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time as CivilTime, UtcOffset
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
// The serving-layer DTO impls for the storage-level Object Lock traits live
|
||||
// here because this module owns the persisted `ObjectLockConfiguration`
|
||||
// during the s3s ratchet migration (rustfs/backlog#1842).
|
||||
impl ObjectLockApi for ObjectLockConfiguration {
|
||||
fn enabled(&self) -> bool {
|
||||
self.object_lock_enabled
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.as_str() == s3s::dto::ObjectLockEnabled::ENABLED)
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectLockStatusExt for s3s::dto::ObjectLockLegalHoldStatus {
|
||||
fn valid(&self) -> bool {
|
||||
matches!(
|
||||
self.as_str(),
|
||||
s3s::dto::ObjectLockLegalHoldStatus::ON | s3s::dto::ObjectLockLegalHoldStatus::OFF
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_msgp_str<R: Read>(rd: &mut R) -> Result<String> {
|
||||
let len = rmp::decode::read_str_len(rd)? as usize;
|
||||
let mut buf = vec![0u8; len];
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use crate::store::{ECStore, await_bucket_namespace_operation};
|
||||
use futures::future::join_all;
|
||||
use rustfs_common::heal_channel::HealOpts;
|
||||
use rustfs_heal_contracts::heal_channel::HealOpts;
|
||||
use rustfs_policy::policy::BucketPolicy;
|
||||
use s3s::dto::ReplicationConfiguration;
|
||||
use s3s::dto::{
|
||||
@@ -167,6 +167,53 @@ pub(crate) fn object_lock_config_state_from_authoritative_metadata(bm: &BucketMe
|
||||
Ok(ObjectLockConfigState::ConfirmedAbsent)
|
||||
}
|
||||
|
||||
/// Convert the persisted serving-layer configuration into the storage-level
|
||||
/// [`DefaultRetention`](crate::bucket::object_lock::types::DefaultRetention)
|
||||
/// the WORM evaluation code consumes (rustfs/backlog#1842). A rule without a
|
||||
/// usable GOVERNANCE/COMPLIANCE mode converts to `None`, exactly like the
|
||||
/// evaluation code has always ignored such rules; days/years are passed
|
||||
/// through untouched so an invalid period still fails closed at evaluation.
|
||||
pub(crate) fn default_retention_from_object_lock_config(
|
||||
config: &ObjectLockConfiguration,
|
||||
) -> Option<crate::bucket::object_lock::types::DefaultRetention> {
|
||||
let default_retention = config.rule.as_ref()?.default_retention.as_ref()?;
|
||||
let mode = crate::bucket::object_lock::types::RetentionMode::parse(default_retention.mode.as_ref()?.as_str())?;
|
||||
Some(crate::bucket::object_lock::types::DefaultRetention {
|
||||
mode,
|
||||
days: default_retention.days,
|
||||
years: default_retention.years,
|
||||
})
|
||||
}
|
||||
|
||||
/// Test-only builder for a `Configured` Object Lock state carrying a default
|
||||
/// retention, so storage-side tests do not have to name serving-layer DTOs.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn configured_object_lock_state_for_tests(
|
||||
mode: crate::bucket::object_lock::types::RetentionMode,
|
||||
days: i32,
|
||||
) -> ObjectLockConfigState {
|
||||
ObjectLockConfigState::Configured {
|
||||
config: ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(s3s::dto::ObjectLockRule {
|
||||
default_retention: Some(s3s::dto::DefaultRetention {
|
||||
mode: Some(s3s::dto::ObjectLockRetentionMode::from_static(match mode {
|
||||
crate::bucket::object_lock::types::RetentionMode::Governance => {
|
||||
s3s::dto::ObjectLockRetentionMode::GOVERNANCE
|
||||
}
|
||||
crate::bucket::object_lock::types::RetentionMode::Compliance => {
|
||||
s3s::dto::ObjectLockRetentionMode::COMPLIANCE
|
||||
}
|
||||
})),
|
||||
days: Some(days),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
updated_at: OffsetDateTime::now_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -> Result<()> {
|
||||
if config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str) != Some(ObjectLockEnabled::ENABLED) {
|
||||
return Err(Error::other("persisted bucket Object Lock enabled state is invalid"));
|
||||
|
||||
@@ -14,27 +14,18 @@
|
||||
|
||||
pub mod objectlock;
|
||||
pub mod objectlock_sys;
|
||||
pub mod types;
|
||||
|
||||
use s3s::dto::{ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus};
|
||||
|
||||
/// Whether a bucket Object Lock configuration has locking enabled. The
|
||||
/// serving-layer `ObjectLockConfiguration` DTO implements this in
|
||||
/// the bucket-metadata module, which owns the persisted configuration type
|
||||
/// during the s3s ratchet migration (rustfs/backlog#1842).
|
||||
pub trait ObjectLockApi {
|
||||
fn enabled(&self) -> bool;
|
||||
}
|
||||
|
||||
impl ObjectLockApi for ObjectLockConfiguration {
|
||||
fn enabled(&self) -> bool {
|
||||
self.object_lock_enabled
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.as_str() == ObjectLockEnabled::ENABLED)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a legal-hold status value is one of the two valid wire values.
|
||||
/// Implemented for the serving-layer DTO in the bucket-metadata module.
|
||||
pub trait ObjectLockStatusExt {
|
||||
fn valid(&self) -> bool;
|
||||
}
|
||||
|
||||
impl ObjectLockStatusExt for ObjectLockLegalHoldStatus {
|
||||
fn valid(&self) -> bool {
|
||||
matches!(self.as_str(), ObjectLockLegalHoldStatus::ON | ObjectLockLegalHoldStatus::OFF)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use s3s::dto::{Date, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockRetention, ObjectLockRetentionMode};
|
||||
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
|
||||
use super::types::{LegalHoldStatus, ObjectLegalHold, ObjectRetention, RetentionMode};
|
||||
use rustfs_utils::http::headers::{
|
||||
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use time::{OffsetDateTime, format_description};
|
||||
|
||||
@@ -31,65 +33,48 @@ pub fn utc_now_ntp() -> OffsetDateTime {
|
||||
OffsetDateTime::now_utc()
|
||||
}
|
||||
|
||||
pub fn get_object_retention_meta(meta: &HashMap<String, String>) -> ObjectLockRetention {
|
||||
// Note: X_AMZ_OBJECT_LOCK_MODE.as_str() is already lowercase ("x-amz-object-lock-mode")
|
||||
let mode_str = meta.get(X_AMZ_OBJECT_LOCK_MODE.as_str());
|
||||
pub fn get_object_retention_meta(meta: &HashMap<String, String>) -> ObjectRetention {
|
||||
// The persisted metadata keys are the lowercase wire header names.
|
||||
let mode_str = meta.get(AMZ_OBJECT_LOCK_MODE_LOWER);
|
||||
|
||||
let Some(mode_str) = mode_str else {
|
||||
return ObjectLockRetention {
|
||||
mode: None,
|
||||
retain_until_date: None,
|
||||
};
|
||||
return ObjectRetention::default();
|
||||
};
|
||||
|
||||
// If mode is invalid, return empty retention (don't panic)
|
||||
let Some(mode) = parse_ret_mode(mode_str.as_str()) else {
|
||||
return ObjectLockRetention {
|
||||
mode: None,
|
||||
retain_until_date: None,
|
||||
};
|
||||
return ObjectRetention::default();
|
||||
};
|
||||
|
||||
let till_str = meta.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
|
||||
let till_str = meta.get(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER);
|
||||
|
||||
let retain_until_date = till_str
|
||||
.and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok())
|
||||
.map(Date::from);
|
||||
let retain_until_date =
|
||||
till_str.and_then(|s| OffsetDateTime::parse(s, &format_description::well_known::Iso8601::DEFAULT).ok());
|
||||
|
||||
ObjectLockRetention {
|
||||
ObjectRetention {
|
||||
mode: Some(mode),
|
||||
retain_until_date,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_object_legalhold_meta(meta: &HashMap<String, String>) -> ObjectLockLegalHold {
|
||||
// Note: X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str() is already lowercase
|
||||
let hold_str = meta.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str());
|
||||
pub fn get_object_legalhold_meta(meta: &HashMap<String, String>) -> ObjectLegalHold {
|
||||
let hold_str = meta.get(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER);
|
||||
|
||||
match hold_str.and_then(|s| parse_legalhold_status(s)) {
|
||||
Some(status) => ObjectLockLegalHold { status: Some(status) },
|
||||
None => ObjectLockLegalHold { status: None },
|
||||
ObjectLegalHold {
|
||||
status: hold_str.and_then(|s| parse_legalhold_status(s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse retention mode string into ObjectLockRetentionMode.
|
||||
/// Parse retention mode string into [`RetentionMode`].
|
||||
/// Returns None for invalid/unknown mode strings instead of panicking.
|
||||
pub fn parse_ret_mode(mode_str: &str) -> Option<ObjectLockRetentionMode> {
|
||||
match mode_str.to_uppercase().as_str() {
|
||||
"GOVERNANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::GOVERNANCE)),
|
||||
"COMPLIANCE" => Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
_ => None,
|
||||
}
|
||||
pub fn parse_ret_mode(mode_str: &str) -> Option<RetentionMode> {
|
||||
RetentionMode::parse(mode_str)
|
||||
}
|
||||
|
||||
/// Parse legal hold status string into ObjectLockLegalHoldStatus.
|
||||
/// Parse legal hold status string into [`LegalHoldStatus`].
|
||||
/// Returns None for invalid/unknown status strings instead of panicking.
|
||||
pub fn parse_legalhold_status(hold_str: &str) -> Option<ObjectLockLegalHoldStatus> {
|
||||
match hold_str.to_uppercase().as_str() {
|
||||
"ON" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::ON)),
|
||||
"OFF" => Some(ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
|
||||
_ => None,
|
||||
}
|
||||
pub fn parse_legalhold_status(hold_str: &str) -> Option<LegalHoldStatus> {
|
||||
LegalHoldStatus::parse(hold_str)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -101,25 +86,25 @@ mod tests {
|
||||
// Test uppercase
|
||||
let mode = parse_ret_mode("GOVERNANCE");
|
||||
assert!(mode.is_some());
|
||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
|
||||
assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
|
||||
|
||||
let mode = parse_ret_mode("COMPLIANCE");
|
||||
assert!(mode.is_some());
|
||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
|
||||
assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE);
|
||||
|
||||
// Test lowercase
|
||||
let mode = parse_ret_mode("governance");
|
||||
assert!(mode.is_some());
|
||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
|
||||
assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
|
||||
|
||||
let mode = parse_ret_mode("compliance");
|
||||
assert!(mode.is_some());
|
||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
|
||||
assert_eq!(mode.unwrap().as_str(), RetentionMode::COMPLIANCE);
|
||||
|
||||
// Test mixed case
|
||||
let mode = parse_ret_mode("Governance");
|
||||
assert!(mode.is_some());
|
||||
assert_eq!(mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
|
||||
assert_eq!(mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -136,20 +121,20 @@ mod tests {
|
||||
// Test uppercase
|
||||
let status = parse_legalhold_status("ON");
|
||||
assert!(status.is_some());
|
||||
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
|
||||
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON);
|
||||
|
||||
let status = parse_legalhold_status("OFF");
|
||||
assert!(status.is_some());
|
||||
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
|
||||
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF);
|
||||
|
||||
// Test lowercase
|
||||
let status = parse_legalhold_status("on");
|
||||
assert!(status.is_some());
|
||||
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
|
||||
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::ON);
|
||||
|
||||
let status = parse_legalhold_status("off");
|
||||
assert!(status.is_some());
|
||||
assert_eq!(status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
|
||||
assert_eq!(status.unwrap().as_str(), LegalHoldStatus::OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -175,7 +160,7 @@ mod tests {
|
||||
meta.insert("x-amz-object-lock-mode".to_string(), "GOVERNANCE".to_string());
|
||||
let retention = get_object_retention_meta(&meta);
|
||||
assert!(retention.mode.is_some());
|
||||
assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::GOVERNANCE);
|
||||
assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::GOVERNANCE);
|
||||
assert!(retention.retain_until_date.is_none());
|
||||
}
|
||||
|
||||
@@ -196,7 +181,7 @@ mod tests {
|
||||
meta.insert("x-amz-object-lock-retain-until-date".to_string(), "2030-01-01T00:00:00Z".to_string());
|
||||
let retention = get_object_retention_meta(&meta);
|
||||
assert!(retention.mode.is_some());
|
||||
assert_eq!(retention.mode.unwrap().as_str(), ObjectLockRetentionMode::COMPLIANCE);
|
||||
assert_eq!(retention.mode.unwrap().as_str(), RetentionMode::COMPLIANCE);
|
||||
assert!(retention.retain_until_date.is_some());
|
||||
}
|
||||
|
||||
@@ -210,17 +195,11 @@ mod tests {
|
||||
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
|
||||
|
||||
let retention = get_object_retention_meta(&meta);
|
||||
assert_eq!(
|
||||
retention.mode.as_ref().map(|mode| mode.as_str()),
|
||||
Some(ObjectLockRetentionMode::COMPLIANCE)
|
||||
);
|
||||
assert_eq!(retention.mode.as_ref().map(|mode| mode.as_str()), Some(RetentionMode::COMPLIANCE));
|
||||
assert!(retention.retain_until_date.is_some(), "persisted retention date must remain readable");
|
||||
|
||||
let legal_hold = get_object_legalhold_meta(&meta);
|
||||
assert_eq!(
|
||||
legal_hold.status.as_ref().map(|status| status.as_str()),
|
||||
Some(ObjectLockLegalHoldStatus::ON)
|
||||
);
|
||||
assert_eq!(legal_hold.status.as_ref().map(|status| status.as_str()), Some(LegalHoldStatus::ON));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -236,7 +215,7 @@ mod tests {
|
||||
meta.insert("x-amz-object-lock-legal-hold".to_string(), "ON".to_string());
|
||||
let legalhold = get_object_legalhold_meta(&meta);
|
||||
assert!(legalhold.status.is_some());
|
||||
assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::ON);
|
||||
assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::ON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -245,7 +224,7 @@ mod tests {
|
||||
meta.insert("x-amz-object-lock-legal-hold".to_string(), "OFF".to_string());
|
||||
let legalhold = get_object_legalhold_meta(&meta);
|
||||
assert!(legalhold.status.is_some());
|
||||
assert_eq!(legalhold.status.unwrap().as_str(), ObjectLockLegalHoldStatus::OFF);
|
||||
assert_eq!(legalhold.status.unwrap().as_str(), LegalHoldStatus::OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,12 +12,16 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::bucket::metadata_sys::{ObjectLockConfigState, get_object_lock_config, get_object_lock_config_state};
|
||||
use crate::bucket::metadata_sys::{
|
||||
ObjectLockConfigState, default_retention_from_object_lock_config, get_object_lock_config, get_object_lock_config_state,
|
||||
};
|
||||
use crate::bucket::object_lock::objectlock;
|
||||
use crate::bucket::object_lock::types::{DefaultRetention, LegalHoldStatus, RetentionMode};
|
||||
use crate::error::{Error, Result, StorageError};
|
||||
use crate::object_api::{ObjectInfo, ObjectOptions};
|
||||
use s3s::dto::{Date, DefaultRetention, ObjectLockConfiguration, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
||||
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
|
||||
use rustfs_utils::http::headers::{
|
||||
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -29,11 +33,12 @@ impl BucketObjectLockSys {
|
||||
Arc::new(Self {})
|
||||
}
|
||||
|
||||
/// The bucket's active default retention, if the bucket has an
|
||||
/// authoritative Object Lock configuration with a usable
|
||||
/// GOVERNANCE/COMPLIANCE default retention rule.
|
||||
pub async fn get(bucket: &str) -> Option<DefaultRetention> {
|
||||
if let Ok(object_lock_config) = get_object_lock_config(bucket).await
|
||||
&& let Some(object_lock_rule) = object_lock_config.0.rule
|
||||
{
|
||||
return object_lock_rule.default_retention;
|
||||
if let Ok(object_lock_config) = get_object_lock_config(bucket).await {
|
||||
return default_retention_from_object_lock_config(&object_lock_config.0);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -54,13 +59,10 @@ pub(crate) fn ensure_recursive_force_delete_allowed_for_state(bucket: &str, stat
|
||||
}
|
||||
|
||||
/// Check if a retention period is still active based on mode and retain_until_date
|
||||
pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool {
|
||||
if mode != ObjectLockRetentionMode::COMPLIANCE && mode != ObjectLockRetentionMode::GOVERNANCE {
|
||||
return false;
|
||||
}
|
||||
pub fn is_retention_active(_mode: RetentionMode, retain_until_date: Option<OffsetDateTime>) -> bool {
|
||||
if let Some(retain_until) = retain_until_date {
|
||||
let now = objectlock::utc_now_ntp();
|
||||
return OffsetDateTime::from(retain_until.clone()).unix_timestamp() > now.unix_timestamp();
|
||||
return retain_until.unix_timestamp() > now.unix_timestamp();
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -68,23 +70,20 @@ pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date
|
||||
/// Check if retention modification is blocked for the given object.
|
||||
pub fn check_retention_for_modification(
|
||||
user_defined: &std::collections::HashMap<String, String>,
|
||||
new_mode: Option<&str>,
|
||||
new_mode: Option<RetentionMode>,
|
||||
new_retain_until: Option<OffsetDateTime>,
|
||||
bypass_governance: bool,
|
||||
) -> Option<ObjectLockBlockReason> {
|
||||
let retention = objectlock::get_object_retention_meta(user_defined);
|
||||
|
||||
let Some(mode) = &retention.mode else {
|
||||
return None;
|
||||
};
|
||||
let mode = retention.mode?;
|
||||
|
||||
let mode_str = mode.as_str();
|
||||
if !is_retention_active(mode_str, retention.retain_until_date.as_ref()) {
|
||||
if !is_retention_active(mode, retention.retain_until_date) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let existing_retain_until = retention.retain_until_date.as_ref().map(|d| OffsetDateTime::from(d.clone()));
|
||||
let mode_changed = new_mode != Some(mode_str);
|
||||
let existing_retain_until = retention.retain_until_date;
|
||||
let mode_changed = new_mode != Some(mode);
|
||||
|
||||
// Check if new retention period is shorter than existing
|
||||
let is_shortening = match (&existing_retain_until, &new_retain_until) {
|
||||
@@ -93,35 +92,34 @@ pub fn check_retention_for_modification(
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// COMPLIANCE mode: cannot shorten retention at all (even with bypass)
|
||||
// Can only extend the retention period
|
||||
if mode_str == ObjectLockRetentionMode::COMPLIANCE {
|
||||
if mode_changed || is_shortening {
|
||||
return Some(ObjectLockBlockReason::Retention {
|
||||
mode: mode_str.to_string(),
|
||||
retain_until: existing_retain_until,
|
||||
});
|
||||
match mode {
|
||||
// COMPLIANCE mode: cannot shorten retention at all (even with bypass)
|
||||
// Can only extend the retention period
|
||||
RetentionMode::Compliance => {
|
||||
if mode_changed || is_shortening {
|
||||
return Some(ObjectLockBlockReason::Retention {
|
||||
mode,
|
||||
retain_until: existing_retain_until,
|
||||
});
|
||||
}
|
||||
// Extending retention in COMPLIANCE mode is allowed
|
||||
None
|
||||
}
|
||||
// Extending retention in COMPLIANCE mode is allowed
|
||||
return None;
|
||||
}
|
||||
|
||||
// GOVERNANCE mode: extending is always allowed, shortening requires bypass
|
||||
// This matches AWS S3 behavior where:
|
||||
// - Extending retention: allowed without bypass permission
|
||||
// - Shortening/removing retention: requires bypass permission
|
||||
if mode_str == ObjectLockRetentionMode::GOVERNANCE {
|
||||
if (mode_changed || is_shortening) && !bypass_governance {
|
||||
return Some(ObjectLockBlockReason::Retention {
|
||||
mode: mode_str.to_string(),
|
||||
retain_until: existing_retain_until,
|
||||
});
|
||||
// GOVERNANCE mode: extending is always allowed, shortening requires bypass
|
||||
// This matches AWS S3 behavior where:
|
||||
// - Extending retention: allowed without bypass permission
|
||||
// - Shortening/removing retention: requires bypass permission
|
||||
RetentionMode::Governance => {
|
||||
if (mode_changed || is_shortening) && !bypass_governance {
|
||||
return Some(ObjectLockBlockReason::Retention {
|
||||
mode,
|
||||
retain_until: existing_retain_until,
|
||||
});
|
||||
}
|
||||
// Extending retention or shortening with bypass is allowed
|
||||
None
|
||||
}
|
||||
// Extending retention or shortening with bypass is allowed
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime {
|
||||
@@ -137,8 +135,7 @@ pub fn add_years(dt: OffsetDateTime, years: i32) -> OffsetDateTime {
|
||||
/// Check if an object has legal hold enabled.
|
||||
/// Returns true if legal hold is ON.
|
||||
fn has_legal_hold(user_defined: &std::collections::HashMap<String, String>) -> bool {
|
||||
let lhold = objectlock::get_object_legalhold_meta(user_defined);
|
||||
matches!(lhold.status, Some(ref st) if st.as_str() == ObjectLockLegalHoldStatus::ON)
|
||||
objectlock::get_object_legalhold_meta(user_defined).is_on()
|
||||
}
|
||||
|
||||
/// Whether an authorized replication write (`ObjectOptions::replication_request`)
|
||||
@@ -172,11 +169,11 @@ pub fn replication_write_may_pass_worm_gate(
|
||||
// Delete markers are never locked (same as the WORM gate).
|
||||
return Ok(true);
|
||||
}
|
||||
let config = object_lock_config_from_state(state)?;
|
||||
let default_retention = default_retention_from_state(state)?;
|
||||
if legal_hold_locks(obj_info)? && opts.replication_legalhold_timestamp.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let retention_locked = active_retention(config, obj_info)?.is_some();
|
||||
let retention_locked = active_retention(default_retention.as_ref(), obj_info)?.is_some();
|
||||
Ok(!(retention_locked && opts.replication_retention_timestamp.is_none()))
|
||||
}
|
||||
|
||||
@@ -204,8 +201,8 @@ pub fn is_object_locked_by_metadata(user_defined: &std::collections::HashMap<Str
|
||||
|
||||
// Check retention - reuse is_retention_active to avoid code duplication
|
||||
let ret = objectlock::get_object_retention_meta(user_defined);
|
||||
if let Some(mode) = &ret.mode
|
||||
&& is_retention_active(mode.as_str(), ret.retain_until_date.as_ref())
|
||||
if let Some(mode) = ret.mode
|
||||
&& is_retention_active(mode, ret.retain_until_date)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -220,7 +217,7 @@ pub enum ObjectLockBlockReason {
|
||||
LegalHold,
|
||||
/// Object is under retention until the specified date
|
||||
Retention {
|
||||
mode: String,
|
||||
mode: RetentionMode,
|
||||
retain_until: Option<OffsetDateTime>,
|
||||
},
|
||||
}
|
||||
@@ -246,30 +243,28 @@ impl ObjectLockBlockReason {
|
||||
/// Check if retention blocks deletion based on mode and bypass permission.
|
||||
/// Returns Some(ObjectLockBlockReason) if blocked, None if allowed.
|
||||
fn check_retention_blocks_deletion(
|
||||
mode_str: &str,
|
||||
mode: RetentionMode,
|
||||
retain_until: Option<OffsetDateTime>,
|
||||
bypass_governance: bool,
|
||||
) -> Option<ObjectLockBlockReason> {
|
||||
// COMPLIANCE mode cannot be bypassed; GOVERNANCE can only be bypassed with permission
|
||||
let can_bypass = mode_str == ObjectLockRetentionMode::GOVERNANCE && bypass_governance;
|
||||
let can_bypass = mode == RetentionMode::Governance && bypass_governance;
|
||||
if !can_bypass {
|
||||
return Some(ObjectLockBlockReason::Retention {
|
||||
mode: mode_str.to_string(),
|
||||
retain_until,
|
||||
});
|
||||
return Some(ObjectLockBlockReason::Retention { mode, retain_until });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check an object's lock metadata using an already resolved bucket Object
|
||||
/// Lock configuration. `None` means the configuration is confirmed absent.
|
||||
/// Check an object's lock metadata using an already resolved bucket default
|
||||
/// retention. `None` means the bucket configuration is confirmed absent or
|
||||
/// carries no usable default retention rule.
|
||||
///
|
||||
/// # S3 Standard Behavior
|
||||
/// - COMPLIANCE mode: Cannot be deleted even with bypass header
|
||||
/// - GOVERNANCE mode: Can be deleted if bypass_governance is true (caller must verify s3:BypassGovernanceRetention permission)
|
||||
/// - Legal Hold: Cannot be bypassed regardless of mode
|
||||
pub(crate) fn check_object_lock_for_deletion_with_config(
|
||||
config: Option<&ObjectLockConfiguration>,
|
||||
pub(crate) fn check_object_lock_for_deletion_with_default_retention(
|
||||
default_retention: Option<&DefaultRetention>,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Result<Option<ObjectLockBlockReason>> {
|
||||
@@ -281,8 +276,8 @@ pub(crate) fn check_object_lock_for_deletion_with_config(
|
||||
return Ok(Some(ObjectLockBlockReason::LegalHold));
|
||||
}
|
||||
|
||||
if let Some((mode_str, retain_until)) = active_retention(config, obj_info)?
|
||||
&& let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance)
|
||||
if let Some((mode, retain_until)) = active_retention(default_retention, obj_info)?
|
||||
&& let Some(reason) = check_retention_blocks_deletion(mode, Some(retain_until), bypass_governance)
|
||||
{
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
@@ -300,56 +295,41 @@ fn persisted_lock_value<'a>(obj_info: &'a ObjectInfo, key: &str) -> Option<&'a S
|
||||
/// Whether the version's persisted legal hold is ON. Any other non-empty
|
||||
/// value than ON/OFF is malformed metadata and fails closed.
|
||||
fn legal_hold_locks(obj_info: &ObjectInfo) -> Result<bool> {
|
||||
let Some(status) = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()) else {
|
||||
let Some(status) = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::ON) {
|
||||
return Ok(true);
|
||||
match LegalHoldStatus::parse(status) {
|
||||
Some(LegalHoldStatus::On) => Ok(true),
|
||||
Some(LegalHoldStatus::Off) => Ok(false),
|
||||
None => Err(Error::other("persisted object legal-hold metadata is invalid")),
|
||||
}
|
||||
if !status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::OFF) {
|
||||
return Err(Error::other("persisted object legal-hold metadata is invalid"));
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// The retention that currently locks the version, if any: the explicit
|
||||
/// persisted retention when the keys are present, otherwise the bucket
|
||||
/// default retention computed from the version's modification time. Returns
|
||||
/// `(mode, retain_until)` only while the retention is still active.
|
||||
fn active_retention<'a>(
|
||||
config: Option<&'a ObjectLockConfiguration>,
|
||||
fn active_retention(
|
||||
default_retention: Option<&DefaultRetention>,
|
||||
obj_info: &ObjectInfo,
|
||||
) -> Result<Option<(&'a str, OffsetDateTime)>> {
|
||||
let mode = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_MODE.as_str());
|
||||
let retain_until = persisted_lock_value(obj_info, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
|
||||
) -> Result<Option<(RetentionMode, OffsetDateTime)>> {
|
||||
let mode = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_MODE_LOWER);
|
||||
let retain_until = persisted_lock_value(obj_info, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER);
|
||||
match (mode, retain_until) {
|
||||
(None, None) => {}
|
||||
(Some(mode), Some(retain_until)) => {
|
||||
let mode =
|
||||
objectlock::parse_ret_mode(mode).ok_or_else(|| Error::other("persisted object retention mode is invalid"))?;
|
||||
let retain_until = OffsetDateTime::parse(retain_until, &time::format_description::well_known::Iso8601::DEFAULT)
|
||||
.map(Date::from)
|
||||
.map_err(|_| Error::other("persisted object retention date is invalid"))?;
|
||||
let mode_str = match mode.as_str() {
|
||||
ObjectLockRetentionMode::COMPLIANCE => ObjectLockRetentionMode::COMPLIANCE,
|
||||
ObjectLockRetentionMode::GOVERNANCE => ObjectLockRetentionMode::GOVERNANCE,
|
||||
_ => return Err(Error::other("persisted object retention mode is invalid")),
|
||||
};
|
||||
return Ok(is_retention_active(mode_str, Some(&retain_until)).then(|| (mode_str, OffsetDateTime::from(retain_until))));
|
||||
return Ok(is_retention_active(mode, Some(retain_until)).then_some((mode, retain_until)));
|
||||
}
|
||||
_ => return Err(Error::other("persisted object retention metadata is incomplete")),
|
||||
}
|
||||
|
||||
let Some(default_retention) = config.and_then(|config| config.rule.as_ref()?.default_retention.as_ref()) else {
|
||||
let Some(default_retention) = default_retention else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(mode) = &default_retention.mode else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mode_str = mode.as_str();
|
||||
if mode_str != ObjectLockRetentionMode::COMPLIANCE && mode_str != ObjectLockRetentionMode::GOVERNANCE {
|
||||
return Ok(None);
|
||||
}
|
||||
// Calculate retention expiration date from object modification time
|
||||
let mod_time = obj_info
|
||||
.mod_time
|
||||
@@ -363,12 +343,16 @@ fn active_retention<'a>(
|
||||
.ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?;
|
||||
add_years(mod_time, years)
|
||||
};
|
||||
Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((mode_str, retain_until)))
|
||||
Ok((retain_until.unix_timestamp() > now.unix_timestamp()).then_some((default_retention.mode, retain_until)))
|
||||
}
|
||||
|
||||
fn object_lock_config_from_state(state: &ObjectLockConfigState) -> Result<Option<&ObjectLockConfiguration>> {
|
||||
/// The bucket default retention carried by an authoritative Object Lock
|
||||
/// state. `ConfirmedAbsent` and a configuration without a usable default
|
||||
/// retention rule are both `None`; a fabricated state is an error, never a
|
||||
/// pass.
|
||||
fn default_retention_from_state(state: &ObjectLockConfigState) -> Result<Option<DefaultRetention>> {
|
||||
match state {
|
||||
ObjectLockConfigState::Configured { config, .. } => Ok(Some(config)),
|
||||
ObjectLockConfigState::Configured { config, .. } => Ok(default_retention_from_object_lock_config(config)),
|
||||
ObjectLockConfigState::ConfirmedAbsent => Ok(None),
|
||||
ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")),
|
||||
}
|
||||
@@ -379,7 +363,11 @@ pub(crate) fn check_object_lock_for_deletion_with_state(
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Result<Option<ObjectLockBlockReason>> {
|
||||
check_object_lock_for_deletion_with_config(object_lock_config_from_state(state)?, obj_info, bypass_governance)
|
||||
check_object_lock_for_deletion_with_default_retention(
|
||||
default_retention_from_state(state)?.as_ref(),
|
||||
obj_info,
|
||||
bypass_governance,
|
||||
)
|
||||
}
|
||||
|
||||
/// Compatibility wrapper for callers that predate fallible metadata lookup.
|
||||
@@ -402,7 +390,10 @@ pub async fn check_object_lock_for_deletion(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use s3s::dto::{ObjectLockEnabled, ObjectLockRule};
|
||||
use crate::bucket::metadata_sys::configured_object_lock_state_for_tests;
|
||||
use rustfs_utils::http::headers::{
|
||||
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
||||
};
|
||||
use time::{Date, Month, PrimitiveDateTime, Time};
|
||||
|
||||
fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime {
|
||||
@@ -411,51 +402,46 @@ mod tests {
|
||||
PrimitiveDateTime::new(date, time).assume_utc()
|
||||
}
|
||||
|
||||
fn default_retention_config(mode: &'static str) -> ObjectLockConfiguration {
|
||||
ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(mode)),
|
||||
days: Some(30),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
fn default_retention(mode: RetentionMode) -> DefaultRetention {
|
||||
DefaultRetention {
|
||||
mode,
|
||||
days: Some(30),
|
||||
years: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_config_blocks_active_default_compliance_even_with_bypass() {
|
||||
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
||||
let retention = default_retention(RetentionMode::Compliance);
|
||||
let obj_info = ObjectInfo {
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true);
|
||||
let result = check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true);
|
||||
|
||||
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_config_allows_active_default_governance_with_bypass() {
|
||||
let config = default_retention_config(ObjectLockRetentionMode::GOVERNANCE);
|
||||
let retention = default_retention(RetentionMode::Governance);
|
||||
let obj_info = ObjectInfo {
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true),
|
||||
check_object_lock_for_deletion_with_default_retention(Some(&retention), &obj_info, true),
|
||||
Ok(None)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_default_retention_rejects_missing_object_mod_time() {
|
||||
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
||||
let retention = default_retention(RetentionMode::Compliance);
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(Some(&config), &ObjectInfo::default(), false)
|
||||
let err = check_object_lock_for_deletion_with_default_retention(Some(&retention), &ObjectInfo::default(), false)
|
||||
.expect_err("default retention needs an authoritative object modification time");
|
||||
|
||||
assert!(err.to_string().contains("modification time"));
|
||||
@@ -465,7 +451,7 @@ mod tests {
|
||||
fn deletion_with_confirmed_absence_still_blocks_explicit_compliance() {
|
||||
let retain_until = OffsetDateTime::now_utc() + time::Duration::days(30);
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
user_defined.insert("x-amz-object-lock-mode".to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string());
|
||||
user_defined.insert("x-amz-object-lock-mode".to_string(), RetentionMode::COMPLIANCE.to_string());
|
||||
user_defined.insert(
|
||||
"x-amz-object-lock-retain-until-date".to_string(),
|
||||
retain_until
|
||||
@@ -477,7 +463,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = check_object_lock_for_deletion_with_config(None, &obj_info, true);
|
||||
let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, true);
|
||||
|
||||
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
||||
}
|
||||
@@ -501,16 +487,13 @@ mod tests {
|
||||
#[test]
|
||||
fn deletion_rejects_incomplete_persisted_retention_metadata() {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
user_defined.insert(
|
||||
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
|
||||
ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
||||
);
|
||||
user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), RetentionMode::COMPLIANCE.to_string());
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
||||
let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false)
|
||||
.expect_err("mode without retain-until date must fail closed");
|
||||
|
||||
assert!(err.to_string().contains("incomplete"));
|
||||
@@ -523,29 +506,24 @@ mod tests {
|
||||
.expect("retain-until date should format");
|
||||
let cases = [
|
||||
("invalid mode", Some("INVALID"), Some(valid_date.as_str()), "retention mode"),
|
||||
(
|
||||
"invalid date",
|
||||
Some(ObjectLockRetentionMode::COMPLIANCE),
|
||||
Some("not-a-date"),
|
||||
"retention date",
|
||||
),
|
||||
("invalid date", Some(RetentionMode::COMPLIANCE), Some("not-a-date"), "retention date"),
|
||||
("date only", None, Some(valid_date.as_str()), "incomplete"),
|
||||
];
|
||||
|
||||
for (case, mode, retain_until, expected) in cases {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
if let Some(mode) = mode {
|
||||
user_defined.insert(X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), mode.to_string());
|
||||
user_defined.insert(AMZ_OBJECT_LOCK_MODE_LOWER.to_string(), mode.to_string());
|
||||
}
|
||||
if let Some(retain_until) = retain_until {
|
||||
user_defined.insert(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(), retain_until.to_string());
|
||||
user_defined.insert(AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER.to_string(), retain_until.to_string());
|
||||
}
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false).expect_err(case);
|
||||
let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false).expect_err(case);
|
||||
assert!(err.to_string().contains(expected), "unexpected {case} error: {err}");
|
||||
}
|
||||
}
|
||||
@@ -579,10 +557,6 @@ mod tests {
|
||||
/// source timestamp of every category that currently locks the version.
|
||||
#[test]
|
||||
fn replication_write_passes_worm_gate_only_with_every_locking_category_timestamp() {
|
||||
use rustfs_utils::http::headers::{
|
||||
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
||||
};
|
||||
|
||||
let hold = [(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, "ON")];
|
||||
let retention = [
|
||||
(AMZ_OBJECT_LOCK_MODE_LOWER, "GOVERNANCE"),
|
||||
@@ -623,19 +597,15 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The bucket default retention locks a version that carries no explicit
|
||||
/// retention keys (`check_object_lock_for_deletion_with_config` judges it
|
||||
/// from the modification time), so the replication bypass must demand the
|
||||
/// retention source timestamp for it too — a tagging-only replication
|
||||
/// write must not overwrite the default-protected version unjudged.
|
||||
/// retention keys (`check_object_lock_for_deletion_with_default_retention`
|
||||
/// judges it from the modification time), so the replication bypass must
|
||||
/// demand the retention source timestamp for it too — a tagging-only
|
||||
/// replication write must not overwrite the default-protected version
|
||||
/// unjudged.
|
||||
#[test]
|
||||
fn replication_write_under_bucket_default_retention_requires_retention_timestamp() {
|
||||
use rustfs_utils::http::headers::{AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER};
|
||||
|
||||
for mode in [ObjectLockRetentionMode::COMPLIANCE, ObjectLockRetentionMode::GOVERNANCE] {
|
||||
let state = ObjectLockConfigState::Configured {
|
||||
config: default_retention_config(mode),
|
||||
updated_at: OffsetDateTime::now_utc(),
|
||||
};
|
||||
for mode in [RetentionMode::Compliance, RetentionMode::Governance] {
|
||||
let state = configured_object_lock_state_for_tests(mode, 30);
|
||||
let no_keys = lock_object_info(std::collections::HashMap::new());
|
||||
assert!(
|
||||
check_object_lock_for_deletion_with_state(&state, &no_keys, false)
|
||||
@@ -689,8 +659,6 @@ mod tests {
|
||||
/// state or malformed persisted lock metadata; both are errors, not a pass.
|
||||
#[test]
|
||||
fn replication_write_worm_gate_fails_closed_on_unverifiable_lock_state() {
|
||||
use rustfs_utils::http::headers::AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER;
|
||||
|
||||
let opts = replication_opts(true, true);
|
||||
let err = replication_write_may_pass_worm_gate(
|
||||
&ObjectLockConfigState::Fabricated,
|
||||
@@ -705,10 +673,7 @@ mod tests {
|
||||
.expect_err("malformed legal hold must not be judged");
|
||||
assert!(err.to_string().contains("legal-hold"));
|
||||
|
||||
let state = ObjectLockConfigState::Configured {
|
||||
config: default_retention_config(ObjectLockRetentionMode::COMPLIANCE),
|
||||
updated_at: OffsetDateTime::now_utc(),
|
||||
};
|
||||
let state = configured_object_lock_state_for_tests(RetentionMode::Compliance, 30);
|
||||
let no_mod_time = ObjectInfo::default();
|
||||
let err = replication_write_may_pass_worm_gate(&state, &no_mod_time, &opts)
|
||||
.expect_err("default retention without a modification time must not be judged");
|
||||
@@ -722,10 +687,6 @@ mod tests {
|
||||
/// (rustfs/backlog#1953).
|
||||
#[test]
|
||||
fn deletion_treats_cleared_empty_lock_metadata_as_unlocked() {
|
||||
use rustfs_utils::http::headers::{
|
||||
AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER, AMZ_OBJECT_LOCK_MODE_LOWER, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE_LOWER,
|
||||
};
|
||||
|
||||
let cases: [(&str, &[&str]); 3] = [
|
||||
(
|
||||
"cleared retention",
|
||||
@@ -749,7 +710,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = check_object_lock_for_deletion_with_config(None, &obj_info, false);
|
||||
let result = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false);
|
||||
assert!(matches!(result, Ok(None)), "{case}: empty lock keys must read as unlocked: {result:?}");
|
||||
}
|
||||
}
|
||||
@@ -757,13 +718,13 @@ mod tests {
|
||||
#[test]
|
||||
fn deletion_rejects_invalid_persisted_legal_hold_metadata() {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
user_defined.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "INVALID".to_string());
|
||||
user_defined.insert(AMZ_OBJECT_LOCK_LEGAL_HOLD_LOWER.to_string(), "INVALID".to_string());
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
||||
let err = check_object_lock_for_deletion_with_default_retention(None, &obj_info, false)
|
||||
.expect_err("invalid legal-hold value must fail closed");
|
||||
|
||||
assert!(err.to_string().contains("legal-hold"));
|
||||
@@ -829,42 +790,29 @@ mod tests {
|
||||
assert_eq!(result.day(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_retention_active_invalid_mode() {
|
||||
// Invalid mode should return false
|
||||
assert!(!is_retention_active("INVALID", None));
|
||||
assert!(!is_retention_active("", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_retention_active_no_date() {
|
||||
// Valid mode but no retain_until_date should return false
|
||||
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, None));
|
||||
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, None));
|
||||
assert!(!is_retention_active(RetentionMode::Compliance, None));
|
||||
assert!(!is_retention_active(RetentionMode::Governance, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_retention_active_future_date() {
|
||||
// Valid mode with future retain_until_date should return true
|
||||
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
|
||||
let s3_date = s3s::dto::Date::from(future_date);
|
||||
|
||||
assert!(is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
|
||||
let future_date = OffsetDateTime::now_utc() + time::Duration::days(30);
|
||||
let s3_date = s3s::dto::Date::from(future_date);
|
||||
assert!(is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
|
||||
assert!(is_retention_active(RetentionMode::Compliance, Some(future_date)));
|
||||
assert!(is_retention_active(RetentionMode::Governance, Some(future_date)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_retention_active_past_date() {
|
||||
// Valid mode with past retain_until_date should return false
|
||||
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
|
||||
let s3_date = s3s::dto::Date::from(past_date);
|
||||
|
||||
assert!(!is_retention_active(ObjectLockRetentionMode::COMPLIANCE, Some(&s3_date)));
|
||||
let past_date = OffsetDateTime::now_utc() - time::Duration::days(30);
|
||||
let s3_date = s3s::dto::Date::from(past_date);
|
||||
assert!(!is_retention_active(ObjectLockRetentionMode::GOVERNANCE, Some(&s3_date)));
|
||||
assert!(!is_retention_active(RetentionMode::Compliance, Some(past_date)));
|
||||
assert!(!is_retention_active(RetentionMode::Governance, Some(past_date)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -890,10 +838,7 @@ mod tests {
|
||||
|
||||
// Extending by another 30 days should be allowed
|
||||
let new_retain = Some(existing_retain + time::Duration::days(30));
|
||||
assert!(
|
||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false)
|
||||
.is_none()
|
||||
);
|
||||
assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -911,8 +856,7 @@ mod tests {
|
||||
|
||||
// Shortening to 30 days should be blocked
|
||||
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(30));
|
||||
let result =
|
||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::COMPLIANCE), new_retain, false);
|
||||
let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), new_retain, false);
|
||||
assert!(result.is_some());
|
||||
assert!(matches!(result, Some(ObjectLockBlockReason::Retention { .. })));
|
||||
}
|
||||
@@ -950,8 +894,7 @@ mod tests {
|
||||
|
||||
// Shortening from 30 days to 15 days without bypass should be blocked
|
||||
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
|
||||
let result =
|
||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false);
|
||||
let result = check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false);
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
@@ -971,10 +914,7 @@ mod tests {
|
||||
|
||||
// Extending from 30 days to 60 days without bypass should be allowed
|
||||
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(60));
|
||||
assert!(
|
||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, false)
|
||||
.is_none()
|
||||
);
|
||||
assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -992,10 +932,7 @@ mod tests {
|
||||
|
||||
// Shortening from 30 days to 15 days with bypass should be allowed
|
||||
let new_retain = Some(OffsetDateTime::now_utc() + time::Duration::days(15));
|
||||
assert!(
|
||||
check_retention_for_modification(&user_defined, Some(ObjectLockRetentionMode::GOVERNANCE), new_retain, true)
|
||||
.is_none()
|
||||
);
|
||||
assert!(check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), new_retain, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1010,12 +947,8 @@ mod tests {
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let result = check_retention_for_modification(
|
||||
&user_defined,
|
||||
Some(ObjectLockRetentionMode::COMPLIANCE),
|
||||
Some(existing_retain),
|
||||
false,
|
||||
);
|
||||
let result =
|
||||
check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), false);
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
@@ -1032,13 +965,8 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(
|
||||
check_retention_for_modification(
|
||||
&user_defined,
|
||||
Some(ObjectLockRetentionMode::COMPLIANCE),
|
||||
Some(existing_retain),
|
||||
true,
|
||||
)
|
||||
.is_none()
|
||||
check_retention_for_modification(&user_defined, Some(RetentionMode::Compliance), Some(existing_retain), true)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1054,12 +982,8 @@ mod tests {
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let result = check_retention_for_modification(
|
||||
&user_defined,
|
||||
Some(ObjectLockRetentionMode::GOVERNANCE),
|
||||
Some(existing_retain),
|
||||
true,
|
||||
);
|
||||
let result =
|
||||
check_retention_for_modification(&user_defined, Some(RetentionMode::Governance), Some(existing_retain), true);
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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.
|
||||
|
||||
//! Storage-level Object Lock types (rustfs/backlog#1842).
|
||||
//!
|
||||
//! The engine evaluates WORM state from persisted object metadata and the
|
||||
//! bucket default retention; none of that needs S3 wire/DTO types. The
|
||||
//! serving layer converts to/from its wire DTOs at its own boundary, and the
|
||||
//! bucket-metadata module converts the persisted `ObjectLockConfiguration`
|
||||
//! into [`DefaultRetention`] when handing it to the evaluation code here.
|
||||
|
||||
use std::fmt;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// Object Lock retention mode. Persisted metadata and the bucket default
|
||||
/// retention only ever carry these two values; anything else is either
|
||||
/// malformed metadata (fail-closed at the parse site) or an inactive
|
||||
/// configuration (ignored at the conversion site).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RetentionMode {
|
||||
Governance,
|
||||
Compliance,
|
||||
}
|
||||
|
||||
impl RetentionMode {
|
||||
pub const GOVERNANCE: &'static str = "GOVERNANCE";
|
||||
pub const COMPLIANCE: &'static str = "COMPLIANCE";
|
||||
|
||||
/// Parse the canonical S3 wire spelling, case-insensitively (matching the
|
||||
/// historical `parse_ret_mode` behavior). Returns `None` for anything
|
||||
/// that is not GOVERNANCE/COMPLIANCE.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
if value.eq_ignore_ascii_case(Self::GOVERNANCE) {
|
||||
Some(Self::Governance)
|
||||
} else if value.eq_ignore_ascii_case(Self::COMPLIANCE) {
|
||||
Some(Self::Compliance)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse only the exact canonical wire spelling. Use this for a mode a
|
||||
/// caller supplies in a *request*: the retention-modification gate has
|
||||
/// always compared the requested mode literally against the canonical
|
||||
/// persisted mode, so a non-canonical spelling must stay "not the same
|
||||
/// mode" (and therefore blocked), not be normalized into a match.
|
||||
pub fn parse_exact(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
Self::GOVERNANCE => Some(Self::Governance),
|
||||
Self::COMPLIANCE => Some(Self::Compliance),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Governance => Self::GOVERNANCE,
|
||||
Self::Compliance => Self::COMPLIANCE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RetentionMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Object Lock legal hold status (ON/OFF).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LegalHoldStatus {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
impl LegalHoldStatus {
|
||||
pub const ON: &'static str = "ON";
|
||||
pub const OFF: &'static str = "OFF";
|
||||
|
||||
/// Parse the canonical S3 wire spelling, case-insensitively (matching the
|
||||
/// historical `parse_legalhold_status` behavior).
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
if value.eq_ignore_ascii_case(Self::ON) {
|
||||
Some(Self::On)
|
||||
} else if value.eq_ignore_ascii_case(Self::OFF) {
|
||||
Some(Self::Off)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::On => Self::ON,
|
||||
Self::Off => Self::OFF,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LegalHoldStatus {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// An object version's retention as read from persisted metadata. `mode` is
|
||||
/// `None` when the metadata carries no (or an unparsable) retention mode.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ObjectRetention {
|
||||
pub mode: Option<RetentionMode>,
|
||||
pub retain_until_date: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
/// An object version's legal hold as read from persisted metadata. `status`
|
||||
/// is `None` when the metadata carries no (or an unparsable) legal hold.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ObjectLegalHold {
|
||||
pub status: Option<LegalHoldStatus>,
|
||||
}
|
||||
|
||||
impl ObjectLegalHold {
|
||||
pub fn is_on(&self) -> bool {
|
||||
self.status == Some(LegalHoldStatus::On)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bucket's default Object Lock retention, converted from the persisted
|
||||
/// configuration. Conversion only yields a value for an active default
|
||||
/// retention (a valid GOVERNANCE/COMPLIANCE mode); a rule without a usable
|
||||
/// mode converts to `None`, matching how the evaluation code has always
|
||||
/// ignored such rules.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DefaultRetention {
|
||||
pub mode: RetentionMode,
|
||||
pub days: Option<i32>,
|
||||
pub years: Option<i32>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The modification gate compares a *requested* mode against the canonical
|
||||
/// persisted mode literally: a non-canonical spelling must not normalize
|
||||
/// into a match, or a client could shorten GOVERNANCE retention without
|
||||
/// bypass by spelling the mode differently. `parse_exact` is that pin.
|
||||
#[test]
|
||||
fn parse_exact_accepts_only_canonical_spellings() {
|
||||
assert_eq!(RetentionMode::parse_exact("GOVERNANCE"), Some(RetentionMode::Governance));
|
||||
assert_eq!(RetentionMode::parse_exact("COMPLIANCE"), Some(RetentionMode::Compliance));
|
||||
for non_canonical in ["governance", "Governance", "compliance", "Compliance", "", "INVALID"] {
|
||||
assert_eq!(RetentionMode::parse_exact(non_canonical), None, "{non_canonical:?} must not parse");
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted metadata parsing stays case-insensitive (the historical
|
||||
/// `parse_ret_mode` / `parse_legalhold_status` behavior): on-disk values
|
||||
/// written by older builds must keep locking.
|
||||
#[test]
|
||||
fn parse_is_case_insensitive_for_persisted_values() {
|
||||
assert_eq!(RetentionMode::parse("governance"), Some(RetentionMode::Governance));
|
||||
assert_eq!(RetentionMode::parse("Compliance"), Some(RetentionMode::Compliance));
|
||||
assert_eq!(LegalHoldStatus::parse("on"), Some(LegalHoldStatus::On));
|
||||
assert_eq!(LegalHoldStatus::parse("Off"), Some(LegalHoldStatus::Off));
|
||||
assert_eq!(RetentionMode::parse("INVALID"), None);
|
||||
assert_eq!(LegalHoldStatus::parse("MAYBE"), None);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
|
||||
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
|
||||
use crate::data_usage::get_bucket_usage_memory;
|
||||
use rustfs_common::metrics::Metric;
|
||||
use rustfs_config::QUOTA_CONFIG_FILE;
|
||||
use rustfs_scanner_contracts::metrics::Metric;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use time::OffsetDateTime;
|
||||
@@ -120,9 +120,9 @@ impl QuotaChecker {
|
||||
|
||||
let duration = start_time.elapsed();
|
||||
// inc_time is now a plain fn (not async) — no .await needed.
|
||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
|
||||
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
|
||||
if !allowed {
|
||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
|
||||
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
@@ -185,7 +185,7 @@ impl QuotaChecker {
|
||||
.await
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
|
||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
Ok(updated_at)
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ impl QuotaChecker {
|
||||
}
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
|
||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
Ok(updated_at)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ use tokio_util::sync::CancellationToken;
|
||||
use super::replication_error_boundary::Error;
|
||||
use super::replication_filemeta_boundary::{replication_state_from_filemeta, version_purge_status_from_filemeta};
|
||||
pub(crate) type ReplicationObjectStore = crate::store::ECStore;
|
||||
pub(crate) use crate::client::api_get_options::{AdvancedGetOptions, StatObjectOptions};
|
||||
pub(crate) use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
pub(crate) use crate::storage_api_contracts::list::{
|
||||
ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions,
|
||||
@@ -29,6 +28,7 @@ pub(crate) use crate::storage_api_contracts::object::{
|
||||
};
|
||||
pub(crate) use crate::storage_api_contracts::range::HTTPRangeSpec;
|
||||
pub(crate) use rustfs_replication::{DeletedObject as ReplicationDeletedObject, ObjectToDelete as ReplicationObjectToDelete};
|
||||
pub(crate) use rustfs_s3_client::api_get_options::{AdvancedGetOptions, StatObjectOptions};
|
||||
|
||||
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
|
||||
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||
|
||||
@@ -200,7 +200,7 @@ impl ReplicationTargetStore {
|
||||
}
|
||||
|
||||
pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo) -> Result<(PutObjectOptions, bool)> {
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use base64_simd::STANDARD as BASE64_STANDARD;
|
||||
use rustfs_utils::http::{AMZ_CHECKSUM_TYPE, AMZ_CHECKSUM_TYPE_FULL_OBJECT};
|
||||
|
||||
let mut meta = HashMap::new();
|
||||
@@ -252,7 +252,7 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
|
||||
&& !checksum_data.is_empty()
|
||||
{
|
||||
if is_ssec {
|
||||
let encoded = BASE64_STANDARD.encode(checksum_data);
|
||||
let encoded = BASE64_STANDARD.encode_to_string(checksum_data);
|
||||
insert_header_map(&mut meta, SUFFIX_REPLICATION_SSEC_CRC, encoded);
|
||||
} else if object_info.is_encrypted() {
|
||||
// Encrypted checksums cannot be exposed as plaintext headers, and
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// The S3-consuming client moved to the `rustfs-s3-client` crate
|
||||
// (rustfs/backlog#1842). This shim keeps `crate::client::*` paths working for
|
||||
// in-crate consumers during the migration window; it is deleted once every
|
||||
// consumer imports `rustfs_s3_client` directly. Only the two server-side
|
||||
// modules below (misfiled here historically) remain as real ecstore code.
|
||||
|
||||
pub use rustfs_s3_client::{
|
||||
admin_handler_utils, api_get_options, api_list, api_put_object, api_remove, api_s3_datatypes, credentials, provider_versions,
|
||||
signer_error, transition_api,
|
||||
};
|
||||
|
||||
pub mod object_api_utils;
|
||||
pub mod object_handlers_common;
|
||||
@@ -31,8 +31,7 @@ use crate::storage_api_contracts::internode::{
|
||||
NS_SCANNER_PROTOCOL_VERSION, PUT_FILE_AUTH_TRAILER_DIGEST_LEN, PUT_FILE_AUTH_TRAILER_LEN, PUT_FILE_AUTH_TRAILER_MAC_LEN,
|
||||
PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_CAPABILITY_VERSION,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose;
|
||||
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use http::uri::Authority;
|
||||
use http::{HeaderMap, HeaderValue, Method, Uri};
|
||||
@@ -523,11 +522,11 @@ fn generate_signature(secret: &str, url: &str, method: &Method, timestamp: i64)
|
||||
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
|
||||
mac.update(data.as_bytes());
|
||||
let result = mac.finalize();
|
||||
general_purpose::STANDARD.encode(result.into_bytes())
|
||||
base64_simd::STANDARD.encode_to_string(result.into_bytes())
|
||||
}
|
||||
|
||||
fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, signature: &str) -> bool {
|
||||
let Ok(signature) = general_purpose::STANDARD.decode(signature) else {
|
||||
let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -745,11 +744,11 @@ fn generate_signature_v2(secret: &str, scope: SignatureV2Scope<'_>) -> std::io::
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_signature_v2(&mut mac, scope);
|
||||
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
|
||||
Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
fn verify_signature_v2(secret: &str, scope: SignatureV2Scope<'_>, signature: &str) -> bool {
|
||||
let Ok(signature) = general_purpose::STANDARD.decode(signature) else {
|
||||
let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut mac) = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()) else {
|
||||
@@ -792,11 +791,11 @@ fn generate_replay_scope_signature(secret: &str, scope: ReplayScope<'_>) -> std:
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_replay_scope(&mut mac, scope);
|
||||
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
|
||||
Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
fn verify_replay_scope_signature(secret: &str, scope: ReplayScope<'_>, signature: &str) -> bool {
|
||||
let Ok(signature) = general_purpose::STANDARD.decode(signature) else {
|
||||
let Ok(signature) = base64_simd::STANDARD.decode_to_vec(signature) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut mac) = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()) else {
|
||||
@@ -821,15 +820,15 @@ fn generate_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_boot_epoch_proof(&mut mac, audience, challenge, boot_epoch);
|
||||
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
|
||||
Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
fn verify_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot_epoch: Uuid, proof: &str) -> std::io::Result<()> {
|
||||
if audience.is_empty() || challenge.is_nil() || boot_epoch.is_nil() {
|
||||
return Err(std::io::Error::other("Invalid RPC boot epoch proof scope"));
|
||||
}
|
||||
let proof = general_purpose::STANDARD
|
||||
.decode(proof)
|
||||
let proof = base64_simd::STANDARD
|
||||
.decode_to_vec(proof)
|
||||
.map_err(|_| std::io::Error::other("Invalid RPC boot epoch proof"))?;
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
@@ -862,7 +861,7 @@ fn generate_replay_cache_capability_proof(
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_replay_cache_capability_proof(&mut mac, audience, challenge, boot_epoch);
|
||||
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
|
||||
Ok(base64_simd::STANDARD.encode_to_string(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
fn verify_replay_cache_capability_proof(
|
||||
@@ -872,8 +871,8 @@ fn verify_replay_cache_capability_proof(
|
||||
boot_epoch: Uuid,
|
||||
proof: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let proof = general_purpose::STANDARD
|
||||
.decode(proof)
|
||||
let proof = base64_simd::STANDARD
|
||||
.decode_to_vec(proof)
|
||||
.map_err(|_| std::io::Error::other("Invalid RPC replay cache capability proof"))?;
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
@@ -1988,9 +1987,9 @@ mod tests {
|
||||
let method = Method::GET;
|
||||
let timestamp = 1640995200;
|
||||
let signature = generate_signature(secret, url, &method, timestamp);
|
||||
let mut tampered = general_purpose::STANDARD.decode(&signature).unwrap();
|
||||
let mut tampered = base64_simd::STANDARD.decode_to_vec(&signature).unwrap();
|
||||
tampered[0] ^= 1;
|
||||
let tampered_signature = general_purpose::STANDARD.encode(tampered);
|
||||
let tampered_signature = base64_simd::STANDARD.encode_to_string(tampered);
|
||||
|
||||
assert!(verify_signature(secret, url, &method, timestamp, &signature));
|
||||
assert!(!verify_signature(secret, url, &method, timestamp, &tampered_signature));
|
||||
|
||||
@@ -113,6 +113,21 @@ fn peer_failure_without_details(op: &str, bucket: Option<&str>) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a control-plane response failure. Peers at or above the typed
|
||||
/// `ControlPlaneErrorCode` change (backlog#1845) carry a machine-readable
|
||||
/// discriminant beside the legacy `error_info` string; prefer it, then fall
|
||||
/// back to the string, then to the detail-free per-op failure.
|
||||
/// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): string fallback for peers that predate the typed wire code. Remove after the minimum supported RustFS peer version always sends error_code.
|
||||
fn control_plane_failure(op: &str, bucket: Option<&str>, error_code: Option<i32>, error_info: Option<String>) -> Error {
|
||||
if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32) {
|
||||
return Error::RemoteNotInitialized;
|
||||
}
|
||||
match error_info {
|
||||
Some(msg) => Error::other(msg),
|
||||
None => peer_failure_without_details(op, bucket),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_bucket_stats_response(response: GetBucketStatsDataResponse) -> Result<BucketStats> {
|
||||
if !response.success {
|
||||
return Err(Error::other(
|
||||
@@ -845,10 +860,12 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.local_storage_info(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("local_storage_info", None));
|
||||
return Err(control_plane_failure(
|
||||
"local_storage_info",
|
||||
None,
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
let data = response.storage_info;
|
||||
|
||||
@@ -1235,11 +1252,10 @@ impl PeerRestClient {
|
||||
Err(status) => return Err(status.into()),
|
||||
};
|
||||
if !response.success {
|
||||
return Err(Error::other(
|
||||
response
|
||||
.error_info
|
||||
.unwrap_or_else(|| "peer background heal status failed without an error".to_string()),
|
||||
));
|
||||
return Err(match (response.error_code, response.error_info) {
|
||||
(None, None) => Error::other("peer background heal status failed without an error"),
|
||||
(error_code, error_info) => control_plane_failure("background_heal_status", None, error_code, error_info),
|
||||
});
|
||||
}
|
||||
Ok(Some(response.bg_heal_state.to_vec()))
|
||||
}
|
||||
@@ -1267,11 +1283,12 @@ impl PeerRestClient {
|
||||
Err(status) => return Err(status.into()),
|
||||
};
|
||||
if !response.success {
|
||||
return Err(Error::other(
|
||||
response
|
||||
.error_info
|
||||
.unwrap_or_else(|| "peer replacement recovery status failed without an error".to_string()),
|
||||
));
|
||||
return Err(match (response.error_code, response.error_info) {
|
||||
(None, None) => Error::other("peer replacement recovery status failed without an error"),
|
||||
(error_code, error_info) => {
|
||||
control_plane_failure("replacement_recovery_status", None, error_code, error_info)
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Some(response.recovery_status.to_vec()))
|
||||
}
|
||||
@@ -1489,10 +1506,12 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.load_bucket_metadata(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("load_bucket_metadata", Some(bucket)));
|
||||
return Err(control_plane_failure(
|
||||
"load_bucket_metadata",
|
||||
Some(bucket),
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1531,10 +1550,7 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.delete_policy(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("delete_policy", None));
|
||||
return Err(control_plane_failure("delete_policy", None, response.error_code, response.error_info));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1554,10 +1570,7 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.load_policy(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("load_policy", None));
|
||||
return Err(control_plane_failure("load_policy", None, response.error_code, response.error_info));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1579,10 +1592,12 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.load_policy_mapping(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("load_policy_mapping", None));
|
||||
return Err(control_plane_failure(
|
||||
"load_policy_mapping",
|
||||
None,
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1602,10 +1617,7 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.delete_user(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("delete_user", None));
|
||||
return Err(control_plane_failure("delete_user", None, response.error_code, response.error_info));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1625,10 +1637,12 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.delete_service_account(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("delete_service_account", None));
|
||||
return Err(control_plane_failure(
|
||||
"delete_service_account",
|
||||
None,
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1649,10 +1663,7 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.load_user(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("load_user", None));
|
||||
return Err(control_plane_failure("load_user", None, response.error_code, response.error_info));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1672,10 +1683,12 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.load_service_account(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("load_service_account", None));
|
||||
return Err(control_plane_failure(
|
||||
"load_service_account",
|
||||
None,
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1695,10 +1708,7 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.load_group(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("load_group", None));
|
||||
return Err(control_plane_failure("load_group", None, response.error_code, response.error_info));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1716,10 +1726,12 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.reload_site_replication_config(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("reload_site_replication_config", None));
|
||||
return Err(control_plane_failure(
|
||||
"reload_site_replication_config",
|
||||
None,
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1987,10 +1999,7 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.reload_pool_meta(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("reload_pool_meta", None));
|
||||
return Err(control_plane_failure("reload_pool_meta", None, response.error_code, response.error_info));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2011,10 +2020,7 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.stop_rebalance(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("stop_rebalance", None));
|
||||
return Err(control_plane_failure("stop_rebalance", None, response.error_code, response.error_info));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2045,10 +2051,12 @@ impl PeerRestClient {
|
||||
"peer rebalance metadata response"
|
||||
);
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("load_rebalance_meta", None));
|
||||
return Err(control_plane_failure(
|
||||
"load_rebalance_meta",
|
||||
None,
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2073,10 +2081,12 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.start_decommission(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("start_decommission", None));
|
||||
return Err(control_plane_failure(
|
||||
"start_decommission",
|
||||
None,
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2097,10 +2107,12 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.cancel_decommission(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("decommission_cancel", None));
|
||||
return Err(control_plane_failure(
|
||||
"decommission_cancel",
|
||||
None,
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2121,10 +2133,12 @@ impl PeerRestClient {
|
||||
|
||||
let response = client.clear_decommission(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(peer_failure_without_details("clear_decommission", None));
|
||||
return Err(control_plane_failure(
|
||||
"clear_decommission",
|
||||
None,
|
||||
response.error_code,
|
||||
response.error_info,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -2180,7 +2194,7 @@ impl PeerRestClient {
|
||||
Err(status) => return tier_config_reload_status_outcome(status),
|
||||
};
|
||||
if !response.success {
|
||||
return tier_config_reload_remote_failure(response.error_info);
|
||||
return tier_config_reload_remote_failure(response.error_code, response.error_info);
|
||||
}
|
||||
|
||||
TierConfigReloadOutcome::Success
|
||||
@@ -2239,7 +2253,13 @@ fn is_tier_config_reload_connection_failure(err: &Error) -> bool {
|
||||
/// reload every `TIER_CONFIG_RELOAD_RETRY_CAP`, and `Terminal` stays reachable
|
||||
/// for transport and gRPC status failures, which is where a genuinely
|
||||
/// unrecoverable peer surfaces.
|
||||
fn tier_config_reload_remote_failure(error_info: Option<String>) -> TierConfigReloadOutcome {
|
||||
fn tier_config_reload_remote_failure(error_code: Option<i32>, error_info: Option<String>) -> TierConfigReloadOutcome {
|
||||
// Remote rejections are transient by design (see the doc comment above);
|
||||
// the typed not-initialized code keeps the error typed for downstream
|
||||
// classifiers instead of a bare string (backlog#1845).
|
||||
if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32) {
|
||||
return TierConfigReloadOutcome::TransientRetrySameChannel(Error::RemoteNotInitialized);
|
||||
}
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(Error::other(error_info.unwrap_or_default()))
|
||||
}
|
||||
|
||||
@@ -2278,6 +2298,63 @@ mod tests {
|
||||
use temp_env::async_with_vars;
|
||||
use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt};
|
||||
|
||||
#[test]
|
||||
fn control_plane_failure_prefers_typed_not_initialized_code() {
|
||||
use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode;
|
||||
let code = Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32);
|
||||
|
||||
// Typed code wins even when the legacy string is present (dual-write).
|
||||
let err = control_plane_failure("load_bucket_metadata", Some("b"), code, Some("errServerNotInitialized".to_string()));
|
||||
assert!(matches!(err, Error::RemoteNotInitialized));
|
||||
assert!(crate::error::is_err_not_initialized(&err), "typed variant must satisfy the predicate");
|
||||
|
||||
// Legacy peers: no code, string only — the substring fallback still classifies.
|
||||
let err = control_plane_failure("load_bucket_metadata", Some("b"), None, Some("errServerNotInitialized".to_string()));
|
||||
assert!(crate::error::is_err_not_initialized(&err), "legacy string form must keep classifying");
|
||||
|
||||
// No code, no string: detail-free per-op failure, not misread as not-initialized.
|
||||
let err = control_plane_failure("load_bucket_metadata", Some("b"), None, None);
|
||||
assert!(!crate::error::is_err_not_initialized(&err));
|
||||
assert!(err.to_string().contains("load_bucket_metadata"));
|
||||
|
||||
// Unspecified code behaves like no code.
|
||||
let err = control_plane_failure(
|
||||
"load_bucket_metadata",
|
||||
None,
|
||||
Some(ControlPlaneErrorCode::ControlPlaneErrorUnspecified as i32),
|
||||
Some("boom".to_string()),
|
||||
);
|
||||
assert!(!matches!(err, Error::RemoteNotInitialized));
|
||||
assert_eq!(err.to_string(), "Io error: boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_plane_not_initialized_wire_value_is_pinned() {
|
||||
// The discriminant is wire contract: old peers ignore it, but a renumber
|
||||
// would silently flip classification on mixed-version clusters.
|
||||
use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode;
|
||||
assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorUnspecified as i32, 0);
|
||||
assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_config_reload_remote_failure_keeps_typed_not_initialized() {
|
||||
use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode;
|
||||
let outcome = tier_config_reload_remote_failure(
|
||||
Some(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32),
|
||||
Some("errServerNotInitialized".to_string()),
|
||||
);
|
||||
match outcome {
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(err) => {
|
||||
assert!(matches!(err, Error::RemoteNotInitialized));
|
||||
}
|
||||
TierConfigReloadOutcome::TransientReconnect(err) | TierConfigReloadOutcome::Terminal(err) => {
|
||||
panic!("not-initialized must stay retry-same-channel, got {err}")
|
||||
}
|
||||
TierConfigReloadOutcome::Success => panic!("a rejection cannot classify as success"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_publication_lease_response_rejects_stale_generation_and_session() {
|
||||
let token = Uuid::new_v4();
|
||||
@@ -3031,11 +3108,11 @@ mod tests {
|
||||
// retired: the channel is healthy, so the rejection reflects remote state
|
||||
// that the next attempt can find healed.
|
||||
assert!(matches!(
|
||||
tier_config_reload_remote_failure(Some("backend unavailable".to_string())),
|
||||
tier_config_reload_remote_failure(None, Some("backend unavailable".to_string())),
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_remote_failure(Some("errServerNotInitialized".to_string())),
|
||||
tier_config_reload_remote_failure(None, Some("errServerNotInitialized".to_string())),
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
@@ -3080,7 +3157,7 @@ mod tests {
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
tier_config_reload_remote_failure(Some(error_info.to_string())),
|
||||
tier_config_reload_remote_failure(None, Some(error_info.to_string())),
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(_)
|
||||
),
|
||||
"a peer that rejected the apply must stay retryable so it converges: {error_info}"
|
||||
@@ -3089,7 +3166,7 @@ mod tests {
|
||||
|
||||
// An absent error message is still a rejection, not a reason to stop.
|
||||
assert!(matches!(
|
||||
tier_config_reload_remote_failure(None),
|
||||
tier_config_reload_remote_failure(None, None),
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(_)
|
||||
));
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ use crate::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use futures::future::join_all;
|
||||
use rustfs_common::heal_channel::{DriveState, HealItemType, HealOpts, RUSTFS_RESERVED_BUCKET};
|
||||
use rustfs_heal_contracts::heal_channel::{DriveState, HealItemType, HealOpts, RUSTFS_RESERVED_BUCKET};
|
||||
use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem};
|
||||
use rustfs_protos::proto_gen::node_service::node_service_client::NodeServiceClient;
|
||||
use rustfs_protos::proto_gen::node_service::{
|
||||
|
||||
@@ -35,7 +35,11 @@ use crate::disk::{
|
||||
health_state::{RuntimeDriveHealthState, get_drive_returning_probe_interval, record_drive_runtime_state},
|
||||
validate_batch_read_version_item_count,
|
||||
};
|
||||
use crate::disk::{disk_store::DiskHealthTracker, error::DiskError, local::ScanGuard};
|
||||
use crate::disk::{
|
||||
disk_store::DiskHealthTracker,
|
||||
error::{DiskError, is_terminal_read_error, terminal_read_error_to_io},
|
||||
local::ScanGuard,
|
||||
};
|
||||
use crate::set_disk::DEFAULT_READ_BUFFER_SIZE;
|
||||
use bytes::Bytes;
|
||||
use futures::lock::Mutex;
|
||||
@@ -324,6 +328,39 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a terminal fresh-shard recovery failure for adaptive retirement while
|
||||
/// retaining its typed `DiskError` and original I/O kind. The decoder checks
|
||||
/// the marker independently of the kind because not-found and transport
|
||||
/// failures are terminal too, but must not be reported as timeouts.
|
||||
fn remote_read_error_to_io(error: DiskError) -> io::Error {
|
||||
terminal_read_error_to_io(error)
|
||||
}
|
||||
|
||||
/// Retire a remote shard after its stream can no longer be trusted. A body
|
||||
/// error that arrives after the one permitted resume is terminal: retaining
|
||||
/// the reader would let the next stripe poll an already misaligned stream.
|
||||
fn remote_terminal_io_error(error: io::Error) -> io::Error {
|
||||
if is_terminal_read_error(&error) {
|
||||
return error;
|
||||
}
|
||||
terminal_read_error_to_io(DiskError::from(error))
|
||||
}
|
||||
|
||||
fn remote_terminal_message_to_io(message: &'static str) -> io::Error {
|
||||
terminal_read_error_to_io(DiskError::Io(io::Error::other(message)))
|
||||
}
|
||||
|
||||
fn remote_terminal_eof_to_io() -> io::Error {
|
||||
terminal_read_error_to_io(DiskError::Io(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"remote read ended before requested length",
|
||||
)))
|
||||
}
|
||||
|
||||
fn remote_terminal_task_error_to_io(error: JoinError) -> io::Error {
|
||||
terminal_read_error_to_io(DiskError::other(error))
|
||||
}
|
||||
|
||||
struct AbortOnDropTask<T>(JoinHandle<T>);
|
||||
|
||||
impl<T> AbortOnDropTask<T> {
|
||||
@@ -418,6 +455,9 @@ impl RetryingRemoteReader {
|
||||
|
||||
impl AsyncRead for RetryingRemoteReader {
|
||||
fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
|
||||
if buf.remaining() == 0 {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
loop {
|
||||
// After the absolute cutoff, let initial progress win over a stale fresh-open.
|
||||
let resume_pending = if let Some(resume) = self.resume.as_mut() {
|
||||
@@ -431,14 +471,14 @@ impl AsyncRead for RetryingRemoteReader {
|
||||
Poll::Ready(Ok(Err(error))) => {
|
||||
self.resume = None;
|
||||
if self.reader.is_none() {
|
||||
return Poll::Ready(Err(io::Error::other(error)));
|
||||
return Poll::Ready(Err(remote_read_error_to_io(error)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.resume = None;
|
||||
if self.reader.is_none() {
|
||||
return Poll::Ready(Err(io::Error::other(error)));
|
||||
return Poll::Ready(Err(remote_terminal_task_error_to_io(error)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -479,6 +519,9 @@ impl AsyncRead for RetryingRemoteReader {
|
||||
} else {
|
||||
self.resume = None;
|
||||
}
|
||||
} else if produced == 0 && self.request.length != 0 && self.emitted < self.request.length {
|
||||
self.reader = None;
|
||||
return Poll::Ready(Err(remote_terminal_eof_to_io()));
|
||||
}
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
@@ -494,7 +537,10 @@ impl AsyncRead for RetryingRemoteReader {
|
||||
self.reader = None;
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.reader = None;
|
||||
return Poll::Ready(Err(remote_terminal_io_error(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -585,21 +631,23 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader {
|
||||
Poll::Ready(Ok(Ok(None))) => {
|
||||
self.resume = None;
|
||||
if self.reader.is_none() {
|
||||
return Poll::Ready(Err(io::Error::other("remote resume transport did not provide a chunk reader")));
|
||||
return Poll::Ready(Err(remote_terminal_message_to_io(
|
||||
"remote resume transport did not provide a chunk reader",
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Ok(Err(error))) => {
|
||||
self.resume = None;
|
||||
if self.reader.is_none() {
|
||||
return Poll::Ready(Err(io::Error::other(error)));
|
||||
return Poll::Ready(Err(remote_read_error_to_io(error)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.resume = None;
|
||||
if self.reader.is_none() {
|
||||
return Poll::Ready(Err(io::Error::other(error)));
|
||||
return Poll::Ready(Err(remote_terminal_task_error_to_io(error)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -635,10 +683,26 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader {
|
||||
return Poll::Ready(Ok(Some(chunk)));
|
||||
}
|
||||
Poll::Ready(Ok(None)) if resume_pending => {
|
||||
// A clean EOF from the original stream wins when the
|
||||
// request is unbounded (or has already emitted its full
|
||||
// bounded length). Waiting for a speculative fresh open
|
||||
// in that case can turn a successful read into a recovery
|
||||
// timeout, especially on the read_file/unbounded path.
|
||||
if self.request.length == 0 || self.emitted >= self.request.length {
|
||||
self.reader = None;
|
||||
self.resume = None;
|
||||
return Poll::Ready(Ok(None));
|
||||
}
|
||||
self.reader = None;
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Ok(None)) => return Poll::Ready(Ok(None)),
|
||||
Poll::Ready(Ok(None)) => {
|
||||
if self.request.length != 0 && self.emitted < self.request.length {
|
||||
self.reader = None;
|
||||
return Poll::Ready(Err(remote_terminal_eof_to_io()));
|
||||
}
|
||||
return Poll::Ready(Ok(None));
|
||||
}
|
||||
Poll::Ready(Err(error)) if !self.retried && is_retryable_remote_body_error(&error) => {
|
||||
self.retried = true;
|
||||
self.reader = None;
|
||||
@@ -651,7 +715,10 @@ impl rustfs_rio::ChunkReader for RetryingRemoteChunkReader {
|
||||
self.reader = None;
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.reader = None;
|
||||
return Poll::Ready(Err(remote_terminal_io_error(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5154,6 +5221,7 @@ mod tests {
|
||||
enum ResumeReadStep {
|
||||
PartialThenReset(Vec<u8>),
|
||||
Data(Vec<u8>),
|
||||
Eof,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -5412,6 +5480,7 @@ mod tests {
|
||||
struct PendingFreshOpenTransport {
|
||||
fresh_read_drops: Arc<AtomicUsize>,
|
||||
fresh_chunk_drops: Arc<AtomicUsize>,
|
||||
initial_chunk_eof: bool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -5429,6 +5498,9 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
|
||||
if self.initial_chunk_eof {
|
||||
return Ok(Some(resume_step_chunk_reader(ResumeReadStep::Eof)));
|
||||
}
|
||||
Ok(Some(Box::new(ChunkPartialThenErrorReader {
|
||||
data: None,
|
||||
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
|
||||
@@ -5457,6 +5529,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TerminalFreshOpenTransport {
|
||||
fresh_read_opens: Arc<AtomicUsize>,
|
||||
fresh_chunk_opens: Arc<AtomicUsize>,
|
||||
chunk_returns_none: bool,
|
||||
}
|
||||
|
||||
impl TerminalFreshOpenTransport {
|
||||
fn new(chunk_returns_none: bool) -> Self {
|
||||
Self {
|
||||
fresh_read_opens: Arc::new(AtomicUsize::new(0)),
|
||||
fresh_chunk_opens: Arc::new(AtomicUsize::new(0)),
|
||||
chunk_returns_none,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InternodeDataTransport for TerminalFreshOpenTransport {
|
||||
async fn open_read(&self, _request: ReadStreamRequest) -> Result<FileReader> {
|
||||
Ok(Box::new(PartialThenErrorReader {
|
||||
cursor: Cursor::new(Vec::new()),
|
||||
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn open_read_fresh(&self, _request: ReadStreamRequest) -> Result<FileReader> {
|
||||
self.fresh_read_opens.fetch_add(1, Ordering::Relaxed);
|
||||
Err(DiskError::FileNotFound)
|
||||
}
|
||||
|
||||
async fn open_read_chunks(&self, _request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
|
||||
Ok(Some(Box::new(ChunkPartialThenErrorReader {
|
||||
data: Some(Bytes::from_static(b"x")),
|
||||
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn open_read_chunks_fresh(&self, _request: ReadStreamRequest) -> Result<Option<rustfs_rio::ChunkReaderBox>> {
|
||||
self.fresh_chunk_opens.fetch_add(1, Ordering::Relaxed);
|
||||
if self.chunk_returns_none {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(DiskError::FileNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_write(&self, _request: WriteStreamRequest) -> Result<FileWriter> {
|
||||
panic!("open_write should not be used in terminal fresh-open tests");
|
||||
}
|
||||
|
||||
async fn open_walk_dir(&self, _request: WalkDirStreamRequest) -> Result<FileReader> {
|
||||
panic!("open_walk_dir should not be used in terminal fresh-open tests");
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"terminal-fresh-open-test"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> InternodeDataTransportCapabilities {
|
||||
InternodeDataTransportCapabilities::tcp_http()
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_step_reader(step: ResumeReadStep) -> FileReader {
|
||||
match step {
|
||||
ResumeReadStep::PartialThenReset(data) => Box::new(PartialThenErrorReader {
|
||||
@@ -5464,6 +5600,7 @@ mod tests {
|
||||
error: Some(io::Error::new(std_io::ErrorKind::ConnectionReset, "stream reset")),
|
||||
}),
|
||||
ResumeReadStep::Data(data) => Box::new(Cursor::new(data)),
|
||||
ResumeReadStep::Eof => Box::new(Cursor::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5477,6 +5614,7 @@ mod tests {
|
||||
data: Some(Bytes::from(data)),
|
||||
error: None,
|
||||
}),
|
||||
ResumeReadStep::Eof => Box::new(ChunkPartialThenErrorReader { data: None, error: None }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5553,6 +5691,430 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn partial_hashed_shard(shard_size: usize) -> (rustfs_utils::HashAlgorithm, Vec<u8>, usize) {
|
||||
let checksum = rustfs_utils::HashAlgorithm::HighwayHash256S;
|
||||
let data = vec![0x5a; shard_size];
|
||||
let hash_bytes = {
|
||||
let hash = checksum.hash_encode(&data);
|
||||
hash.as_ref().to_vec()
|
||||
};
|
||||
let hash_len = hash_bytes.len();
|
||||
let encoded_length = hash_len + data.len();
|
||||
let mut prefix = Vec::with_capacity(hash_len + shard_size / 2);
|
||||
prefix.extend_from_slice(&hash_bytes);
|
||||
prefix.extend_from_slice(&data[..shard_size / 2]);
|
||||
(checksum, prefix, encoded_length)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_read_error_conversion_preserves_recovery_classification() {
|
||||
for disk_error in [DiskError::Timeout, DiskError::SourceStalled] {
|
||||
let error = remote_read_error_to_io(disk_error);
|
||||
assert_eq!(error.kind(), std_io::ErrorKind::TimedOut);
|
||||
}
|
||||
|
||||
let error = remote_read_error_to_io(DiskError::Timeout);
|
||||
assert!(
|
||||
error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<crate::disk::error::TerminalReadError>())
|
||||
.is_some()
|
||||
);
|
||||
assert!(matches!(DiskError::from(error), DiskError::Timeout));
|
||||
|
||||
let error = remote_read_error_to_io(DiskError::SourceStalled);
|
||||
assert!(matches!(DiskError::from(error), DiskError::SourceStalled));
|
||||
|
||||
let error =
|
||||
remote_read_error_to_io(DiskError::Io(io::Error::new(std_io::ErrorKind::ConnectionReset, "connection reset")));
|
||||
assert_eq!(error.kind(), std_io::ErrorKind::ConnectionReset);
|
||||
assert!(crate::disk::error::is_terminal_read_error(&error));
|
||||
assert!(matches!(DiskError::from(error), DiskError::Io(inner) if inner.kind() == std_io::ErrorKind::ConnectionReset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_reader_zero_capacity_poll_is_a_noop() {
|
||||
let transport: Arc<dyn InternodeDataTransport> = Arc::new(PendingFreshOpenTransport::default());
|
||||
let mut reader = RetryingRemoteReader::new_with_timeouts(
|
||||
Box::new(Cursor::new(b"x".to_vec())),
|
||||
transport,
|
||||
resume_request(1),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut empty = [];
|
||||
let mut read_buf = ReadBuf::new(&mut empty);
|
||||
let mut cx = Context::from_waker(std::task::Waker::noop());
|
||||
|
||||
assert!(matches!(Pin::new(&mut reader).poll_read(&mut cx, &mut read_buf), Poll::Ready(Ok(()))));
|
||||
assert!(reader.reader.is_some(), "zero-capacity polls must not retire the remote reader");
|
||||
|
||||
let mut output = Vec::new();
|
||||
futures::executor::block_on(reader.read_to_end(&mut output)).expect("the reader should remain usable");
|
||||
assert_eq!(output, b"x");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn remote_reader_fresh_open_timeout_preserves_timed_out_kind() {
|
||||
let transport = Arc::new(PendingFreshOpenTransport::default());
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let mut reader = RetryingRemoteReader::new_with_timeouts(
|
||||
resume_step_reader(ResumeReadStep::PartialThenReset(Vec::new())),
|
||||
transport_for_reader,
|
||||
resume_request(1),
|
||||
None,
|
||||
Some(Duration::from_secs(1)),
|
||||
);
|
||||
|
||||
let error = reader
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("a hung fresh open must surface its recovery timeout");
|
||||
|
||||
assert_eq!(error.kind(), std_io::ErrorKind::TimedOut);
|
||||
assert!(
|
||||
error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<crate::disk::error::TerminalReadError>())
|
||||
.is_some()
|
||||
);
|
||||
assert!(matches!(DiskError::from(error), DiskError::Timeout));
|
||||
assert_eq!(transport.fresh_read_drops.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn remote_chunk_reader_fresh_open_timeout_preserves_timed_out_kind() {
|
||||
let transport = Arc::new(PendingFreshOpenTransport::default());
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let mut reader = RetryingRemoteChunkReader::new_with_timeouts(
|
||||
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"x".to_vec())),
|
||||
transport_for_reader,
|
||||
resume_request(2),
|
||||
None,
|
||||
Some(Duration::from_secs(1)),
|
||||
);
|
||||
|
||||
let error = reader
|
||||
.read_to_end(&mut Vec::new())
|
||||
.await
|
||||
.expect_err("a hung fresh chunk open must surface its recovery timeout");
|
||||
|
||||
assert_eq!(error.kind(), std_io::ErrorKind::TimedOut);
|
||||
assert!(
|
||||
error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<crate::disk::error::TerminalReadError>())
|
||||
.is_some()
|
||||
);
|
||||
assert!(matches!(DiskError::from(error), DiskError::Timeout));
|
||||
assert_eq!(transport.fresh_chunk_drops.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn remote_chunk_reader_unbounded_clean_eof_wins_over_speculative_resume() {
|
||||
let transport = Arc::new(PendingFreshOpenTransport {
|
||||
initial_chunk_eof: true,
|
||||
..PendingFreshOpenTransport::default()
|
||||
});
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let mut reader = RetryingRemoteChunkReader::new_with_timeouts(
|
||||
resume_step_chunk_reader(ResumeReadStep::Eof),
|
||||
transport_for_reader,
|
||||
resume_request(0),
|
||||
Some(Duration::ZERO),
|
||||
Some(Duration::from_secs(1)),
|
||||
);
|
||||
|
||||
let mut output = Vec::new();
|
||||
reader
|
||||
.read_to_end(&mut output)
|
||||
.await
|
||||
.expect("clean EOF from an unbounded original stream should finish the read");
|
||||
assert!(output.is_empty());
|
||||
// The executor may abort the speculative task before it is first
|
||||
// polled, in which case the pending-open future never constructs its
|
||||
// drop probe. The dedicated drop-cancellation tests cover the
|
||||
// already-polled case; this regression only needs to establish that a
|
||||
// clean unbounded EOF is not converted into a recovery timeout.
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn remote_reader_fresh_open_non_timeout_error_is_retired_from_adaptive_decode() {
|
||||
let transport = Arc::new(TerminalFreshOpenTransport::new(false));
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let retry = RetryingRemoteReader::new_with_timeouts(
|
||||
resume_step_reader(ResumeReadStep::PartialThenReset(Vec::new())),
|
||||
transport_for_reader,
|
||||
resume_request(8),
|
||||
None,
|
||||
Some(Duration::from_secs(1)),
|
||||
);
|
||||
let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 8, rustfs_utils::HashAlgorithm::None, false);
|
||||
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 8), 0, 16);
|
||||
|
||||
let (_, first_errors) = parallel.read().await;
|
||||
assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
|
||||
assert_eq!(transport.fresh_read_opens.load(Ordering::Relaxed), 1);
|
||||
|
||||
let (_, second_errors) = parallel.read().await;
|
||||
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
|
||||
assert_eq!(transport.fresh_read_opens.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn remote_chunk_reader_missing_fresh_reader_is_retired_from_adaptive_decode() {
|
||||
let transport = Arc::new(TerminalFreshOpenTransport::new(true));
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let retry = RetryingRemoteChunkReader::new_with_timeouts(
|
||||
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"x".to_vec())),
|
||||
transport_for_reader,
|
||||
resume_request(8),
|
||||
None,
|
||||
Some(Duration::from_secs(1)),
|
||||
);
|
||||
let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 8, rustfs_utils::HashAlgorithm::None, false);
|
||||
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 8), 0, 16);
|
||||
|
||||
let (_, first_errors) = parallel.read().await;
|
||||
assert!(
|
||||
matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::Other),
|
||||
"unexpected first errors: {first_errors:?}"
|
||||
);
|
||||
assert_eq!(transport.fresh_chunk_opens.load(Ordering::Relaxed), 1);
|
||||
|
||||
let (_, second_errors) = parallel.read().await;
|
||||
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
|
||||
assert_eq!(transport.fresh_chunk_opens.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_reader_fresh_open_short_eof_is_retired_from_adaptive_decode() {
|
||||
// A successful fresh open can still end before the bounded request.
|
||||
// Treat that as terminal immediately so the next stripe does not poll
|
||||
// an already exhausted reader and defer the failure to BitrotReader.
|
||||
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Eof]));
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let retry = RetryingRemoteReader::new_with_timeouts(
|
||||
resume_step_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())),
|
||||
transport_for_reader,
|
||||
resume_request(7),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false);
|
||||
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14);
|
||||
|
||||
let (_, first_errors) = parallel.read().await;
|
||||
assert!(matches!(
|
||||
first_errors.first().and_then(Option::as_ref),
|
||||
Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::UnexpectedEof
|
||||
));
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_read_requests
|
||||
.lock()
|
||||
.expect("fresh read request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
let (_, second_errors) = parallel.read().await;
|
||||
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_read_requests
|
||||
.lock()
|
||||
.expect("fresh read request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_chunk_reader_fresh_open_short_eof_is_retired_from_adaptive_decode() {
|
||||
let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::Eof]));
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let retry = RetryingRemoteChunkReader::new_with_timeouts(
|
||||
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())),
|
||||
transport_for_reader,
|
||||
resume_request(7),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false);
|
||||
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14);
|
||||
|
||||
let (_, first_errors) = parallel.read().await;
|
||||
assert!(matches!(
|
||||
first_errors.first().and_then(Option::as_ref),
|
||||
Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::UnexpectedEof
|
||||
));
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_chunk_requests
|
||||
.lock()
|
||||
.expect("fresh chunk request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
let (_, second_errors) = parallel.read().await;
|
||||
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_chunk_requests
|
||||
.lock()
|
||||
.expect("fresh chunk request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_reader_second_body_reset_is_retired_from_adaptive_decode() {
|
||||
// The first connection emits a prefix, the one permitted fresh
|
||||
// connection emits another prefix, and then resets again. The second
|
||||
// reset must retire the reader so the next stripe cannot consume a
|
||||
// misaligned stream.
|
||||
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::PartialThenReset(b"23".to_vec())]));
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let retry = RetryingRemoteReader::new_with_timeouts(
|
||||
resume_step_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())),
|
||||
transport_for_reader,
|
||||
resume_request(7),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false);
|
||||
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14);
|
||||
|
||||
let (_, first_errors) = parallel.read().await;
|
||||
assert!(matches!(
|
||||
first_errors.first().and_then(Option::as_ref),
|
||||
Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::ConnectionReset
|
||||
));
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_read_requests
|
||||
.lock()
|
||||
.expect("fresh read request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
let (_, second_errors) = parallel.read().await;
|
||||
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_read_requests
|
||||
.lock()
|
||||
.expect("fresh read request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_chunk_reader_second_body_reset_is_retired_from_adaptive_decode() {
|
||||
let transport = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::PartialThenReset(b"23".to_vec())]));
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let retry = RetryingRemoteChunkReader::new_with_timeouts(
|
||||
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(b"01".to_vec())),
|
||||
transport_for_reader,
|
||||
resume_request(7),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), 7, rustfs_utils::HashAlgorithm::None, false);
|
||||
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, 7), 0, 14);
|
||||
|
||||
let (_, first_errors) = parallel.read().await;
|
||||
assert!(matches!(
|
||||
first_errors.first().and_then(Option::as_ref),
|
||||
Some(DiskError::Io(error)) if error.kind() == std_io::ErrorKind::ConnectionReset
|
||||
));
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_chunk_requests
|
||||
.lock()
|
||||
.expect("fresh chunk request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
let (_, second_errors) = parallel.read().await;
|
||||
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
|
||||
assert_eq!(
|
||||
transport
|
||||
.fresh_chunk_requests
|
||||
.lock()
|
||||
.expect("fresh chunk request lock should not be poisoned")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial]
|
||||
async fn remote_reader_hashed_timeout_is_retired_from_adaptive_decode() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, Some("5"))], async {
|
||||
const SHARD_SIZE: usize = 64;
|
||||
let (checksum, encoded_prefix, encoded_length) = partial_hashed_shard(SHARD_SIZE);
|
||||
let transport = Arc::new(PendingFreshOpenTransport::default());
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let retry = RetryingRemoteReader::new_with_timeouts(
|
||||
resume_step_reader(ResumeReadStep::PartialThenReset(encoded_prefix)),
|
||||
transport_for_reader,
|
||||
resume_request(encoded_length),
|
||||
None,
|
||||
Some(Duration::from_secs(1)),
|
||||
);
|
||||
let shard = BitrotReader::new(ShardReader::Stream(Box::new(retry)), SHARD_SIZE, checksum, false);
|
||||
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, SHARD_SIZE), 0, SHARD_SIZE * 2);
|
||||
|
||||
let (first_buffers, first_errors) = parallel.read().await;
|
||||
assert!(first_buffers.first().and_then(Option::as_ref).is_none());
|
||||
assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Timeout)));
|
||||
assert_eq!(transport.fresh_read_drops.load(Ordering::Relaxed), 1);
|
||||
|
||||
// A TimedOut error retires the dead slot. The next stripe therefore
|
||||
// reports the slot as unavailable instead of polling a reader whose
|
||||
// fresh connection already timed out.
|
||||
let (_, second_errors) = parallel.read().await;
|
||||
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial]
|
||||
async fn remote_chunk_reader_hashed_timeout_is_retired_from_adaptive_decode() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT, Some("5"))], async {
|
||||
const SHARD_SIZE: usize = 64;
|
||||
let (checksum, encoded_prefix, encoded_length) = partial_hashed_shard(SHARD_SIZE);
|
||||
let transport = Arc::new(PendingFreshOpenTransport::default());
|
||||
let transport_for_reader: Arc<dyn InternodeDataTransport> = transport.clone();
|
||||
let retry = RetryingRemoteChunkReader::new_with_timeouts(
|
||||
resume_step_chunk_reader(ResumeReadStep::PartialThenReset(encoded_prefix)),
|
||||
transport_for_reader,
|
||||
resume_request(encoded_length),
|
||||
None,
|
||||
Some(Duration::from_secs(1)),
|
||||
);
|
||||
let shard = BitrotReader::new(ShardReader::Chunked(Box::new(retry)), SHARD_SIZE, checksum, false);
|
||||
let mut parallel = ParallelReader::new(vec![Some(shard)], Erasure::new(1, 0, SHARD_SIZE), 0, SHARD_SIZE * 2);
|
||||
|
||||
let (first_buffers, first_errors) = parallel.read().await;
|
||||
assert!(first_buffers.first().and_then(Option::as_ref).is_none());
|
||||
assert!(matches!(first_errors.first().and_then(Option::as_ref), Some(DiskError::Timeout)));
|
||||
assert_eq!(transport.fresh_chunk_drops.load(Ordering::Relaxed), 1);
|
||||
|
||||
let (_, second_errors) = parallel.read().await;
|
||||
assert!(matches!(second_errors.first().and_then(Option::as_ref), Some(DiskError::FileNotFound)));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_reader_resumes_from_emitted_bytes_without_duplicates() {
|
||||
let transport = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::Data(b"456789".to_vec())]));
|
||||
|
||||
@@ -27,7 +27,6 @@ use crate::storage_api_contracts::{
|
||||
};
|
||||
use crate::store::ECStore;
|
||||
use http::HeaderMap;
|
||||
use rustfs_common::heal_channel::{HealOpts, HealScanMode};
|
||||
use rustfs_config::audit::{
|
||||
AUDIT_AMQP_KEYS, AUDIT_AMQP_SUB_SYS, AUDIT_KAFKA_KEYS, AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_KEYS, AUDIT_MQTT_SUB_SYS,
|
||||
AUDIT_MYSQL_KEYS, AUDIT_MYSQL_SUB_SYS, AUDIT_NATS_KEYS, AUDIT_NATS_SUB_SYS, AUDIT_POSTGRES_KEYS, AUDIT_POSTGRES_SUB_SYS,
|
||||
@@ -46,8 +45,10 @@ use rustfs_config::{
|
||||
SCANNER_SUB_SYS,
|
||||
};
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -168,6 +169,7 @@ const EVENT_SERVER_CONFIG_READ_FAILED: &str = "server_config_read_failed";
|
||||
const EVENT_SERVER_CONFIG_HEAL_RESULT: &str = "server_config_heal_result";
|
||||
const EVENT_SERVER_CONFIG_RECOVERED: &str = "server_config_recovered_after_heal";
|
||||
const EVENT_SERVER_CONFIG_FALLBACK: &str = "server_config_corruption_fallback";
|
||||
const EVENT_SERVER_CONFIG_SCALAR_SECTION_IGNORED: &str = "server_config_scalar_section_ignored";
|
||||
|
||||
fn config_corruption_recovery_enabled() -> bool {
|
||||
rustfs_utils::get_env_bool(ENV_CONFIG_RECOVER_ON_CORRUPTION, DEFAULT_CONFIG_RECOVER_ON_CORRUPTION)
|
||||
@@ -218,6 +220,55 @@ fn server_config_decrypt_fn() -> Option<ServerConfigDecryptFn> {
|
||||
SERVER_CONFIG_DECRYPT_FN.read().ok().and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
/// Dedup set for [`warn_ignored_scalar_section`], keyed by (subsystem, value)
|
||||
/// hash. The persisted config is re-read on a short interval (event-notifier
|
||||
/// reconcile runs every 5s), so an undeduplicated warn would flood the log with
|
||||
/// one line per cycle for the same unchanged remnant.
|
||||
static IGNORED_SCALAR_SECTION_WARNED: LazyLock<RwLock<HashSet<u64>>> = LazyLock::new(|| RwLock::new(HashSet::new()));
|
||||
const IGNORED_SCALAR_SECTION_WARNED_CAP: usize = 64;
|
||||
|
||||
fn should_warn_ignored_scalar_section(subsystem_key: &str, config_value: &Value) -> bool {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
subsystem_key.hash(&mut hasher);
|
||||
config_value.to_string().hash(&mut hasher);
|
||||
let digest = hasher.finish();
|
||||
|
||||
let Ok(mut seen) = IGNORED_SCALAR_SECTION_WARNED.write() else {
|
||||
return true;
|
||||
};
|
||||
if seen.contains(&digest) {
|
||||
return false;
|
||||
}
|
||||
if seen.len() >= IGNORED_SCALAR_SECTION_WARNED_CAP {
|
||||
seen.clear();
|
||||
}
|
||||
seen.insert(digest);
|
||||
true
|
||||
}
|
||||
|
||||
fn warn_ignored_scalar_section(subsystem_key: &str, config_value: &Value) {
|
||||
if !should_warn_ignored_scalar_section(subsystem_key, config_value) {
|
||||
return;
|
||||
}
|
||||
let mut rendered = config_value.to_string();
|
||||
if rendered.len() > 128 {
|
||||
let mut cut = 128;
|
||||
while !rendered.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
rendered.truncate(cut);
|
||||
rendered.push('…');
|
||||
}
|
||||
warn!(
|
||||
event = EVENT_SERVER_CONFIG_SCALAR_SECTION_IGNORED,
|
||||
component = LOG_COMPONENT_CONFIG,
|
||||
subsystem = LOG_SUBSYSTEM_CONFIG,
|
||||
config_subsystem = subsystem_key,
|
||||
ignored_value = %rendered,
|
||||
"Ignoring persisted {subsystem_key} config with a legacy scalar shape; it carries no decodable settings and is dropped on the next config save"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn replace_server_config_decrypt_fn_for_test(decrypt_fn: Option<ServerConfigDecryptFn>) -> Option<ServerConfigDecryptFn> {
|
||||
SERVER_CONFIG_DECRYPT_FN
|
||||
@@ -980,10 +1031,20 @@ fn decode_scalar_config_value(config_value: &Value, descriptor: ScalarConfigDesc
|
||||
}
|
||||
Ok(overrides)
|
||||
}
|
||||
_ => Err(Error::other(format!(
|
||||
"invalid external {} config shape: expected an object or KVS array",
|
||||
descriptor.subsystem_key
|
||||
))),
|
||||
// Scalar and null section shapes (`"heal": ""`, `"scanner": null`,
|
||||
// `"heal": {"default": null}`) are legacy remnants that carry no
|
||||
// decodable settings; failing the whole config decode over them bricks
|
||||
// every consumer of the persisted config (startup falls back to the
|
||||
// default config, the admin config API cannot read-modify-write, and
|
||||
// the notify reconciler retries forever). Ignore them with a deduped
|
||||
// warning instead; the next config save scrubs them (see
|
||||
// `normalize_scalar_section_seed`). Malformed values inside object or
|
||||
// KVS-array shapes stay hard errors above — those shapes are where
|
||||
// data (including fields from newer versions) can live.
|
||||
_ => {
|
||||
warn_ignored_scalar_section(descriptor.subsystem_key, config_value);
|
||||
Ok(KVS::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1192,6 +1253,44 @@ fn decode_server_config_blob(data: &[u8]) -> Result<Config> {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// True when a persisted scanner/heal section value is a shape the decoder
|
||||
/// warn-ignores instead of decoding: a bare scalar or null, or an object whose
|
||||
/// nested `default`/`_` member is such a shape. Object and KVS-array shapes
|
||||
/// with decodable structure are never ignorable — they are where data
|
||||
/// (including fields written by newer versions) can live.
|
||||
fn scalar_section_shape_is_ignorable(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Object(config_obj) => config_obj
|
||||
.get("default")
|
||||
.or_else(|| config_obj.get(DEFAULT_DELIMITER))
|
||||
.is_some_and(scalar_section_shape_is_ignorable),
|
||||
Value::Array(_) => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip warn-ignored scalar section shapes (see
|
||||
/// [`scalar_section_shape_is_ignorable`]) from a scanner/heal seed value so the
|
||||
/// canonical rewrite drops the remnant instead of preserving it verbatim, which
|
||||
/// would keep tripping the decoder on every future read.
|
||||
fn normalize_scalar_section_seed(existing: Option<Value>) -> Option<Value> {
|
||||
match existing? {
|
||||
Value::Object(mut config_obj) => {
|
||||
for nested_key in ["default", DEFAULT_DELIMITER] {
|
||||
if config_obj.get(nested_key).is_some_and(scalar_section_shape_is_ignorable) {
|
||||
let nested = config_obj.remove(nested_key);
|
||||
if let Some(normalized) = normalize_scalar_section_seed(nested) {
|
||||
config_obj.insert(nested_key.to_string(), normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
(!config_obj.is_empty()).then_some(Value::Object(config_obj))
|
||||
}
|
||||
value if scalar_section_shape_is_ignorable(&value) => None,
|
||||
value => Some(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_object_seed(data: &[u8]) -> Option<Map<String, Value>> {
|
||||
let value: Value = serde_json::from_slice(data).ok()?;
|
||||
value.as_object().cloned()
|
||||
@@ -1811,10 +1910,7 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8
|
||||
root.remove("storage_class");
|
||||
|
||||
for descriptor in [scanner_config_descriptor(), heal_config_descriptor()] {
|
||||
let mut existing = root.remove(descriptor.subsystem_key);
|
||||
if descriptor.subsystem_key == HEAL_SUB_SYS && existing.as_ref().is_some_and(Value::is_null) {
|
||||
existing = None;
|
||||
}
|
||||
let existing = normalize_scalar_section_seed(root.remove(descriptor.subsystem_key));
|
||||
let rendered = build_scalar_config_object(cfg, descriptor);
|
||||
if let Some(config_value) = sync_rendered_scalar_config_value(existing, &rendered, descriptor)? {
|
||||
root.insert(descriptor.subsystem_key.to_string(), config_value);
|
||||
@@ -1871,7 +1967,8 @@ fn is_standard_object_server_config(data: &[u8]) -> bool {
|
||||
matches!(root.get("version"), Some(Value::String(v)) if !v.trim().is_empty())
|
||||
&& matches!(root.get("storageclass"), Some(Value::Object(_)))
|
||||
&& !root.contains_key("storage_class")
|
||||
&& !matches!(root.get(HEAL_SUB_SYS), Some(Value::Null))
|
||||
&& !matches!(root.get(HEAL_SUB_SYS), Some(value) if scalar_section_shape_is_ignorable(value))
|
||||
&& !matches!(root.get(SCANNER_SUB_SYS), Some(value) if scalar_section_shape_is_ignorable(value))
|
||||
}
|
||||
|
||||
fn configs_semantically_equal(lhs: &Config, rhs: &Config) -> bool {
|
||||
@@ -2701,7 +2798,7 @@ mod tests {
|
||||
lookup_configs, new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata,
|
||||
read_config_preserve_empty, read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot,
|
||||
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
|
||||
server_config_transaction_lock_path, storage_class_kvs_mut,
|
||||
server_config_transaction_lock_path, should_warn_ignored_scalar_section, storage_class_kvs_mut,
|
||||
};
|
||||
use crate::config::{audit, heal, notify, oidc, scanner};
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
@@ -3550,28 +3647,98 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_scalar_and_nested_null_config_shapes_remain_rejected() {
|
||||
fn legacy_scalar_section_shapes_decode_as_no_override_and_canonicalize_on_save() {
|
||||
let base = decode_server_config_blob(br#"{"version":"33","storageclass":{"standard":"","rrs":""}}"#)
|
||||
.expect("base config should decode");
|
||||
|
||||
let ignorable_sections = [
|
||||
(SCANNER_SUB_SYS, r#""scanner":null"#),
|
||||
(SCANNER_SUB_SYS, r#""scanner":"cycle=61""#),
|
||||
(HEAL_SUB_SYS, r#""heal":"""#),
|
||||
(HEAL_SUB_SYS, r#""heal":false"#),
|
||||
(HEAL_SUB_SYS, r#""heal":0"#),
|
||||
(HEAL_SUB_SYS, r#""heal":{"default":null}"#),
|
||||
(HEAL_SUB_SYS, r#""heal":{"_":null}"#),
|
||||
];
|
||||
|
||||
for (section_key, section) in ignorable_sections {
|
||||
let input = format!(r#"{{"version":"33","storageclass":{{"standard":"","rrs":""}},{section}}}"#);
|
||||
let cfg = decode_server_config_blob(input.as_bytes())
|
||||
.unwrap_or_else(|err| panic!("legacy scalar section {section} should be ignored, got: {err}"));
|
||||
assert_eq!(cfg, base, "ignored section {section} must contribute no overrides");
|
||||
assert!(
|
||||
!is_standard_object_server_config(input.as_bytes()),
|
||||
"seed with {section} must not count as standard so a save rewrites it"
|
||||
);
|
||||
|
||||
let encoded =
|
||||
encode_server_config_blob(&cfg, Some(input.as_bytes())).expect("legacy seed should canonicalize on save");
|
||||
let value: Value = serde_json::from_slice(&encoded).expect("canonical config should be valid JSON");
|
||||
assert!(
|
||||
value.get(section_key).is_none(),
|
||||
"canonical save must scrub the {section} remnant, got: {value}"
|
||||
);
|
||||
assert!(is_standard_object_server_config(&encoded));
|
||||
decode_server_config_blob(&encoded).expect("canonicalized config must decode cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_ignorable_nested_member_preserves_unknown_section_fields() {
|
||||
let input = br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":{"_":null,"future_flag":"keep"}}"#;
|
||||
|
||||
let cfg = decode_server_config_blob(input).expect("ignorable nested member should not fail decode");
|
||||
assert!(!is_standard_object_server_config(input));
|
||||
|
||||
let encoded = encode_server_config_blob(&cfg, Some(input)).expect("seed should canonicalize on save");
|
||||
let value: Value = serde_json::from_slice(&encoded).expect("canonical config should be valid JSON");
|
||||
assert!(value[HEAL_SUB_SYS].get(DEFAULT_DELIMITER).is_none(), "null member must be scrubbed");
|
||||
assert_eq!(
|
||||
value[HEAL_SUB_SYS]["future_flag"].as_str(),
|
||||
Some("keep"),
|
||||
"unknown section fields must survive the scrub"
|
||||
);
|
||||
assert!(is_standard_object_server_config(&encoded));
|
||||
decode_server_config_blob(&encoded).expect("canonicalized config must decode cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_level_scalar_config_errors_remain_rejected() {
|
||||
let invalid_sections = [
|
||||
r#""scanner":null"#,
|
||||
r#""heal":"""#,
|
||||
r#""heal":false"#,
|
||||
r#""heal":0"#,
|
||||
r#""heal":{"default":null}"#,
|
||||
r#""heal":{"_":null}"#,
|
||||
r#""heal":{"bitrot_cycle":null}"#,
|
||||
r#""heal":[{"key":"bitrot_cycle","value":null}]"#,
|
||||
];
|
||||
|
||||
for section in invalid_sections {
|
||||
let input = format!(r#"{{"version":"33","storageclass":{{"standard":"","rrs":""}},{section}}}"#);
|
||||
let err = decode_server_config_blob(input.as_bytes()).expect_err("invalid scalar shape must remain rejected");
|
||||
let err = decode_server_config_blob(input.as_bytes())
|
||||
.expect_err("value-level errors inside object/array shapes must remain rejected");
|
||||
assert!(
|
||||
err.to_string().contains("expected"),
|
||||
err.to_string().contains("expected a scalar"),
|
||||
"invalid section {section} returned an unrelated error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_scalar_sections_do_not_defeat_unrecognized_config_detection() {
|
||||
let err = decode_server_config_blob(br#"{"scanner":"","heal":null}"#)
|
||||
.expect_err("a config with only ignorable sections and no recognized header is still unrecognized");
|
||||
assert!(
|
||||
err.to_string().contains("unrecognized external server config shape"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_scalar_section_warning_dedups_by_content() {
|
||||
let value = Value::String("dedup-probe".to_string());
|
||||
assert!(should_warn_ignored_scalar_section("dedup-test-subsystem", &value));
|
||||
assert!(!should_warn_ignored_scalar_section("dedup-test-subsystem", &value));
|
||||
let changed = Value::String("dedup-probe-changed".to_string());
|
||||
assert!(should_warn_ignored_scalar_section("dedup-test-subsystem", &changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_heal_object_and_kvs_array_shapes_remain_accepted() {
|
||||
let empty_object = br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":{}}"#;
|
||||
@@ -4018,18 +4185,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_rejects_scalar_scanner_section() {
|
||||
let err = decode_server_config_blob(br#"{"version":"33","scanner":"cycle=61"}"#)
|
||||
.expect_err("scalar scanner sections should be rejected");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("invalid external scanner config shape: expected an object or KVS array"),
|
||||
"unexpected scanner decode error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_rejects_malformed_known_scanner_kvs_value() {
|
||||
let err = decode_server_config_blob(br#"{"scanner":[{"key":"cycle","value":{"seconds":61}}]}"#)
|
||||
@@ -4710,7 +4865,7 @@ mod tests {
|
||||
decode_persisted_server_config, fallback_server_config_after_corruption, is_server_config_corrupt_error,
|
||||
read_config_without_migrate_with_recovery, replace_server_config_decrypt_fn_for_test,
|
||||
};
|
||||
use rustfs_common::heal_channel::HealOpts;
|
||||
use rustfs_heal_contracts::heal_channel::HealOpts;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Bytes mirroring issue #4156: a bitrot-corrupted `config.json` whose
|
||||
@@ -5065,7 +5220,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn server_config_snapshot_serializes_read_modify_write_transactions() {
|
||||
let baseline = encode_server_config_blob(&Config::new(), None).expect("baseline config should encode");
|
||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline), None));
|
||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline.clone()), None));
|
||||
let first = read_server_config_snapshot(store.clone())
|
||||
.await
|
||||
.expect("first config snapshot");
|
||||
@@ -5079,7 +5234,11 @@ mod tests {
|
||||
.await
|
||||
.expect("second transaction should acquire after the first snapshot is dropped")
|
||||
.expect("second config snapshot");
|
||||
assert!(configs_semantically_equal(&second.config, &Config::new()));
|
||||
// Compare raw bytes against the baseline blob rather than a fresh
|
||||
// Config::new(): the process-global DEFAULT_KVS can be registered by a
|
||||
// sibling test mid-run, which would make a Config::new() evaluated here
|
||||
// diverge from the baseline encoded above.
|
||||
assert_eq!(second.raw.as_deref(), Some(baseline.as_slice()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::bucket::replication::replication_state_from_filemeta;
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
use crate::bucket::utils::is_meta_bucketname;
|
||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
||||
use crate::bucket::{
|
||||
@@ -70,8 +70,8 @@ use http::HeaderMap;
|
||||
#[cfg(test)]
|
||||
use rmp_serde::Deserializer;
|
||||
use rmp_serde::Serializer;
|
||||
use rustfs_common::heal_channel::HealOpts;
|
||||
use rustfs_filemeta::{FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
|
||||
use rustfs_heal_contracts::heal_channel::HealOpts;
|
||||
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
|
||||
use rustfs_utils::path::{encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path};
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration};
|
||||
@@ -8503,7 +8503,7 @@ impl ECStore {
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn decommission_entry_for_test_with_bucket_incarnation(
|
||||
self: &Arc<Self>,
|
||||
idx: usize,
|
||||
@@ -8671,7 +8671,7 @@ impl ECStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn decommission_pool_for_test(
|
||||
self: &Arc<Self>,
|
||||
rx: CancellationToken,
|
||||
@@ -9587,7 +9587,7 @@ impl ECStore {
|
||||
Ok(receipt_paths)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
async fn persist_decommission_durable_ilm_manifest(&self, source_pool_idx: usize) -> Result<()> {
|
||||
let run_token = self.durable_ilm_receipt_run_token(source_pool_idx).await?;
|
||||
self.persist_decommission_durable_ilm_manifest_for_run(source_pool_idx, &run_token)
|
||||
@@ -9705,7 +9705,7 @@ impl ECStore {
|
||||
Ok(receipts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
async fn persist_decommission_durable_ilm_receipt(
|
||||
&self,
|
||||
source_pool_idx: usize,
|
||||
@@ -10309,7 +10309,7 @@ impl ECStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
async fn verify_and_cleanup_decommissioned_durable_ilm_record(
|
||||
&self,
|
||||
source_pool_idx: usize,
|
||||
@@ -10381,7 +10381,7 @@ impl ECStore {
|
||||
resolve_decommission_entry_cleanup_delete_result(cleanup_result, RUSTFS_META_BUCKET, path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn verify_and_cleanup_decommissioned_durable_ilm_record_for_test(
|
||||
&self,
|
||||
source_pool_idx: usize,
|
||||
@@ -10392,12 +10392,12 @@ impl ECStore {
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn decommission_durable_ilm_receipt_count_for_test(&self, source_pool_idx: usize) -> Result<usize> {
|
||||
Ok(self.list_decommission_durable_ilm_receipts(source_pool_idx).await?.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn decommission_durable_ilm_receipt_paths_for_test(
|
||||
&self,
|
||||
source_pool_idx: usize,
|
||||
@@ -10405,7 +10405,7 @@ impl ECStore {
|
||||
self.list_decommission_durable_ilm_receipts(source_pool_idx).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn persist_decommission_durable_ilm_receipt_for_test(
|
||||
&self,
|
||||
source_pool_idx: usize,
|
||||
@@ -10424,12 +10424,12 @@ impl ECStore {
|
||||
Ok(decommission_durable_ilm_receipt_path(&run_token, source_path, record.id_kind, &record.id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn persist_decommission_durable_ilm_manifest_for_test(&self, source_pool_idx: usize) -> Result<()> {
|
||||
self.persist_decommission_durable_ilm_manifest(source_pool_idx).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn cleanup_decommission_durable_ilm_receipts_for_test(&self, source_pool_idx: usize) -> Result<()> {
|
||||
self.cleanup_decommission_durable_ilm_receipts(source_pool_idx).await
|
||||
}
|
||||
@@ -10782,7 +10782,7 @@ impl ECStore {
|
||||
self.ensure_decommission_multipart_uploads_drained(idx, pool.as_ref(), &buckets)
|
||||
.await
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) async fn check_after_decommission_for_test(self: &Arc<Self>, idx: usize) -> Result<()> {
|
||||
let generation = self.active_decommission_generation(idx).await?;
|
||||
self.check_after_decommission(idx, &CancellationToken::new(), generation)
|
||||
|
||||
@@ -46,9 +46,9 @@ use futures::{
|
||||
stream::{FuturesUnordered, StreamExt},
|
||||
};
|
||||
use http::HeaderMap;
|
||||
use rustfs_common::heal_channel::HealOpts;
|
||||
use rustfs_common::heal_channel::{DriveState, HealItemType};
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use rustfs_heal_contracts::heal_channel::HealOpts;
|
||||
use rustfs_heal_contracts::heal_channel::{DriveState, HealItemType};
|
||||
use rustfs_lock::NamespaceLockWrapper;
|
||||
use rustfs_madmin::heal_commands::HealResultItem;
|
||||
use rustfs_utils::{crc_hash, path::path_join_buf, sip_hash};
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::{
|
||||
use crate::data_usage::load_data_usage_cache;
|
||||
use crate::storage_api_contracts::admin::StorageAdminApi;
|
||||
use crate::storage_api_contracts::bucket::BucketOptions;
|
||||
use rustfs_common::heal_channel::DriveState;
|
||||
use rustfs_heal_contracts::heal_channel::DriveState;
|
||||
use rustfs_madmin::{
|
||||
BackendDisks, Disk, ErasureSetInfo, ITEM_INITIALIZING, ITEM_OFFLINE, ITEM_ONLINE, ITEM_UNKNOWN, InfoMessage, MemStats,
|
||||
ServerProperties,
|
||||
|
||||
@@ -23,6 +23,16 @@ pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
const METACACHE_OUTPUT_STREAM_CLOSED: &str = "metacache output stream closed";
|
||||
|
||||
/// Marker carried by a shard-read `io::Error` when the underlying reader can
|
||||
/// no longer be realigned after a fresh remote open failed. The marker is
|
||||
/// deliberately separate from the `ErrorKind`: a terminal read must retire
|
||||
/// its reader, while its original typed disk error and I/O kind still need to
|
||||
/// survive quorum/error mapping.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TerminalReadError {
|
||||
source: DiskError,
|
||||
}
|
||||
|
||||
// DiskError == StorageErr
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DiskError {
|
||||
@@ -168,6 +178,67 @@ pub enum DiskError {
|
||||
RemoteClientUnavailable(String),
|
||||
}
|
||||
|
||||
impl TerminalReadError {
|
||||
pub(crate) fn new(source: DiskError) -> Self {
|
||||
Self { source }
|
||||
}
|
||||
|
||||
fn into_source(self) -> DiskError {
|
||||
self.source
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TerminalReadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.source.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl StdError for TerminalReadError {
|
||||
fn source(&self) -> Option<&(dyn StdError + 'static)> {
|
||||
Some(&self.source)
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_internode_missing_error(error: &InternodeHttpError) -> Option<DiskError> {
|
||||
if error.is_remote_file_not_found() {
|
||||
return Some(DiskError::FileNotFound);
|
||||
}
|
||||
if error.is_remote_volume_not_found() {
|
||||
return Some(DiskError::VolumeNotFound);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Wrap a terminal shard-read failure without changing its typed
|
||||
/// classification. Timeout-like disk errors retain `TimedOut`; other errors
|
||||
/// retain their inner I/O kind or use `Other` when no more specific kind exists.
|
||||
pub(crate) fn terminal_read_error_to_io(error: DiskError) -> io::Error {
|
||||
let kind = match &error {
|
||||
DiskError::Io(inner) => inner.kind(),
|
||||
DiskError::SourceStalled | DiskError::Timeout => io::ErrorKind::TimedOut,
|
||||
DiskError::DiskNotFound
|
||||
| DiskError::FileNotFound
|
||||
| DiskError::FileVersionNotFound
|
||||
| DiskError::PathNotFound
|
||||
| DiskError::VolumeNotFound => io::ErrorKind::NotFound,
|
||||
DiskError::DiskAccessDenied | DiskError::FileAccessDenied | DiskError::VolumeAccessDenied => {
|
||||
io::ErrorKind::PermissionDenied
|
||||
}
|
||||
DiskError::DiskFull => io::ErrorKind::StorageFull,
|
||||
DiskError::FileCorrupt | DiskError::PartMissingOrCorrupt | DiskError::BitrotHashAlgoInvalid => io::ErrorKind::InvalidData,
|
||||
_ => io::ErrorKind::Other,
|
||||
};
|
||||
io::Error::new(kind, TerminalReadError::new(error))
|
||||
}
|
||||
|
||||
/// Whether an I/O error marks a shard reader as terminal for adaptive decode.
|
||||
pub(crate) fn is_terminal_read_error(error: &io::Error) -> bool {
|
||||
error
|
||||
.get_ref()
|
||||
.is_some_and(|source| source.downcast_ref::<TerminalReadError>().is_some())
|
||||
}
|
||||
|
||||
impl From<crate::erasure::coding::ErasureConstructionError> for DiskError {
|
||||
fn from(error: crate::erasure::coding::ErasureConstructionError) -> Self {
|
||||
Self::Io(error.into_io_error())
|
||||
@@ -344,6 +415,21 @@ impl From<std::io::Error> for DiskError {
|
||||
return DiskError::VolumeNotFound;
|
||||
}
|
||||
}
|
||||
let e = match e.downcast::<TerminalReadError>() {
|
||||
Ok(terminal_error) => {
|
||||
let source = terminal_error.into_source();
|
||||
if let DiskError::Io(io_error) = &source
|
||||
&& let Some(internode_error) = io_error
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
|
||||
&& let Some(classified) = classify_internode_missing_error(internode_error)
|
||||
{
|
||||
return classified;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
match e.downcast::<DiskError>() {
|
||||
Ok(disk_error) => disk_error,
|
||||
// Mirror `From<io::Error> for StorageError`: a StorageError boxed
|
||||
@@ -679,6 +765,32 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn terminal_read_error_preserves_kind_and_disk_classification() {
|
||||
let timeout = terminal_read_error_to_io(DiskError::Timeout);
|
||||
assert_eq!(timeout.kind(), io::ErrorKind::TimedOut);
|
||||
assert!(is_terminal_read_error(&timeout));
|
||||
assert!(matches!(DiskError::from(timeout), DiskError::Timeout));
|
||||
|
||||
let missing = terminal_read_error_to_io(DiskError::FileNotFound);
|
||||
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
|
||||
assert!(is_terminal_read_error(&missing));
|
||||
assert!(matches!(DiskError::from(missing), DiskError::FileNotFound));
|
||||
|
||||
let reset = terminal_read_error_to_io(DiskError::Io(io::Error::new(io::ErrorKind::ConnectionReset, "connection reset")));
|
||||
assert_eq!(reset.kind(), io::ErrorKind::ConnectionReset);
|
||||
assert!(is_terminal_read_error(&reset));
|
||||
assert!(matches!(DiskError::from(reset), DiskError::Io(error) if error.kind() == io::ErrorKind::ConnectionReset));
|
||||
|
||||
for (remote_error, expected) in [
|
||||
(rustfs_rio::new_test_remote_file_not_found_http_io_error(), DiskError::FileNotFound),
|
||||
(rustfs_rio::new_test_remote_volume_not_found_http_io_error(), DiskError::VolumeNotFound),
|
||||
] {
|
||||
let wrapped = terminal_read_error_to_io(DiskError::Io(remote_error));
|
||||
assert_eq!(DiskError::from(wrapped), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_preserves_erasure_construction_source_chain() {
|
||||
use crate::erasure::coding::ErasureConstructionError;
|
||||
|
||||
@@ -208,7 +208,7 @@ async fn blackbox_get_restores_body_after_one_shard_file_is_removed() {
|
||||
// Serialized: forces the reader-setup strategy through a process-global env var.
|
||||
#[serial_test::serial]
|
||||
async fn blackbox_heal_requests_preserve_repair_scope() {
|
||||
use rustfs_common::heal_channel::{
|
||||
use rustfs_heal_contracts::heal_channel::{
|
||||
HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest, HealRequestSource,
|
||||
};
|
||||
|
||||
@@ -238,7 +238,7 @@ async fn blackbox_heal_requests_preserve_repair_scope() {
|
||||
// (failing their submitter, which releases their dedup reservation), and
|
||||
// fail fast once the receiver drops at test end. Tests that must observe a
|
||||
// deterministic channel state serialize under the same serial key.
|
||||
let mut heal_rx = rustfs_common::heal_channel::init_heal_channel()
|
||||
let mut heal_rx = rustfs_heal_contracts::heal_channel::init_heal_channel()
|
||||
.expect("this must be the only ecstore test that owns the heal channel receiver");
|
||||
|
||||
// Ordinary PUTs use the same admission channel as read repair. A single
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -228,6 +228,14 @@ pub enum StorageError {
|
||||
/// during quorum aggregation (backlog#1845).
|
||||
#[error("remote rpc client unavailable: {0}")]
|
||||
RemoteClientUnavailable(String),
|
||||
|
||||
/// A peer answered a control-plane RPC but its storage/IAM layer is not
|
||||
/// initialized yet. Typed form of the legacy "errServerNotInitialized"
|
||||
/// error_info string (backlog#1845); the wire carries it as
|
||||
/// `ControlPlaneErrorCode::ControlPlaneErrorNotInitialized` alongside the
|
||||
/// legacy string for rolling-upgrade compatibility.
|
||||
#[error("remote peer not initialized")]
|
||||
RemoteNotInitialized,
|
||||
}
|
||||
|
||||
impl From<crate::erasure::coding::ErasureConstructionError> for StorageError {
|
||||
@@ -581,6 +589,7 @@ impl Clone for StorageError {
|
||||
limit: *limit,
|
||||
},
|
||||
StorageError::RemoteClientUnavailable(detail) => StorageError::RemoteClientUnavailable(detail.clone()),
|
||||
StorageError::RemoteNotInitialized => StorageError::RemoteNotInitialized,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -670,6 +679,7 @@ impl StorageError {
|
||||
StorageError::NamespaceLockQuorumUnavailable { .. } => StorageErrorCode::NamespaceLockQuorumUnavailable,
|
||||
StorageError::QuotaExceeded { .. } => StorageErrorCode::QuotaExceeded,
|
||||
StorageError::RemoteClientUnavailable(_) => StorageErrorCode::RemoteClientUnavailable,
|
||||
StorageError::RemoteNotInitialized => StorageErrorCode::RemoteNotInitialized,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,6 +810,7 @@ impl StorageError {
|
||||
limit: Default::default(),
|
||||
}),
|
||||
StorageErrorCode::RemoteClientUnavailable => Some(StorageError::RemoteClientUnavailable(Default::default())),
|
||||
StorageErrorCode::RemoteNotInitialized => Some(StorageError::RemoteNotInitialized),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -943,7 +954,13 @@ pub fn is_err_operation_canceled(err: &Error) -> bool {
|
||||
|
||||
#[allow(dead_code, reason = "predicate asserted by this file's tests (backlog#1823)")]
|
||||
pub fn is_err_not_initialized(err: &Error) -> bool {
|
||||
err.to_string().contains("errServerNotInitialized") || err.to_string().contains("ServerNotInitialized")
|
||||
// Typed-first: peers at or above the ControlPlaneErrorCode change decode to
|
||||
// the typed variant. The substring form only matches legacy peers' string
|
||||
// responses.
|
||||
// RUSTFS_COMPAT_TODO(not-initialized-error-code-v1): substring fallback for peers that predate the typed wire code. Remove after the minimum supported RustFS peer version always sends error_code.
|
||||
matches!(err, StorageError::RemoteNotInitialized)
|
||||
|| err.to_string().contains("errServerNotInitialized")
|
||||
|| err.to_string().contains("ServerNotInitialized")
|
||||
}
|
||||
|
||||
/// Strict "not found" predicate that only matches genuine object/version/volume
|
||||
|
||||
@@ -346,16 +346,11 @@ impl AsyncRead for DeferredObjectReader {
|
||||
}
|
||||
|
||||
fn disk_error_to_io_error(err: DiskError) -> io::Error {
|
||||
let kind = match err {
|
||||
DiskError::Timeout | DiskError::SourceStalled => io::ErrorKind::TimedOut,
|
||||
DiskError::DiskNotFound | DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::PathNotFound => {
|
||||
io::ErrorKind::NotFound
|
||||
}
|
||||
DiskError::FileCorrupt | DiskError::PartMissingOrCorrupt | DiskError::BitrotHashAlgoInvalid => io::ErrorKind::InvalidData,
|
||||
DiskError::Io(io_err) => return io_err,
|
||||
_ => io::ErrorKind::Other,
|
||||
};
|
||||
io::Error::new(kind, err.to_string())
|
||||
// Keep the typed disk error attached to deferred-reader failures. The
|
||||
// decoder uses the marker to retire a stream that can no longer be
|
||||
// realigned, while quorum reduction still sees Timeout/NotFound instead
|
||||
// of an opaque `DiskError::Io` wrapper.
|
||||
crate::disk::error::terminal_read_error_to_io(err)
|
||||
}
|
||||
|
||||
async fn open_disk_reader(
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use crate::disk::{DiskInfo, error::DiskError};
|
||||
use crate::layout::{endpoints::Endpoints, format::FormatV3};
|
||||
use rustfs_common::heal_channel::DriveState;
|
||||
use rustfs_heal_contracts::heal_channel::DriveState;
|
||||
use rustfs_madmin::heal_commands::HealDriveInfo;
|
||||
|
||||
pub(crate) fn formats_to_drives_info(
|
||||
|
||||
@@ -56,7 +56,6 @@ mod storage_api_contracts;
|
||||
mod store;
|
||||
|
||||
// pub mod checksum;
|
||||
mod client;
|
||||
mod event;
|
||||
|
||||
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
|
||||
/// Check mimalloc arena configuration and log diagnostics
|
||||
pub fn log_mimalloc_diagnostics() {
|
||||
#[cfg(feature = "mimalloc")]
|
||||
{
|
||||
use rustfs_mimalloc::MiMalloc;
|
||||
|
||||
// Check arena_max_object_size
|
||||
let arena_max_obj_size = MiMalloc::option_get_size(
|
||||
rustfs_mimalloc_sys::mi_option_t::mi_option_arena_max_object_size
|
||||
);
|
||||
tracing::info!(
|
||||
arena_max_object_size_bytes = arena_max_obj_size,
|
||||
"mimalloc arena_max_object_size"
|
||||
);
|
||||
|
||||
// Check if pagemap is enabled
|
||||
let pagemap_commit = MiMalloc::option_is_enabled(
|
||||
rustfs_mimalloc_sys::mi_option_t::mi_option_pagemap_commit
|
||||
);
|
||||
tracing::info!(
|
||||
pagemap_commit = pagemap_commit,
|
||||
"mimalloc pagemap_commit"
|
||||
);
|
||||
|
||||
// Log version
|
||||
let version = MiMalloc::version();
|
||||
tracing::info!(
|
||||
mimalloc_version = version,
|
||||
"mimalloc version"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
// #730: object API readers keep staged compatibility paths during facade migration.
|
||||
|
||||
pub mod object_api_utils;
|
||||
|
||||
use crate::bucket::metadata_sys::get_versioning_config;
|
||||
use crate::bucket::replication::{
|
||||
DeleteReplicationConfigSnapshot, ReplicateDecision, ReplicationState, ReplicationStatusType, VersionPurgeStatusType,
|
||||
|
||||
@@ -1407,8 +1407,7 @@ fn multipart_part_numbers(parts: &[ObjectPartInfo]) -> Vec<usize> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64_simd::STANDARD as BASE64_STANDARD;
|
||||
use md5::{Digest, Md5};
|
||||
use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_MD5_HEADER};
|
||||
use std::collections::HashMap;
|
||||
@@ -1468,7 +1467,7 @@ mod tests {
|
||||
request: ReadEncryptionRequest<'_>,
|
||||
) -> std::result::Result<Option<ReadEncryptionMaterial>, EncryptionResolutionError> {
|
||||
if let Some(encoded) = request.metadata.get(TEST_OBJECT_KEY_HEADER) {
|
||||
let decoded = BASE64_STANDARD.decode(encoded).map_err(|_| {
|
||||
let decoded = BASE64_STANDARD.decode_to_vec(encoded).map_err(|_| {
|
||||
EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidMetadata, "invalid test object key")
|
||||
})?;
|
||||
let key_bytes = decoded.try_into().map_err(|_| {
|
||||
@@ -1493,7 +1492,7 @@ mod tests {
|
||||
.map_err(|_| {
|
||||
EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidRequest, "invalid test encryption key")
|
||||
})?;
|
||||
let decoded = BASE64_STANDARD.decode(encoded).map_err(|_| {
|
||||
let decoded = BASE64_STANDARD.decode_to_vec(encoded).map_err(|_| {
|
||||
EncryptionResolutionError::new(EncryptionResolutionErrorKind::InvalidRequest, "invalid test encryption key")
|
||||
})?;
|
||||
let key_bytes = decoded.try_into().map_err(|_| {
|
||||
@@ -1505,7 +1504,7 @@ mod tests {
|
||||
let base_nonce = request
|
||||
.metadata
|
||||
.get(TEST_NONCE_HEADER)
|
||||
.and_then(|encoded| BASE64_STANDARD.decode(encoded).ok())
|
||||
.and_then(|encoded| BASE64_STANDARD.decode_to_vec(encoded).ok())
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.unwrap_or_else(|| fixture_nonce(request.bucket, request.object));
|
||||
Ok(Some(ReadEncryptionMaterial {
|
||||
@@ -1533,7 +1532,7 @@ mod tests {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
TEST_DIRECT_KEY_HEADER,
|
||||
HeaderValue::from_str(&BASE64_STANDARD.encode(key_bytes)).expect("test key header is valid"),
|
||||
HeaderValue::from_str(&BASE64_STANDARD.encode_to_string(key_bytes)).expect("test key header is valid"),
|
||||
);
|
||||
headers
|
||||
}
|
||||
@@ -2439,7 +2438,10 @@ mod tests {
|
||||
user_defined: Arc::new(HashMap::from([
|
||||
("X-Amz-Server-Side-Encryption".to_string(), "aws:kms".to_string()),
|
||||
("X-Amz-Server-Side-Encryption-Iv".to_string(), "AAAAAAAAAAAAAAAA".to_string()),
|
||||
("X-Amz-Server-Side-Encryption-Key".to_string(), BASE64_STANDARD.encode([7_u8; 32])),
|
||||
(
|
||||
"X-Amz-Server-Side-Encryption-Key".to_string(),
|
||||
BASE64_STANDARD.encode_to_string([7_u8; 32]),
|
||||
),
|
||||
("x-rustfs-encryption-original-size".to_string(), "64".to_string()),
|
||||
])),
|
||||
..Default::default()
|
||||
@@ -2748,7 +2750,7 @@ mod tests {
|
||||
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
||||
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
|
||||
BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)),
|
||||
),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-original-size".to_string(),
|
||||
@@ -2796,11 +2798,11 @@ mod tests {
|
||||
name: object.to_string(),
|
||||
size: encrypted.len() as i64,
|
||||
user_defined: Arc::new(HashMap::from([
|
||||
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)),
|
||||
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)),
|
||||
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
||||
BASE64_STANDARD.encode(md5_bytes(customer_key)),
|
||||
BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)),
|
||||
),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-original-size".to_string(),
|
||||
@@ -2856,7 +2858,7 @@ mod tests {
|
||||
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
||||
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
|
||||
BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)),
|
||||
),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-original-size".to_string(),
|
||||
@@ -3008,13 +3010,13 @@ mod tests {
|
||||
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
||||
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
|
||||
BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)),
|
||||
),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-original-size".to_string(),
|
||||
total_plaintext.to_string(),
|
||||
),
|
||||
(TEST_NONCE_HEADER.to_string(), BASE64_STANDARD.encode(LEGACY_FIXTURE_BASE_NONCE)),
|
||||
(TEST_NONCE_HEADER.to_string(), BASE64_STANDARD.encode_to_string(LEGACY_FIXTURE_BASE_NONCE)),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -3751,11 +3753,11 @@ mod tests {
|
||||
name: object.to_string(),
|
||||
size: encrypted.len() as i64,
|
||||
user_defined: Arc::new(HashMap::from([
|
||||
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)),
|
||||
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)),
|
||||
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
||||
BASE64_STANDARD.encode(md5_bytes(customer_key)),
|
||||
BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)),
|
||||
),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-original-size".to_string(),
|
||||
@@ -3821,7 +3823,7 @@ mod tests {
|
||||
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
||||
BASE64_STANDARD.encode(md5_bytes(key_bytes)),
|
||||
BASE64_STANDARD.encode_to_string(md5_bytes(key_bytes)),
|
||||
),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-original-size".to_string(),
|
||||
@@ -3964,11 +3966,11 @@ mod tests {
|
||||
..Default::default()
|
||||
}]),
|
||||
user_defined: Arc::new(HashMap::from([
|
||||
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode(object_key)),
|
||||
(TEST_OBJECT_KEY_HEADER.to_string(), BASE64_STANDARD.encode_to_string(object_key)),
|
||||
("x-amz-server-side-encryption-customer-algorithm".to_string(), "AES256".to_string()),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-key-md5".to_string(),
|
||||
BASE64_STANDARD.encode(md5_bytes(customer_key)),
|
||||
BASE64_STANDARD.encode_to_string(md5_bytes(customer_key)),
|
||||
),
|
||||
(
|
||||
"x-amz-server-side-encryption-customer-original-size".to_string(),
|
||||
|
||||
@@ -281,7 +281,7 @@ pub struct QuotaAdmission {
|
||||
pub struct LifecycleDeleteAllRequest {
|
||||
pub(crate) version_id: Option<Uuid>,
|
||||
pub(crate) delete_marker: bool,
|
||||
pub(crate) action: rustfs_common::metrics::IlmAction,
|
||||
pub(crate) action: rustfs_scanner_contracts::metrics::IlmAction,
|
||||
pub(crate) rule_id: String,
|
||||
pub(crate) phase: LifecycleDeleteAllPhase,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::storage_api_contracts::admin::StorageAdminApi;
|
||||
#[cfg(test)]
|
||||
use chrono::Utc;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_common::{heal_channel::DriveState, metrics::global_metrics};
|
||||
use rustfs_heal_contracts::heal_channel::DriveState;
|
||||
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
||||
use rustfs_madmin::metrics::{
|
||||
DiskIOStats, DiskMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, RealtimeMetrics,
|
||||
@@ -32,6 +32,7 @@ use rustfs_madmin::metrics::{
|
||||
ScannerSourceCycleSnapshot as MadminScannerSourceCycleSnapshot, ScannerSourceWorkSnapshot as MadminScannerSourceWorkSnapshot,
|
||||
ScannerUsageFreshnessSnapshot as MadminScannerUsageFreshnessSnapshot, TimedAction as MadminTimedAction,
|
||||
};
|
||||
use rustfs_scanner_contracts::metrics::global_metrics;
|
||||
use rustfs_utils::os::get_drive_stats;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -81,7 +82,7 @@ fn unix_millis_to_jiff_timestamp(millis: u64, fallback: Timestamp) -> Timestamp
|
||||
}
|
||||
}
|
||||
|
||||
fn to_madmin_scanner_metrics(metrics: rustfs_common::metrics::ScannerMetricsReport) -> MadminScannerMetrics {
|
||||
fn to_madmin_scanner_metrics(metrics: rustfs_scanner_contracts::metrics::ScannerMetricsReport) -> MadminScannerMetrics {
|
||||
MadminScannerMetrics {
|
||||
collected_at: metrics.collected_at,
|
||||
current_cycle: metrics.current_cycle,
|
||||
@@ -563,8 +564,8 @@ async fn collect_local_disks_metrics(disks: &HashSet<String>) -> HashMap<String,
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use rustfs_common::metrics::CurrentCycle;
|
||||
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
||||
use rustfs_scanner_contracts::metrics::CurrentCycle;
|
||||
use serial_test::serial;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -618,7 +619,7 @@ mod test {
|
||||
#[test]
|
||||
fn scanner_metrics_mapping_preserves_partial_source_status() {
|
||||
let current_started = Utc::now() - chrono::Duration::seconds(5);
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
|
||||
current_cycle_active: true,
|
||||
current_started: chrono_to_jiff_timestamp(current_started),
|
||||
last_cycle_partial_source: "usage".to_string(),
|
||||
@@ -627,7 +628,7 @@ mod test {
|
||||
cycle_recovery_required_total: 2,
|
||||
cycle_last_progress_age: 17,
|
||||
leader_lease_without_progress: true,
|
||||
partial_cycles_by_source: vec![rustfs_common::metrics::ScannerSourceCycleSnapshot {
|
||||
partial_cycles_by_source: vec![rustfs_scanner_contracts::metrics::ScannerSourceCycleSnapshot {
|
||||
source: "usage".to_string(),
|
||||
cycles: 2,
|
||||
}],
|
||||
@@ -653,11 +654,11 @@ mod test {
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn collect_local_metrics_preserves_scanner_cycle_started_time() {
|
||||
let previous_init_time = *rustfs_common::globals::GLOBAL_INIT_TIME.read().await;
|
||||
let previous_init_time = *rustfs_scanner_contracts::GLOBAL_INIT_TIME.read().await;
|
||||
let previous_cycle = global_metrics().get_cycle().await;
|
||||
let init_time = Utc::now() - chrono::Duration::hours(1);
|
||||
let cycle_started = Utc::now() - chrono::Duration::seconds(5);
|
||||
*rustfs_common::globals::GLOBAL_INIT_TIME.write().await = Some(init_time);
|
||||
*rustfs_scanner_contracts::GLOBAL_INIT_TIME.write().await = Some(init_time);
|
||||
let cycle = CurrentCycle {
|
||||
current: 0,
|
||||
next: 1,
|
||||
@@ -672,7 +673,7 @@ mod test {
|
||||
.finish_scan_cycle_work_with_cycle(cycle_start, previous_cycle.clone().unwrap_or_default())
|
||||
.await;
|
||||
global_metrics().set_cycle(previous_cycle).await;
|
||||
*rustfs_common::globals::GLOBAL_INIT_TIME.write().await = previous_init_time;
|
||||
*rustfs_scanner_contracts::GLOBAL_INIT_TIME.write().await = previous_init_time;
|
||||
|
||||
let encoded = rmp_serde::to_vec_named(&realtime).expect("realtime metrics should encode");
|
||||
let decoded: RealtimeMetrics = rmp_serde::from_slice(&encoded).expect("realtime metrics should decode");
|
||||
@@ -685,8 +686,8 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn scanner_metrics_mapping_preserves_pacing_pressure() {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
|
||||
pacing_pressure: rustfs_common::metrics::ScannerPacingPressureSnapshot {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
|
||||
pacing_pressure: rustfs_scanner_contracts::metrics::ScannerPacingPressureSnapshot {
|
||||
primary_pressure: "cycle_budget".to_string(),
|
||||
current_queued_scans: 4,
|
||||
current_active_scans: 2,
|
||||
@@ -711,12 +712,12 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn scanner_metrics_mapping_preserves_lifecycle_transition_status() {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
|
||||
current_cycle_lifecycle_expiry_actions: 2,
|
||||
current_cycle_lifecycle_transition_actions: 3,
|
||||
last_cycle_lifecycle_expiry_actions: 5,
|
||||
last_cycle_lifecycle_transition_actions: 7,
|
||||
lifecycle_expiry: rustfs_common::metrics::ScannerLifecycleExpirySnapshot {
|
||||
lifecycle_expiry: rustfs_scanner_contracts::metrics::ScannerLifecycleExpirySnapshot {
|
||||
current_queue_capacity: 16,
|
||||
current_queued: 5,
|
||||
current_active: 2,
|
||||
@@ -728,7 +729,7 @@ mod test {
|
||||
scanner_not_enqueued: 2,
|
||||
delete_failed: 1,
|
||||
},
|
||||
lifecycle_transition: rustfs_common::metrics::ScannerLifecycleTransitionSnapshot {
|
||||
lifecycle_transition: rustfs_scanner_contracts::metrics::ScannerLifecycleTransitionSnapshot {
|
||||
current_queue_capacity: 16,
|
||||
current_queued: 5,
|
||||
current_active: 2,
|
||||
@@ -777,10 +778,10 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn scanner_metrics_mapping_preserves_maintenance_control_status() {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
|
||||
maintenance_control: rustfs_common::metrics::ScannerMaintenanceControlSnapshot {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
|
||||
maintenance_control: rustfs_scanner_contracts::metrics::ScannerMaintenanceControlSnapshot {
|
||||
primary_control: "blocked_source".to_string(),
|
||||
sources: vec![rustfs_common::metrics::ScannerMaintenanceSourceSnapshot {
|
||||
sources: vec![rustfs_scanner_contracts::metrics::ScannerMaintenanceSourceSnapshot {
|
||||
source: "lifecycle".to_string(),
|
||||
state: "blocked".to_string(),
|
||||
reason: "missed_work".to_string(),
|
||||
@@ -814,8 +815,8 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn scanner_metrics_mapping_preserves_usage_freshness_status() {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
|
||||
usage_freshness: rustfs_common::metrics::ScannerUsageFreshnessSnapshot {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
|
||||
usage_freshness: rustfs_scanner_contracts::metrics::ScannerUsageFreshnessSnapshot {
|
||||
dirty_pending_buckets: 3,
|
||||
last_dirty_mark_unix_secs: 10,
|
||||
last_dirty_clear_unix_secs: 11,
|
||||
@@ -856,7 +857,7 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn scanner_metrics_mapping_preserves_distributed_status_fields() {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_scanner_contracts::metrics::ScannerMetricsReport {
|
||||
active_scan_paths: 2,
|
||||
oldest_active_path_age_seconds: 45,
|
||||
active_paths: vec!["disk-a/bucket-a".to_string(), "disk-b/bucket-b".to_string()],
|
||||
@@ -912,7 +913,7 @@ mod test {
|
||||
cycle_max_directories: 38,
|
||||
bitrot_cycle_enabled: true,
|
||||
bitrot_cycle_seconds: 39.0,
|
||||
scan_checkpoint: Some(rustfs_common::metrics::ScannerCheckpointReport {
|
||||
scan_checkpoint: Some(rustfs_scanner_contracts::metrics::ScannerCheckpointReport {
|
||||
version: 1,
|
||||
resume_after: "bucket-a/prefix-a".to_string(),
|
||||
reason: "directories".to_string(),
|
||||
@@ -922,7 +923,7 @@ mod test {
|
||||
scan_checkpoint_cleared: 41,
|
||||
scan_checkpoint_ignored: 42,
|
||||
scan_checkpoint_stale: 43,
|
||||
source_work: vec![rustfs_common::metrics::ScannerSourceWorkSnapshot {
|
||||
source_work: vec![rustfs_scanner_contracts::metrics::ScannerSourceWorkSnapshot {
|
||||
source: "usage".to_string(),
|
||||
checked: 44,
|
||||
queued: 45,
|
||||
@@ -931,7 +932,7 @@ mod test {
|
||||
skipped: 48,
|
||||
missed: 49,
|
||||
}],
|
||||
current_cycle_source_work: vec![rustfs_common::metrics::ScannerSourceWorkSnapshot {
|
||||
current_cycle_source_work: vec![rustfs_scanner_contracts::metrics::ScannerSourceWorkSnapshot {
|
||||
source: "lifecycle".to_string(),
|
||||
checked: 50,
|
||||
queued: 51,
|
||||
@@ -940,7 +941,7 @@ mod test {
|
||||
skipped: 54,
|
||||
missed: 55,
|
||||
}],
|
||||
last_cycle_source_work: vec![rustfs_common::metrics::ScannerSourceWorkSnapshot {
|
||||
last_cycle_source_work: vec![rustfs_scanner_contracts::metrics::ScannerSourceWorkSnapshot {
|
||||
source: "heal".to_string(),
|
||||
checked: 56,
|
||||
queued: 57,
|
||||
@@ -949,7 +950,7 @@ mod test {
|
||||
skipped: 60,
|
||||
missed: 61,
|
||||
}],
|
||||
replication_repair: vec![rustfs_common::metrics::ScannerReplicationRepairSnapshot {
|
||||
replication_repair: vec![rustfs_scanner_contracts::metrics::ScannerReplicationRepairSnapshot {
|
||||
source: "bucket_replication".to_string(),
|
||||
kind: "object".to_string(),
|
||||
scanner_role: "repair_admission".to_string(),
|
||||
@@ -961,7 +962,7 @@ mod test {
|
||||
skipped: 66,
|
||||
missed: 67,
|
||||
}],
|
||||
current_cycle_replication_repair: vec![rustfs_common::metrics::ScannerReplicationRepairSnapshot {
|
||||
current_cycle_replication_repair: vec![rustfs_scanner_contracts::metrics::ScannerReplicationRepairSnapshot {
|
||||
source: "bucket_replication".to_string(),
|
||||
kind: "delete_marker".to_string(),
|
||||
scanner_role: "repair_admission".to_string(),
|
||||
@@ -973,7 +974,7 @@ mod test {
|
||||
skipped: 72,
|
||||
missed: 73,
|
||||
}],
|
||||
last_cycle_replication_repair: vec![rustfs_common::metrics::ScannerReplicationRepairSnapshot {
|
||||
last_cycle_replication_repair: vec![rustfs_scanner_contracts::metrics::ScannerReplicationRepairSnapshot {
|
||||
source: "site_replication".to_string(),
|
||||
kind: "active_resync".to_string(),
|
||||
scanner_role: "boundary_signal".to_string(),
|
||||
|
||||
@@ -2089,7 +2089,7 @@ async fn peer_disk_health(host: &str) -> Option<PeerDiskHealth> {
|
||||
disks.push(rustfs_madmin::Disk {
|
||||
endpoint: ep.to_string(),
|
||||
state: if online {
|
||||
rustfs_common::heal_channel::DriveState::Ok.to_string()
|
||||
rustfs_heal_contracts::heal_channel::DriveState::Ok.to_string()
|
||||
} else {
|
||||
ItemState::Offline.to_string().to_owned()
|
||||
},
|
||||
|
||||
@@ -68,7 +68,6 @@ use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{Mutex, Notify, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::transition_api::{ReadCloser, ReaderImpl};
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::disk::format::FormatV3;
|
||||
use crate::disk::{DiskAPI, DiskOption, FORMAT_CONFIG_FILE, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE, new_disk};
|
||||
@@ -78,6 +77,7 @@ use crate::services::tier::warm_backend::{
|
||||
TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options,
|
||||
};
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl};
|
||||
use rustfs_utils::path::path_join_buf;
|
||||
|
||||
/// One-shot barrier before rejected transition cleanup resolves its ECStore.
|
||||
|
||||
@@ -47,7 +47,6 @@ use tokio::{
|
||||
};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities};
|
||||
use crate::error::{Error, Result, StorageError};
|
||||
use crate::services::tier::{
|
||||
tier_admin::TierCreds,
|
||||
@@ -80,6 +79,7 @@ use crate::{
|
||||
};
|
||||
use rustfs_filemeta::FileInfo;
|
||||
use rustfs_rio::HashReader;
|
||||
use rustfs_s3_client::{admin_handler_utils::AdminError, provider_versions::ProviderVersionCapabilities};
|
||||
use rustfs_utils::path::{SLASH_SEPARATOR, path_join};
|
||||
use s3s::S3ErrorCode;
|
||||
|
||||
@@ -1529,14 +1529,14 @@ impl WarmBackend for SharedWarmBackendProxy {
|
||||
self.0.validate_remote_version_id(remote_version_id)
|
||||
}
|
||||
|
||||
async fn put(&self, object: &str, r: crate::client::transition_api::ReaderImpl, length: i64) -> io::Result<String> {
|
||||
async fn put(&self, object: &str, r: rustfs_s3_client::transition_api::ReaderImpl, length: i64) -> io::Result<String> {
|
||||
self.0.put(object, r, length).await
|
||||
}
|
||||
|
||||
async fn put_with_meta(
|
||||
&self,
|
||||
object: &str,
|
||||
r: crate::client::transition_api::ReaderImpl,
|
||||
r: rustfs_s3_client::transition_api::ReaderImpl,
|
||||
length: i64,
|
||||
meta: HashMap<String, String>,
|
||||
) -> io::Result<String> {
|
||||
@@ -1548,7 +1548,7 @@ impl WarmBackend for SharedWarmBackendProxy {
|
||||
object: &str,
|
||||
rv: &str,
|
||||
opts: crate::services::tier::warm_backend::WarmBackendGetOpts,
|
||||
) -> io::Result<crate::client::transition_api::ReadCloser> {
|
||||
) -> io::Result<rustfs_s3_client::transition_api::ReadCloser> {
|
||||
self.0.get(object, rv, opts).await
|
||||
}
|
||||
|
||||
@@ -5778,8 +5778,8 @@ mod tests {
|
||||
// lets us drive `remove`/`verify` through every branch.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
use crate::client::transition_api::{ReadCloser, ReaderImpl};
|
||||
use crate::services::tier::warm_backend::{WarmBackend, WarmBackendGetOpts};
|
||||
use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl};
|
||||
|
||||
fn empty_mgr() -> TierConfigMgr {
|
||||
TierConfigMgr {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::client::admin_handler_utils::AdminError;
|
||||
use http::status::StatusCode;
|
||||
use lazy_static::lazy_static;
|
||||
use rustfs_s3_client::admin_handler_utils::AdminError;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref ERR_TIER_ALREADY_EXISTS: AdminError = AdminError {
|
||||
|
||||
@@ -22,9 +22,9 @@ use super::tier_mutation_intent::{
|
||||
MAX_TIER_MUTATION_INTENT_SIZE, TierMutationIntent, TierMutationIntentState, advance_tier_mutation_intent_record_idempotent,
|
||||
load_tier_mutation_intent_record, save_tier_mutation_intent_record_if_absent,
|
||||
};
|
||||
use crate::client::admin_handler_utils::AdminError;
|
||||
use crate::error::{Error, StorageError};
|
||||
use crate::store::ECStore;
|
||||
use rustfs_s3_client::admin_handler_utils::AdminError;
|
||||
|
||||
pub const MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE: usize = rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE;
|
||||
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
#![allow(unused_must_use)]
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use crate::client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::{AdvancedPutOptions, PutObjectOptions},
|
||||
transition_api::{ReadCloser, ReaderImpl},
|
||||
};
|
||||
use crate::error::is_err_bucket_not_found;
|
||||
use crate::services::tier::{
|
||||
tier::{ERR_TIER_INVALID_CONFIG, ERR_TIER_TYPE_UNSUPPORTED},
|
||||
@@ -41,6 +36,11 @@ use crate::services::tier::{
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use http::StatusCode;
|
||||
use rustfs_s3_client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::{AdvancedPutOptions, PutObjectOptions},
|
||||
transition_api::{ReadCloser, ReaderImpl},
|
||||
};
|
||||
use rustfs_utils::http::headers::{
|
||||
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
|
||||
};
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use crate::services::tier::{
|
||||
tier_config::TierAliyun,
|
||||
warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options},
|
||||
warm_backend_s3::WarmBackendS3,
|
||||
};
|
||||
use rustfs_s3_client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use crate::services::tier::{
|
||||
tier_config::TierAzure,
|
||||
warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options},
|
||||
warm_backend_s3::WarmBackendS3,
|
||||
};
|
||||
use rustfs_s3_client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
|
||||
|
||||
@@ -30,15 +30,15 @@ use google_cloud_storage::client::Storage;
|
||||
use google_cloud_storage::client::StorageControl;
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use crate::client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
transition_api::{Options, ReadCloser, ReaderImpl},
|
||||
};
|
||||
use crate::services::tier::{
|
||||
tier_config::TierGCS,
|
||||
warm_backend::{WarmBackend, WarmBackendGetOpts},
|
||||
};
|
||||
use rustfs_s3_client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
transition_api::{Options, ReadCloser, ReaderImpl},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
const _MAX_PART_SIZE: i64 = 1024 * 1024 * 1024 * 5;
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use crate::services::tier::{
|
||||
tier_config::TierHuaweicloud,
|
||||
warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options},
|
||||
warm_backend_s3::WarmBackendS3,
|
||||
};
|
||||
use rustfs_s3_client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use crate::services::tier::{
|
||||
tier_config::TierMinIO,
|
||||
warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options},
|
||||
warm_backend_s3::WarmBackendS3,
|
||||
};
|
||||
use rustfs_s3_client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use crate::services::tier::{
|
||||
tier_config::TierR2,
|
||||
warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options},
|
||||
warm_backend_s3::WarmBackendS3,
|
||||
};
|
||||
use rustfs_s3_client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use crate::services::tier::{
|
||||
tier_config::TierRustFS,
|
||||
warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options},
|
||||
warm_backend_s3::WarmBackendS3,
|
||||
};
|
||||
use rustfs_s3_client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
|
||||
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
|
||||
const MAX_PARTS_COUNT: i64 = 10000;
|
||||
|
||||
@@ -22,7 +22,15 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
use crate::client::{
|
||||
use crate::services::tier::{
|
||||
tier_config::TierS3,
|
||||
warm_backend::{
|
||||
TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts,
|
||||
build_transition_put_options,
|
||||
},
|
||||
};
|
||||
use http::HeaderMap;
|
||||
use rustfs_s3_client::{
|
||||
api_get_options::GetObjectOptions,
|
||||
api_list::ListObjectsOptions,
|
||||
api_put_object::PutObjectOptions,
|
||||
@@ -33,14 +41,6 @@ use crate::client::{
|
||||
transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore},
|
||||
transition_api::{ReadCloser, ReaderImpl},
|
||||
};
|
||||
use crate::services::tier::{
|
||||
tier_config::TierS3,
|
||||
warm_backend::{
|
||||
TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts,
|
||||
build_transition_put_options,
|
||||
},
|
||||
};
|
||||
use http::HeaderMap;
|
||||
use rustfs_utils::egress::validate_outbound_url;
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use s3s::dto::BucketVersioningStatus;
|
||||
@@ -379,7 +379,7 @@ impl TransitionCandidateVersions {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::api_s3_datatypes::{ListVersionsResult, Version};
|
||||
use rustfs_s3_client::api_s3_datatypes::{ListVersionsResult, Version};
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_rejects_loopback_endpoint_before_network_setup() {
|
||||
|
||||
@@ -21,17 +21,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use crate::services::tier::{
|
||||
tier_config::TierTencent,
|
||||
warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options},
|
||||
warm_backend_s3::WarmBackendS3,
|
||||
};
|
||||
use rustfs_s3_client::{
|
||||
admin_handler_utils::AdminError,
|
||||
api_put_object::PutObjectOptions,
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{BucketLookupType, Options, ReadCloser, ReaderImpl, TransitionClient, TransitionCore},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
const MAX_MULTIPART_PUT_OBJECT_SIZE: i64 = 1024 * 1024 * 1024 * 1024 * 5;
|
||||
|
||||
@@ -21,14 +21,12 @@ use std::{
|
||||
use s3s::header::{X_AMZ_DELETE_MARKER, X_AMZ_VERSION_ID};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
client::transition_api::{BucketLookupType, ReadCloser, ReaderImpl},
|
||||
services::tier::{
|
||||
tier_config::{TierS3, TierWasabi},
|
||||
warm_backend::{WarmBackend, WarmBackendGetOpts},
|
||||
warm_backend_s3::WarmBackendS3,
|
||||
},
|
||||
use crate::services::tier::{
|
||||
tier_config::{TierS3, TierWasabi},
|
||||
warm_backend::{WarmBackend, WarmBackendGetOpts},
|
||||
warm_backend_s3::WarmBackendS3,
|
||||
};
|
||||
use rustfs_s3_client::transition_api::{BucketLookupType, ReadCloser, ReaderImpl};
|
||||
|
||||
const WASABI_VERSIONING_DRIFT_ERROR: &str = "Wasabi tier bucket versioning changed after configuration";
|
||||
|
||||
@@ -184,7 +182,7 @@ impl WarmBackend for WarmBackendWasabi {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client::{
|
||||
use rustfs_s3_client::{
|
||||
credentials::{Credentials, SignatureType, Static, Value},
|
||||
transition_api::{Options, TransitionCore},
|
||||
};
|
||||
@@ -221,7 +219,7 @@ mod tests {
|
||||
|
||||
async fn backend_for_endpoint(endpoint: &str, max_retries: i64) -> WarmBackendWasabi {
|
||||
let client = Arc::new(
|
||||
crate::client::transition_api::TransitionClient::new(
|
||||
rustfs_s3_client::transition_api::TransitionClient::new(
|
||||
endpoint,
|
||||
Options {
|
||||
creds: Credentials::new(Static(Value {
|
||||
@@ -653,7 +651,7 @@ mod tests {
|
||||
assert_ne!(read, 0, "connection closed before request headers were received");
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
let body = vec![b'x'; crate::client::transition_api::MAX_S3_ERROR_RESPONSE_SIZE + 1];
|
||||
let body = vec![b'x'; rustfs_s3_client::transition_api::MAX_S3_ERROR_RESPONSE_SIZE + 1];
|
||||
let head = format!(
|
||||
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
|
||||
@@ -22,20 +22,47 @@
|
||||
//! byte-identical to the pre-move sources; only the module header
|
||||
//! (`use super::*;` -> `use super::super::*;`) and item visibility change.
|
||||
|
||||
use super::super::*;
|
||||
#[cfg(test)]
|
||||
use super::super::ENV_RUSTFS_GET_METADATA_DATA_READ_EARLY_STOP_ENABLE;
|
||||
#[cfg(test)]
|
||||
use super::super::ENV_RUSTFS_GET_METADATA_EARLY_STOP_BOUNDED_FANOUT;
|
||||
#[cfg(test)]
|
||||
use super::super::ENV_RUSTFS_GET_METADATA_EARLY_STOP_ENABLE;
|
||||
#[cfg(test)]
|
||||
use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_BUCKET;
|
||||
#[cfg(test)]
|
||||
use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DELAY_MS;
|
||||
#[cfg(test)]
|
||||
use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_DISKS;
|
||||
#[cfg(test)]
|
||||
use super::super::ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_OBJECT_PREFIX;
|
||||
#[cfg(test)]
|
||||
use super::super::get_metadata_slowtail_fault_delay;
|
||||
use super::super::{
|
||||
Bytes, CHECK_PART_DISK_NOT_FOUND, DeleteOptions, DiskError, DiskStore, EVENT_SET_DISK_RENAME_TAIL_DRAIN_FAILED,
|
||||
EVENT_SET_DISK_WRITE, Error, FileInfo, FileMeta, FileMetaShallowVersion, GetCodecStreamingFallbackReason,
|
||||
GetObjectMetadataCacheEntry, HTTPPreconditions, HashAlgorithm, HealAdmissionResult, HealChannelPriority, HealRequestSource,
|
||||
LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_SET_DISK, MultipartWriteQuorumContext, OBJECT_OP_IGNORED_ERRS, ObjectOptions,
|
||||
ObjectPartInfo, OffsetDateTime, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RawFileInfo, ReadMultipleReq,
|
||||
ReadMultipleResp, ReadOptions, Result, SLASH_SEPARATOR, STORAGE_FORMAT_FILE, SetDisks, SnapshotLeaseToken, StorageError,
|
||||
UpdateMetadataOpts, Uuid, build_inline_bitrot_readers_from_refs, can_try_inline_data_shards_direct,
|
||||
capacity_scope_from_disks, coding, collect_inline_data_shard_fileinfos_by_index_or_reason, current_dirty_generation, debug,
|
||||
disk, file_info_is_valid_for_metadata, get_metadata_slowtail_fault_request, info, inline_erasure_shard_file_offset,
|
||||
inline_erasure_shard_size, is_err_object_not_found, is_err_version_not_found, is_get_metadata_data_read_early_stop_enabled,
|
||||
is_get_metadata_early_stop_bounded_fanout_enabled, is_get_metadata_early_stop_enabled, is_object_dangling,
|
||||
is_version_early_stop_enabled, issue3031_diag_enabled, join_all, join_errs, log_multipart_write_quorum_failure,
|
||||
merge_file_meta_versions, path_join_buf, record_global_dirty_scope, reduce_read_quorum_errs, reduce_write_quorum_errs,
|
||||
send_heal_request_with_admission, should_prevent_write, to_object_err, try_read_inline_data_shards_direct, warn,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
||||
#[cfg(test)]
|
||||
use crate::diagnostics::get::GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH;
|
||||
#[cfg(test)]
|
||||
use crate::diagnostics::get::GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD;
|
||||
use crate::diagnostics::get::{
|
||||
GET_DIRECT_MEMORY_SUBPATH_DISK_DATA_BLOCKS, GET_DIRECT_MEMORY_SUBPATH_INLINE_BUFFERED, GET_METADATA_CACHE_DECISION_HIT,
|
||||
GET_METADATA_CACHE_DECISION_MISS, GET_METADATA_CACHE_DECISION_REJECT, GET_METADATA_CACHE_DECISION_SKIP,
|
||||
GET_METADATA_CACHE_REASON_DATA_MOVEMENT, GET_METADATA_CACHE_REASON_DELETE_MARKER, GET_METADATA_CACHE_REASON_DIST_ERASURE,
|
||||
GET_METADATA_CACHE_REASON_INCL_FREE_VERSIONS, GET_METADATA_CACHE_REASON_INSUFFICIENT_CACHED_QUORUM,
|
||||
GET_METADATA_CACHE_REASON_META_BUCKET, GET_METADATA_CACHE_REASON_NO_LOCK, GET_METADATA_CACHE_REASON_NOT_FOUND_OR_EXPIRED,
|
||||
GET_METADATA_CACHE_REASON_NOT_READ_DATA, GET_METADATA_CACHE_REASON_PART_NUMBER,
|
||||
GET_METADATA_CACHE_REASON_RAW_DATA_MOVEMENT_READ, GET_METADATA_CACHE_REASON_USABLE, GET_METADATA_CACHE_REASON_VERSION_ID,
|
||||
GET_METADATA_CACHE_REASON_VERSION_SUSPENDED, GET_METADATA_CACHE_REASON_VERSIONED,
|
||||
GET_METADATA_EARLY_STOP_REASON_CONFLICTING_METADATA, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_BODY_VERIFY,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_DELETED, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_GEOMETRY,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_IDENTITY_MISMATCH,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_PAYLOAD,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_MISSING_SHARD, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_NOT_INLINE,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_PART_SHAPE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_REMOTE,
|
||||
GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_SIZE, GET_METADATA_EARLY_STOP_REASON_DATA_READ_INLINE_TRANSFORMED,
|
||||
@@ -45,16 +72,26 @@ use crate::diagnostics::get::{
|
||||
GET_METADATA_EARLY_STOP_REASON_VERSION_MATCH_QUORUM, GET_METADATA_EARLY_STOP_REASON_VERSION_NOT_FOUND,
|
||||
GET_METADATA_RESPONSE_CORRUPT, GET_METADATA_RESPONSE_DISK_NOT_FOUND, GET_METADATA_RESPONSE_ERROR,
|
||||
GET_METADATA_RESPONSE_IGNORED, GET_METADATA_RESPONSE_NOT_FOUND, GET_METADATA_RESPONSE_TIMEOUT, GET_METADATA_RESPONSE_VALID,
|
||||
GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_CODEC_STREAMING, GET_OBJECT_PATH_DIRECT_MEMORY,
|
||||
GET_OBJECT_PATH_INTERNAL_META, GET_OBJECT_PATH_LEGACY_DUPLEX, GET_OBJECT_PATH_SET_DISK, GET_STAGE_DECODE,
|
||||
GET_STAGE_METADATA_CACHE_LOOKUP, GET_STAGE_METADATA_RESOLVE, GET_STAGE_RANGE, GET_STAGE_READER_SETUP,
|
||||
GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE, GET_STAGE_READER_SETUP_WAIT_QUORUM,
|
||||
GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN, GET_STAGE_READER_TASK_READER_CONSTRUCTION,
|
||||
GetObjectFailureReason, classify_disk_error, get_stage_timer_if_enabled, record_get_object_pipeline_failure,
|
||||
record_get_object_pipeline_failure_for_path, record_get_stage_duration_if_enabled,
|
||||
GET_METADATA_RESPONSE_VERSION_NOT_FOUND, GET_OBJECT_PATH_DIRECT_MEMORY, GET_OBJECT_PATH_INTERNAL_META,
|
||||
GET_OBJECT_PATH_LEGACY_DUPLEX, GET_STAGE_READER_SETUP_DROP_PENDING, GET_STAGE_READER_SETUP_SCHEDULE,
|
||||
GET_STAGE_READER_SETUP_WAIT_QUORUM, GET_STAGE_READER_TASK_BITROT_READER_INIT, GET_STAGE_READER_TASK_FILE_OPEN,
|
||||
GET_STAGE_READER_TASK_READER_CONSTRUCTION, get_stage_timer_if_enabled, record_get_stage_duration_if_enabled,
|
||||
};
|
||||
use crate::disk::disk_store::{DiskStoreRenameDataExt, get_drive_metadata_timeout};
|
||||
#[cfg(test)]
|
||||
use crate::disk::CHECK_PART_FILE_NOT_FOUND;
|
||||
use crate::disk::DiskAPI;
|
||||
#[cfg(test)]
|
||||
use crate::disk::DiskOption;
|
||||
#[cfg(test)]
|
||||
use crate::disk::RUSTFS_META_TMP_BUCKET;
|
||||
use crate::disk::disk_store::get_drive_metadata_timeout;
|
||||
#[cfg(test)]
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
#[cfg(test)]
|
||||
use crate::disk::format::FormatV3;
|
||||
use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX;
|
||||
#[cfg(test)]
|
||||
use crate::disk::new_disk;
|
||||
use crate::disk::{
|
||||
BATCH_READ_VERSION_MAX_ITEMS, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp, DataDirDeleteStatus, Disk,
|
||||
OldCurrentSize, PART_TRANSACTION_NEW_META, PART_TRANSACTION_OLD_META, PART_TRANSACTION_ROLLBACK, PartTransactionAction,
|
||||
@@ -65,9 +102,11 @@ use crate::io_support::bitrot::ShardReader;
|
||||
use crate::io_support::bitrot::{
|
||||
BitrotReaderStageMetrics, DeferredReaderStripeHandle, adjust_shard_read_params,
|
||||
create_bitrot_reader_from_bytes_with_stage_metrics, create_deferred_bitrot_reader_with_stripe_handle,
|
||||
object_mmap_read_enabled, object_mmap_read_max_length,
|
||||
object_mmap_read_max_length,
|
||||
};
|
||||
use crate::set_disk::runtime_sources;
|
||||
use crate::set_disk::shard_source::ShardReadCost;
|
||||
use crate::storage_api_contracts::object::ObjectOperations;
|
||||
use futures::FutureExt as _;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use metrics::counter;
|
||||
@@ -281,6 +320,9 @@ async fn flush_read_version_coalescer_pending(
|
||||
return;
|
||||
}
|
||||
|
||||
// Only the #[cfg(test)] counter-recording block below reads this.
|
||||
#[cfg(not(test))]
|
||||
let _ = lane_key;
|
||||
#[cfg(test)]
|
||||
{
|
||||
let mut observed_paths = HashSet::new();
|
||||
@@ -1364,7 +1406,7 @@ pub(in crate::set_disk) enum ReadRepairAdmissionOutcome {
|
||||
|
||||
pub(in crate::set_disk) type ReadRepairAdmissionFuture = Pin<Box<dyn Future<Output = ReadRepairAdmissionOutcome> + Send>>;
|
||||
pub(in crate::set_disk) type ReadRepairAdmissionSubmitter =
|
||||
fn(rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture;
|
||||
fn(rustfs_heal_contracts::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture;
|
||||
|
||||
pub(in crate::set_disk) struct ReadRepairHealSubmission<'a> {
|
||||
pub(in crate::set_disk) bucket: &'a str,
|
||||
@@ -1385,7 +1427,7 @@ pub(in crate::set_disk) struct ReadRepairHealSubmission<'a> {
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) fn send_read_repair_heal_request(
|
||||
request: rustfs_common::heal_channel::HealChannelRequest,
|
||||
request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
|
||||
) -> ReadRepairAdmissionFuture {
|
||||
Box::pin(async {
|
||||
match send_heal_request_with_admission(request).await {
|
||||
@@ -1453,7 +1495,7 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter(
|
||||
let _ = rustfs_common::mrf_channel::try_send_mrf_intent_typed(kind, bucket, object, version_uuid, Some(scope));
|
||||
}
|
||||
|
||||
let mut request = rustfs_common::heal_channel::create_heal_request_with_options(
|
||||
let mut request = rustfs_heal_contracts::heal_channel::create_heal_request_with_options(
|
||||
bucket.to_string(),
|
||||
Some(object.to_string()),
|
||||
false,
|
||||
@@ -1517,6 +1559,7 @@ pub(in crate::set_disk) async fn submit_read_repair_heal_with_submitter(
|
||||
}
|
||||
|
||||
pub(in crate::set_disk) type ObjectBitrotReader = BitrotReader<ShardReader>;
|
||||
pub(in crate::set_disk) type DeferredReaderReopener = crate::erasure::coding::decode::DeferredReaderReopener<ShardReader>;
|
||||
pub(in crate::set_disk) type BitrotReaderTask<'a> =
|
||||
Pin<Box<dyn Future<Output = (usize, std::result::Result<Option<ObjectBitrotReader>, DiskError>)> + Send + 'a>>;
|
||||
|
||||
@@ -1533,6 +1576,10 @@ pub(in crate::set_disk) struct BitrotReaderSetup {
|
||||
/// readers. The lockstep GET decode uses them to open a parity shard
|
||||
/// aligned to the stripe where a data shard failed (backlog#923).
|
||||
pub(in crate::set_disk) deferred_stripe_handles: Vec<Option<DeferredReaderStripeHandle>>,
|
||||
/// Factories for a fresh, stripe-aligned parity reader. CopySource hedges
|
||||
/// use these disposable readers so an abandoned hedge leaves the original
|
||||
/// deferred reserve untouched.
|
||||
pub(in crate::set_disk) deferred_reopeners: Vec<Option<DeferredReaderReopener>>,
|
||||
pub(in crate::set_disk) errors: Vec<Option<DiskError>>,
|
||||
pub(in crate::set_disk) scheduled: Vec<bool>,
|
||||
pub(in crate::set_disk) attempted: Vec<bool>,
|
||||
@@ -1595,6 +1642,16 @@ pub(in crate::set_disk) fn get_bitrot_reader_setup_strategy(
|
||||
mode: BitrotReaderSetupMode,
|
||||
prefer_data_blocks_first: bool,
|
||||
) -> BitrotReaderSetupStrategy {
|
||||
// CopyObject holds the source reader behind a backpressured destination.
|
||||
// Keep its setup demand-bound even when an operator has retained the
|
||||
// legacy all-shards environment setting for ordinary GETs.
|
||||
if matches!(
|
||||
crate::set_disk::get_object_read_policy(),
|
||||
crate::set_disk::GetObjectReadPolicy::CopySource
|
||||
) {
|
||||
return BitrotReaderSetupStrategy::DataBlocksFirst;
|
||||
}
|
||||
|
||||
match mode {
|
||||
BitrotReaderSetupMode::ReadQuorum
|
||||
if prefer_data_blocks_first
|
||||
@@ -1620,6 +1677,7 @@ impl BitrotReaderSetup {
|
||||
Self {
|
||||
readers: (0..shards).map(|_| None).collect(),
|
||||
deferred_stripe_handles: (0..shards).map(|_| None).collect(),
|
||||
deferred_reopeners: (0..shards).map(|_| None).collect(),
|
||||
errors: vec![Some(DiskError::DiskNotFound); shards],
|
||||
scheduled: vec![false; shards],
|
||||
attempted: vec![false; shards],
|
||||
@@ -1814,6 +1872,41 @@ pub(in crate::set_disk) fn next_unscheduled_reader_index(
|
||||
.find(|idx| !setup.scheduled[*idx])
|
||||
}
|
||||
|
||||
/// Build a cloneable opener for an unopened deferred shard. The returned
|
||||
/// reader is aligned to the requested stripe before its first poll, while the
|
||||
/// source reader created during setup remains untouched as a reserve.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn deferred_reader_reopener(
|
||||
inline_data: Option<Bytes>,
|
||||
disk: Option<DiskStore>,
|
||||
bucket: &str,
|
||||
path: &str,
|
||||
read_offset: usize,
|
||||
read_length: usize,
|
||||
shard_size: usize,
|
||||
checksum_algo: HashAlgorithm,
|
||||
skip_verify_bitrot: bool,
|
||||
use_mmap_read: bool,
|
||||
) -> DeferredReaderReopener {
|
||||
let bucket = bucket.to_owned();
|
||||
let path = path.to_owned();
|
||||
Arc::new(move |stripe_index| {
|
||||
let (reader, handle) = create_deferred_bitrot_reader_with_stripe_handle(
|
||||
inline_data.clone(),
|
||||
disk.clone(),
|
||||
&bucket,
|
||||
&path,
|
||||
read_offset,
|
||||
read_length,
|
||||
shard_size,
|
||||
checksum_algo.clone(),
|
||||
skip_verify_bitrot,
|
||||
use_mmap_read,
|
||||
);
|
||||
handle.advance_stripes(stripe_index).then_some(reader)
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
|
||||
setup: &mut BitrotReaderSetup,
|
||||
@@ -1836,6 +1929,15 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
|
||||
return;
|
||||
}
|
||||
|
||||
// Only CopySource uses disposable, stripe-aligned reopeners. Ordinary GET
|
||||
// readers use the existing deferred handle and should not retain one
|
||||
// heap-allocated closure (plus cloned path/disk state) for every parity
|
||||
// slot.
|
||||
let copy_source_demand_bound = matches!(
|
||||
crate::set_disk::get_object_read_policy(),
|
||||
crate::set_disk::GetObjectReadPolicy::CopySource
|
||||
);
|
||||
|
||||
for idx in 0..disks.len() {
|
||||
if setup.attempted[idx] {
|
||||
continue;
|
||||
@@ -1849,6 +1951,20 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
|
||||
let disk = disks[idx].clone();
|
||||
let data_dir = files[idx].data_dir.unwrap_or_default();
|
||||
let path = format!("{object}/{data_dir}/part.{part_number}");
|
||||
let reopener = copy_source_demand_bound.then(|| {
|
||||
deferred_reader_reopener(
|
||||
inline_data.clone(),
|
||||
disk.clone(),
|
||||
bucket,
|
||||
&path,
|
||||
read_offset,
|
||||
read_length,
|
||||
shard_size,
|
||||
checksum_algo.clone(),
|
||||
skip_verify_bitrot,
|
||||
use_mmap_read,
|
||||
)
|
||||
});
|
||||
let (reader, stripe_handle) = create_deferred_bitrot_reader_with_stripe_handle(
|
||||
inline_data,
|
||||
disk,
|
||||
@@ -1862,6 +1978,7 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
|
||||
use_mmap_read,
|
||||
);
|
||||
setup.retain_deferred_reader(idx, reader, stripe_handle);
|
||||
setup.deferred_reopeners[idx] = reopener;
|
||||
}
|
||||
|
||||
// With the data-shards-only lockstep gate on (backlog#923), the GET decode
|
||||
@@ -1887,6 +2004,20 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
|
||||
let disk = disks[idx].clone();
|
||||
let data_dir = files[idx].data_dir.unwrap_or_default();
|
||||
let path = format!("{object}/{data_dir}/part.{part_number}");
|
||||
let reopener = copy_source_demand_bound.then(|| {
|
||||
deferred_reader_reopener(
|
||||
inline_data.clone(),
|
||||
disk.clone(),
|
||||
bucket,
|
||||
&path,
|
||||
read_offset,
|
||||
read_length,
|
||||
shard_size,
|
||||
checksum_algo.clone(),
|
||||
skip_verify_bitrot,
|
||||
use_mmap_read,
|
||||
)
|
||||
});
|
||||
let (reader, stripe_handle) = create_deferred_bitrot_reader_with_stripe_handle(
|
||||
inline_data,
|
||||
disk,
|
||||
@@ -1901,6 +2032,7 @@ pub(in crate::set_disk) fn fill_deferred_bitrot_readers(
|
||||
);
|
||||
setup.readers[idx] = Some(reader);
|
||||
setup.deferred_stripe_handles[idx] = Some(stripe_handle);
|
||||
setup.deferred_reopeners[idx] = reopener;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2210,6 +2342,10 @@ pub(in crate::set_disk) async fn create_bitrot_readers_until_quorum_with_prefere
|
||||
let strategy = get_bitrot_reader_setup_strategy(mode, prefer_data_blocks_first);
|
||||
|
||||
if use_mmap_read
|
||||
&& !matches!(
|
||||
crate::set_disk::get_object_read_policy(),
|
||||
crate::set_disk::GetObjectReadPolicy::CopySource
|
||||
)
|
||||
&& let Some(mut setup) = try_create_bitrot_readers_via_batch_pread(
|
||||
files,
|
||||
disks,
|
||||
@@ -3580,7 +3716,7 @@ pub(in crate::set_disk) async fn finish_rename_tail_heal<
|
||||
tail_drain: tokio::task::JoinHandle<Option<RenameTailOutcome>>,
|
||||
guard_release: tokio::sync::oneshot::Receiver<bool>,
|
||||
guards: Guards,
|
||||
request: rustfs_common::heal_channel::HealChannelRequest,
|
||||
request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
|
||||
finalize: Finalize,
|
||||
cleanup: Cleanup,
|
||||
submit: Submit,
|
||||
@@ -3590,7 +3726,7 @@ pub(in crate::set_disk) async fn finish_rename_tail_heal<
|
||||
FinalizeFuture: Future<Output = ()> + Send,
|
||||
Cleanup: FnOnce(Guards, Vec<RenameTailCleanup>) -> CleanupFuture + Send,
|
||||
CleanupFuture: Future<Output = ()> + Send,
|
||||
Submit: FnOnce(rustfs_common::heal_channel::HealChannelRequest) -> SubmitFuture + Send,
|
||||
Submit: FnOnce(rustfs_heal_contracts::heal_channel::HealChannelRequest) -> SubmitFuture + Send,
|
||||
SubmitFuture: Future<Output = ()> + Send,
|
||||
{
|
||||
let (needs_heal, tail_cleanup, tail_complete) = match tail_drain.await {
|
||||
@@ -4939,16 +5075,17 @@ impl SetDisks {
|
||||
// reclaim_orphan_data_dirs. Reuses the existing heal channel, which
|
||||
// deduplicates and back-pressures via admission; failures only drop
|
||||
// the return value (same shape as multipart's existing heal enqueue).
|
||||
let _ =
|
||||
rustfs_common::heal_channel::send_heal_request(rustfs_common::heal_channel::create_heal_request_with_options(
|
||||
let _ = rustfs_heal_contracts::heal_channel::send_heal_request(
|
||||
rustfs_heal_contracts::heal_channel::create_heal_request_with_options(
|
||||
bucket.to_string(),
|
||||
Some(object.to_string()),
|
||||
false,
|
||||
Some(rustfs_common::heal_channel::HealChannelPriority::Normal),
|
||||
Some(rustfs_heal_contracts::heal_channel::HealChannelPriority::Normal),
|
||||
Some(self.pool_index),
|
||||
Some(self.set_index),
|
||||
))
|
||||
.await;
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6667,7 +6804,7 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[test]
|
||||
fn write_precondition_lookup_errors_fail_closed_unless_absence_is_known() {
|
||||
@@ -6800,18 +6937,24 @@ mod tests {
|
||||
write_raw_file_meta_unchecked(disk, bucket, object, metadata).await;
|
||||
}
|
||||
|
||||
fn failed_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
|
||||
fn failed_read_repair_submitter(
|
||||
_request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
|
||||
) -> ReadRepairAdmissionFuture {
|
||||
Box::pin(async { ReadRepairAdmissionOutcome::Failed("injected submit failure".to_string()) })
|
||||
}
|
||||
|
||||
fn accepted_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
|
||||
fn accepted_read_repair_submitter(
|
||||
_request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
|
||||
) -> ReadRepairAdmissionFuture {
|
||||
Box::pin(async { ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Accepted) })
|
||||
}
|
||||
|
||||
fn dropped_read_repair_submitter(_request: rustfs_common::heal_channel::HealChannelRequest) -> ReadRepairAdmissionFuture {
|
||||
fn dropped_read_repair_submitter(
|
||||
_request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
|
||||
) -> ReadRepairAdmissionFuture {
|
||||
Box::pin(async {
|
||||
ReadRepairAdmissionOutcome::Response(HealAdmissionResult::Dropped(
|
||||
rustfs_common::heal_channel::HealAdmissionDropReason::PolicyDropped,
|
||||
rustfs_heal_contracts::heal_channel::HealAdmissionDropReason::PolicyDropped,
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -8853,7 +8996,7 @@ mod tests {
|
||||
tail_drain,
|
||||
released,
|
||||
(),
|
||||
rustfs_common::heal_channel::HealChannelRequest::default(),
|
||||
rustfs_heal_contracts::heal_channel::HealChannelRequest::default(),
|
||||
move || async move {
|
||||
*finalize_captured.lock().expect("finalize recorder should not poison") = true;
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! This module only establishes the borrow handle. It moves no trait impl and
|
||||
//! changes no runtime behavior.
|
||||
|
||||
use super::*;
|
||||
use super::{Arc, DiskStore, Endpoint, FormatV3, LockClient, RwLock, SetDisks};
|
||||
|
||||
/// Lightweight, `Copy` handle borrowing the shared [`SetDisks`] core state.
|
||||
///
|
||||
|
||||
@@ -12,8 +12,19 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
Bytes, DATA_MOVEMENT_MULTIPART_PREFIX, DiskError, DiskStore, FileInfo, HashMap, HashSet, OBJECT_OP_IGNORED_ERRS, ObjProps,
|
||||
OffsetDateTime, SetDisks, Sha256, TRANSITION_COMPLETE, Uuid, debug, disk, error, file_info_is_valid_for_metadata, hex,
|
||||
reduce_read_quorum_errs, warn,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::disk::DiskOption;
|
||||
#[cfg(test)]
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
#[cfg(test)]
|
||||
use crate::disk::new_disk;
|
||||
use rustfs_utils::http;
|
||||
use sha2::Digest;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FileInfoIdentityGroup {
|
||||
|
||||
@@ -38,24 +38,15 @@
|
||||
//! read primitives it drives.
|
||||
//! - `metadata.rs`, `replication.rs`, `shard_source.rs` — supporting helpers.
|
||||
|
||||
// #730: SetDisks still hosts staged read/heal/write migration helpers.
|
||||
#![allow(unused_imports)]
|
||||
#![allow(unused_variables)]
|
||||
|
||||
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
||||
use crate::bucket::metadata_sys;
|
||||
use crate::bucket::metadata_sys::ObjectLockConfigState;
|
||||
use crate::bucket::object_lock::objectlock_sys::{
|
||||
check_object_lock_for_deletion_with_config, check_object_lock_for_deletion_with_state, check_retention_for_modification,
|
||||
replication_write_may_pass_worm_gate,
|
||||
check_object_lock_for_deletion_with_state, check_retention_for_modification, replication_write_may_pass_worm_gate,
|
||||
};
|
||||
use crate::bucket::replication::{
|
||||
ReplicateDecision, ReplicationObjectBridge, ReplicationState, ReplicationStatusType, VersionPurgeStatusType,
|
||||
replication_state_to_filemeta,
|
||||
};
|
||||
use crate::bucket::versioning::VersioningApi;
|
||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
||||
use crate::client::{object_api_utils::get_raw_etag, transition_api::ObjectReader, transition_api::ReaderImpl};
|
||||
#[cfg(test)]
|
||||
use crate::bucket::replication::ReplicationState;
|
||||
use crate::bucket::replication::{ReplicateDecision, ReplicationObjectBridge, ReplicationStatusType, VersionPurgeStatusType};
|
||||
use crate::cluster::rpc::heal_bucket_local_on_disks;
|
||||
use crate::data_usage::record_compression_total_memory;
|
||||
use crate::diagnostics::get::{
|
||||
@@ -74,9 +65,11 @@ use crate::disk::error_reduce::{
|
||||
BUCKET_OP_IGNORED_ERRS, OBJECT_OP_IGNORED_ERRS, build_write_quorum_failure_summary, count_errs, reduce_read_quorum_errs,
|
||||
reduce_write_quorum_errs,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::disk::has_part_err;
|
||||
use crate::disk::{
|
||||
self, CHECK_PART_DISK_NOT_FOUND, CHECK_PART_FILE_CORRUPT, CHECK_PART_FILE_NOT_FOUND, CHECK_PART_SUCCESS, CHECK_PART_UNKNOWN,
|
||||
conv_part_err_to_int, has_part_err,
|
||||
conv_part_err_to_int,
|
||||
};
|
||||
use crate::disk::{STORAGE_FORMAT_FILE, count_part_not_success};
|
||||
use crate::erasure::codec::bridge::{
|
||||
@@ -88,17 +81,17 @@ use crate::error::{GenericError, ObjectApiError, is_err_object_not_found};
|
||||
use crate::io_support::bitrot::{create_bitrot_reader, create_bitrot_reader_from_bytes, create_bitrot_writer};
|
||||
use crate::object_api::ObjectOptions;
|
||||
use crate::object_api::get_object_body_cache_hook;
|
||||
use crate::object_api::object_api_utils::get_raw_etag;
|
||||
use crate::runtime::instance::{InstanceContext, bootstrap_ctx};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::services::batch_processor::AsyncBatchProcessor;
|
||||
#[cfg(test)]
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations;
|
||||
use crate::storage_api_contracts::{
|
||||
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
|
||||
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
|
||||
multipart::{
|
||||
CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartOperations as _, MultipartUploadResult, PartInfo,
|
||||
},
|
||||
multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo},
|
||||
namespace::NamespaceLocking as _,
|
||||
object::{DeleteAccounting, DeletedObject, HTTPPreconditions, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
|
||||
object::{DeleteAccounting, DeletedObject, HTTPPreconditions, ObjectIO as _, ObjectToDelete},
|
||||
range::HTTPRangeSpec,
|
||||
};
|
||||
use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||
@@ -109,7 +102,7 @@ use crate::{
|
||||
disk::{
|
||||
CheckPartsResp, DeleteOptions, DiskAPI, DiskInfo, DiskInfoOptions, DiskOption, DiskStore, FileInfoVersions,
|
||||
RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions,
|
||||
SnapshotLeaseToken, UpdateMetadataOpts, endpoint::Endpoint, error::DiskError, format::FormatV3, new_disk,
|
||||
SnapshotLeaseToken, UpdateMetadataOpts, endpoint::Endpoint, error::DiskError, format::FormatV3,
|
||||
},
|
||||
error::{StorageError, to_object_err},
|
||||
object_api::{GetObjectReader, NamespaceLockFence, ObjectInfo, ObjectLockConfigSnapshot, PutObjReader},
|
||||
@@ -122,42 +115,40 @@ use crate::{
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use bytesize::ByteSize;
|
||||
use chrono::Utc;
|
||||
use futures::future::join_all;
|
||||
use futures::task::AtomicWaker;
|
||||
use glob::Pattern;
|
||||
use http::HeaderMap;
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use rand::{Rng, seq::SliceRandom};
|
||||
use regex::Regex;
|
||||
use rustfs_common::heal_channel::{
|
||||
DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode,
|
||||
send_heal_disk, send_heal_request_with_admission,
|
||||
};
|
||||
use rustfs_config::MI_B;
|
||||
use rustfs_filemeta::{
|
||||
FileInfo, FileMeta, FileMetaShallowVersion, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams, ObjectPartInfo,
|
||||
RawFileInfo, file_info_from_raw, merge_file_meta_versions,
|
||||
FileInfo, FileMeta, FileMetaShallowVersion, MetaCacheEntries, MetaCacheEntry, ObjectPartInfo, RawFileInfo,
|
||||
merge_file_meta_versions,
|
||||
};
|
||||
use rustfs_heal_contracts::heal_channel::{
|
||||
DriveState, HealAdmissionResult, HealChannelPriority, HealItemType, HealOpts, HealRequestSource, HealScanMode,
|
||||
send_heal_disk, send_heal_request_with_admission,
|
||||
};
|
||||
use rustfs_io_metrics::{
|
||||
record_object_lock_diag_acquire_duration, record_object_lock_diag_enabled, record_object_lock_diag_hold_duration,
|
||||
record_object_lock_diag_slow_acquire, record_object_lock_diag_slow_hold,
|
||||
};
|
||||
use rustfs_lock::LockClient;
|
||||
#[cfg(test)]
|
||||
use rustfs_lock::LockManager;
|
||||
use rustfs_lock::fast_lock::types::LockResult;
|
||||
use rustfs_lock::local_lock::LocalLock;
|
||||
use rustfs_lock::{FastLockGuard, LockManager, NamespaceLock, NamespaceLockGuard, NamespaceLockWrapper, ObjectKey};
|
||||
use rustfs_lock::{FastLockGuard, NamespaceLock, NamespaceLockGuard, NamespaceLockWrapper, ObjectKey};
|
||||
use rustfs_madmin::heal_commands::{HealDriveInfo, HealResultItem, Infos};
|
||||
use rustfs_object_capacity::capacity_scope::{
|
||||
CapacityScope, CapacityScopeDisk, current_dirty_generation, record_capacity_scope, record_global_dirty_scope,
|
||||
};
|
||||
use rustfs_s3_client::transition_api::{ObjectReader, ReaderImpl};
|
||||
use rustfs_s3_types::EventName;
|
||||
#[cfg(test)]
|
||||
use rustfs_utils::http::SSEC_ALGORITHM_HEADER;
|
||||
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
|
||||
use rustfs_utils::http::headers::AMZ_STORAGE_CLASS;
|
||||
use rustfs_utils::http::headers::{
|
||||
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
|
||||
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES,
|
||||
};
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE, SUFFIX_BUCKET_INCARNATION_ID, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE,
|
||||
@@ -170,30 +161,29 @@ use rustfs_utils::{
|
||||
path::{SLASH_SEPARATOR, encode_dir_object, has_suffix, path_join_buf},
|
||||
};
|
||||
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, X_AMZ_RESTORE};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::future::Future;
|
||||
use sha2::Sha256;
|
||||
use std::hash::{BuildHasher, Hash, Hasher};
|
||||
use std::mem::{self};
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
io::{Cursor, Write},
|
||||
io::Cursor,
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
#[cfg(test)]
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
#[cfg(test)]
|
||||
use tokio::time::timeout;
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, ReadBuf},
|
||||
sync::{RwLock, broadcast},
|
||||
};
|
||||
use tokio::{
|
||||
select,
|
||||
sync::mpsc::{self, Sender},
|
||||
time::{interval, timeout},
|
||||
io::{AsyncRead, AsyncWrite, BufReader, ReadBuf},
|
||||
sync::RwLock,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::error;
|
||||
@@ -346,7 +336,7 @@ const ENV_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: &str = "RUSTFS_MULTIP
|
||||
const DEFAULT_RUSTFS_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: usize = 128 * 1024 * 1024;
|
||||
static CACHED_MULTIPART_PUT_LARGE_BATCH_MIN_SIZE_BYTES: OnceLock<usize> = OnceLock::new();
|
||||
|
||||
use crate::io_support::rio::{EtagResolvable, HashReader, HashReaderMut, TryGetIndex as _};
|
||||
use crate::io_support::rio::HashReader;
|
||||
|
||||
pub const DEFAULT_READ_BUFFER_SIZE: usize = MI_B; // 1 MiB = 1024 * 1024;
|
||||
pub const MAX_PARTS_COUNT: usize = 10000;
|
||||
@@ -792,6 +782,65 @@ const ENV_RUSTFS_GET_METADATA_SLOWTAIL_FAULT_OBJECT_PREFIX: &str = "RUSTFS_GET_M
|
||||
const ENV_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: &str = "RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH";
|
||||
const DEFAULT_RUSTFS_GET_MULTIPART_READER_SETUP_PREFETCH: bool = true;
|
||||
|
||||
/// Identifies the caller's read contract for policies that are deliberately
|
||||
/// narrower than the storage API's ordinary GET contract.
|
||||
///
|
||||
/// Server-side copy consumes a source reader while a destination writer is
|
||||
/// applying backpressure. Its source read must not speculatively open the
|
||||
/// next multipart part: those extra shard streams can share an internode H2
|
||||
/// connection with the current part and starve the lockstep decoder. Keep
|
||||
/// this context internal so the public `ObjectOptions` and storage traits do
|
||||
/// not acquire a copy-only field.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum GetObjectReadPolicy {
|
||||
#[default]
|
||||
Default,
|
||||
CopySource,
|
||||
}
|
||||
|
||||
impl GetObjectReadPolicy {
|
||||
pub(crate) const fn allows_multipart_setup_prefetch(self) -> bool {
|
||||
matches!(self, Self::Default)
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
static GET_OBJECT_READ_POLICY: GetObjectReadPolicy;
|
||||
static GET_OBJECT_READ_CANCELLATION: tokio_util::sync::CancellationToken;
|
||||
}
|
||||
|
||||
pub(crate) fn get_object_read_policy() -> GetObjectReadPolicy {
|
||||
GET_OBJECT_READ_POLICY.try_with(|policy| *policy).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn with_get_object_read_policy<F>(policy: GetObjectReadPolicy, future: F) -> F::Output
|
||||
where
|
||||
F: std::future::Future,
|
||||
{
|
||||
let decode_policy = match policy {
|
||||
GetObjectReadPolicy::Default => crate::erasure::coding::decode::DecodeReadPolicy::Default,
|
||||
GetObjectReadPolicy::CopySource => crate::erasure::coding::decode::DecodeReadPolicy::DemandBound,
|
||||
};
|
||||
crate::erasure::coding::decode::with_decode_read_policy(decode_policy, GET_OBJECT_READ_POLICY.scope(policy, future)).await
|
||||
}
|
||||
|
||||
/// Return the request-owned cancellation token for a copy source, when one is
|
||||
/// installed. The token is read before the detached legacy producer is spawned;
|
||||
/// Tokio task-local values do not cross that spawn boundary on their own.
|
||||
pub(crate) fn get_object_read_cancellation() -> Option<tokio_util::sync::CancellationToken> {
|
||||
GET_OBJECT_READ_CANCELLATION.try_with(|token| token.clone()).ok()
|
||||
}
|
||||
|
||||
pub(crate) async fn with_get_object_read_cancellation<F>(
|
||||
cancellation: tokio_util::sync::CancellationToken,
|
||||
future: F,
|
||||
) -> F::Output
|
||||
where
|
||||
F: std::future::Future,
|
||||
{
|
||||
GET_OBJECT_READ_CANCELLATION.scope(cancellation, future).await
|
||||
}
|
||||
|
||||
static OBJECT_LOCK_DIAG_ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
mod core;
|
||||
@@ -812,7 +861,7 @@ pub(crate) use ops::object::DeleteObjectCommitBarrier;
|
||||
#[cfg(feature = "test-util")]
|
||||
pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier;
|
||||
pub(crate) use ops::object::body_cache_plaintext_len;
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
pub(crate) use ops::object::cleanup_rejected_transition_upload_durably;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use ops::object::{PutObjectCommitBarrier, PutObjectCommitPause};
|
||||
@@ -969,8 +1018,7 @@ mod prepared_get_object_metadata_tests {
|
||||
use crate::ecstore_validation_blackbox::make_local_set_disks;
|
||||
use crate::object_api::{BLOCK_SIZE_V2, PutObjReader};
|
||||
use crate::set_disk::core::io_primitives::{bounded_metadata_fanout_order, disk_call_counters, rename_fanout_barrier};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
|
||||
use crate::storage_api_contracts::bucket::MakeBucketOptions;
|
||||
use crate::test_metrics::CapturingRecorder;
|
||||
use http::HeaderMap;
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -2296,6 +2344,7 @@ enum GetCodecStreamingFallbackReason {
|
||||
InvalidMinSize,
|
||||
ReadQuorumNotSafe,
|
||||
MultipartPartLimit,
|
||||
CopySourceDemandBound,
|
||||
}
|
||||
|
||||
impl GetCodecStreamingFallbackReason {
|
||||
@@ -2317,6 +2366,7 @@ impl GetCodecStreamingFallbackReason {
|
||||
Self::InvalidMinSize => "invalid_min_size",
|
||||
Self::ReadQuorumNotSafe => "read_quorum_not_safe",
|
||||
Self::MultipartPartLimit => "multipart_part_limit",
|
||||
Self::CopySourceDemandBound => "copy_source_demand_bound",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2623,6 +2673,17 @@ fn get_codec_streaming_reader_gate(
|
||||
prefer_data_blocks_first_reader_setup: false,
|
||||
};
|
||||
}
|
||||
if matches!(get_object_read_policy(), GetObjectReadPolicy::CopySource) {
|
||||
// The codec reader has its own bounded fill worker. It may still
|
||||
// request an additional stripe for a plain single-part object even
|
||||
// when multipart setup prefetch is disabled, so copy sources use the
|
||||
// legacy demand-bound reader for every object class.
|
||||
return GetCodecStreamingGate {
|
||||
object_class,
|
||||
decision: GetCodecStreamingDecision::Fallback(GetCodecStreamingFallbackReason::CopySourceDemandBound),
|
||||
prefer_data_blocks_first_reader_setup: false,
|
||||
};
|
||||
}
|
||||
if !config.rollout.is_opted_in() {
|
||||
return GetCodecStreamingGate {
|
||||
object_class,
|
||||
@@ -3111,8 +3172,9 @@ pub struct SetDisks {
|
||||
#[cfg(test)]
|
||||
storage_class_config_override: Arc<std::sync::RwLock<Option<Arc<storageclass::Config>>>>,
|
||||
#[cfg(test)]
|
||||
rename_tail_heal_capture:
|
||||
Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<rustfs_common::heal_channel::HealChannelRequest>>>>,
|
||||
rename_tail_heal_capture: Arc<
|
||||
std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<rustfs_heal_contracts::heal_channel::HealChannelRequest>>>,
|
||||
>,
|
||||
}
|
||||
|
||||
// DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones
|
||||
@@ -3388,7 +3450,10 @@ impl DiskHealthEntry {
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
pub(in crate::set_disk) async fn submit_rename_tail_heal(&self, request: rustfs_common::heal_channel::HealChannelRequest) {
|
||||
pub(in crate::set_disk) async fn submit_rename_tail_heal(
|
||||
&self,
|
||||
request: rustfs_heal_contracts::heal_channel::HealChannelRequest,
|
||||
) {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let capture = self
|
||||
@@ -3402,13 +3467,13 @@ impl SetDisks {
|
||||
}
|
||||
}
|
||||
|
||||
let _ = rustfs_common::heal_channel::send_heal_request(request).await;
|
||||
let _ = rustfs_heal_contracts::heal_channel::send_heal_request(request).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(in crate::set_disk) fn capture_test_rename_tail_heals(
|
||||
&self,
|
||||
) -> tokio::sync::mpsc::UnboundedReceiver<rustfs_common::heal_channel::HealChannelRequest> {
|
||||
) -> tokio::sync::mpsc::UnboundedReceiver<rustfs_heal_contracts::heal_channel::HealChannelRequest> {
|
||||
let (capture, requests) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut slot = self
|
||||
.rename_tail_heal_capture
|
||||
@@ -4612,7 +4677,10 @@ fn check_object_lock_retention_update(bucket: &str, object: &str, obj_info: &Obj
|
||||
if let Some(retention) = &opts.object_lock_retention
|
||||
&& check_retention_for_modification(
|
||||
&obj_info.user_defined,
|
||||
retention.mode.as_deref(),
|
||||
retention
|
||||
.mode
|
||||
.as_deref()
|
||||
.and_then(crate::bucket::object_lock::types::RetentionMode::parse_exact),
|
||||
retention.retain_until,
|
||||
retention.bypass_governance,
|
||||
)
|
||||
@@ -5891,7 +5959,7 @@ mod tests {
|
||||
use crate::set_disk::core::io_primitives::rename_fanout_barrier;
|
||||
use crate::storage_api_contracts::{
|
||||
heal::HealOperations as _, lifecycle::TransitionedObject, list::ListOperations as _, multipart::CompletePart,
|
||||
namespace::NamespaceLocking as _, object::ObjectIO as _, object::ObjectOperations as _,
|
||||
object::ObjectOperations as _,
|
||||
};
|
||||
use crate::store::init_format::save_format_file;
|
||||
use crate::store::list_objects::ListPathOptions;
|
||||
@@ -5908,6 +5976,29 @@ mod tests {
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_source_read_policy_is_scoped_and_demand_bound() {
|
||||
assert_eq!(get_object_read_policy(), GetObjectReadPolicy::Default);
|
||||
assert!(GetObjectReadPolicy::Default.allows_multipart_setup_prefetch());
|
||||
assert!(!GetObjectReadPolicy::CopySource.allows_multipart_setup_prefetch());
|
||||
|
||||
with_get_object_read_policy(GetObjectReadPolicy::CopySource, async {
|
||||
assert_eq!(get_object_read_policy(), GetObjectReadPolicy::CopySource);
|
||||
assert!(!get_object_read_policy().allows_multipart_setup_prefetch());
|
||||
assert_eq!(
|
||||
crate::erasure::coding::decode::decode_read_policy(),
|
||||
crate::erasure::coding::decode::DecodeReadPolicy::DemandBound
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(get_object_read_policy(), GetObjectReadPolicy::Default);
|
||||
assert_eq!(
|
||||
crate::erasure::coding::decode::decode_read_policy(),
|
||||
crate::erasure::coding::decode::DecodeReadPolicy::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_part_error_maps_confirmed_missing_to_invalid_part() {
|
||||
for err in ["file not found", "Specified part could not be found", "part.7 not found"] {
|
||||
|
||||
@@ -12,7 +12,16 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::super::*;
|
||||
use super::super::{
|
||||
Cursor, DiskStore, EVENT_SET_DISK_WRITE, Error, FileInfo, HashAlgorithm, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_SET_DISK,
|
||||
Result, join_all, warn,
|
||||
};
|
||||
use crate::disk::DiskAPI;
|
||||
#[cfg(test)]
|
||||
use crate::disk::RUSTFS_META_TMP_BUCKET;
|
||||
use crate::set_disk::coding;
|
||||
#[cfg(test)]
|
||||
use bytes::Bytes;
|
||||
|
||||
/// Null out any disk whose shard writer failed (or was never created) so its
|
||||
/// truncated/absent shard is not committed by the final rename, and return the
|
||||
@@ -132,7 +141,6 @@ pub(in crate::set_disk::ops) async fn verify_written_bitrot_shards(
|
||||
mod tests {
|
||||
use super::super::object::hermetic_set_disks_support::hermetic_set_disks_for_pool_with_default_parity;
|
||||
use super::*;
|
||||
use crate::disk::DiskAPI as _;
|
||||
|
||||
async fn encode_streaming_shard(data: &[u8], shard_size: usize) -> Bytes {
|
||||
let mut writer = coding::BitrotWriter::new(Cursor::new(Vec::new()), shard_size, HashAlgorithm::HighwayHash256S);
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
//! `for SetDisks`, so its associated-type bounds are unchanged and runtime
|
||||
//! behavior is the same.
|
||||
|
||||
use super::super::*;
|
||||
use super::super::{
|
||||
BUCKET_OP_IGNORED_ERRS, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, DiskError, Error, HashMap,
|
||||
MakeBucketOptions, Result, SetDisks, is_reserved_or_invalid_bucket, join_all, reduce_write_quorum_errs,
|
||||
};
|
||||
use crate::api::bucket::metadata_sys;
|
||||
use crate::disk::DiskAPI;
|
||||
|
||||
impl SetDisks {
|
||||
pub(crate) async fn list_bucket_for_scanner(&self, _opts: &BucketOptions) -> Result<(Vec<BucketInfo>, bool)> {
|
||||
|
||||
@@ -12,9 +12,18 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::super::*;
|
||||
use super::super::{
|
||||
Bytes, CHECK_PART_FILE_CORRUPT, CHECK_PART_SUCCESS, DeleteOptions, DiskError, DiskStore, DriveState, EVENT_SET_DISK_HEAL,
|
||||
Error, FileInfo, HashAlgorithm, HashMap, HealDriveInfo, HealItemType, HealOpts, HealResultItem, HealScanMode, Infos,
|
||||
LOG_SUBSYSTEM_SET_DISK, ObjectInfo, ObjectOptions, ObjectPartInfo, Path, RUSTFS_META_TMP_BUCKET, ReadOptions, Result,
|
||||
SLASH_SEPARATOR, SetDisks, StorageError, Uuid, coding, count_errs, count_part_not_success, create_bitrot_reader,
|
||||
create_bitrot_writer, debug, disk, disks_with_all_parts, encode_dir_object, error, file_info_is_valid_for_metadata,
|
||||
formats_match_reference_slots, get_format_erasure_in_quorum, get_lock_acquire_timeout, has_suffix,
|
||||
heal_bucket_local_on_disks, is_object_dir_dangling, join_all, load_format_erasure_all, path_join_buf, save_format_file,
|
||||
should_heal_object_on_disk, stat_all_dirs, to_object_err, warn,
|
||||
};
|
||||
use crate::disk::DataDirDeleteStatus;
|
||||
use crate::disk::disk_store::DiskStoreRenameDataExt;
|
||||
use crate::disk::DiskAPI;
|
||||
use crate::disk::local::DELETE_DATA_DIR_MARKER_PREFIX;
|
||||
use crate::io_support::bitrot::object_mmap_read_enabled;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
@@ -2386,8 +2395,8 @@ mod heal_result_report_tests {
|
||||
store::init_format::{load_format_erasure, save_format_file},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use rustfs_common::heal_channel::{DriveState, HealOpts, HealScanMode};
|
||||
use rustfs_filemeta::{BLOCK_SIZE_V2, FileInfo, ObjectPartInfo, TRANSITION_COMPLETE};
|
||||
use rustfs_heal_contracts::heal_channel::{DriveState, HealOpts, HealScanMode};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -22,8 +22,14 @@
|
||||
//! every `(object, version)` present on ANY disk, feeding each to the existing
|
||||
//! per-version `SetDisks::heal_object`.
|
||||
|
||||
use super::super::*;
|
||||
use super::super::{
|
||||
Arc, CancellationToken, DiskError, ListPathRawOptions, MetaCacheEntries, MetaCacheEntry, SetDisks, debug, disk, list_path_raw,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::disk::DiskAPI;
|
||||
use crate::object_api::ObjectInfo;
|
||||
#[cfg(test)]
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user