Compare commits

..

9 Commits

Author SHA1 Message Date
Chris An 5cc937f18d fix(guards): resolve main integration conflicts 2026-11-11 08:59:16 -05:00
overtrue a03d93e56e Merge remote-tracking branch 'origin/main' into gw/P2-split-bucket-usecase 2026-11-11 15:43:12 +08:00
overtrue 83a9fa9917 docs: point the metadata listing reference at bucket_usecase/listing.rs 2026-11-11 14:30:59 +08:00
overtrue 6703c86b5d refactor(app): split bucket_usecase into one file per config family
Pure move produced by scripts/codemods/split_bucket_usecase.py: the struct, admission control and shared helpers stay in bucket_usecase.rs; the operations of each configuration family, their helpers and their tests move to bucket_usecase/<family>.rs, request fixtures to test_support.rs and the source-text guards to guards.rs. Empty stubs reserve the files the next batches add. No signature, body or test changes.
2026-11-11 14:29:35 +08:00
overtrue e802a25045 chore(scripts): add codemod that splits bucket_usecase by family
Moves each configuration family of rustfs/src/app/bucket_usecase.rs and its tests into its own file under bucket_usecase/ through explicit name-to-file tables, and stops on any item without an assignment. Preparatory refactor P2 of the gateway migration.
2026-11-11 14:29:23 +08:00
overtrue b18e7d594e Merge remote-tracking branch 'origin/gw/P0-guards-read-dirs' into gw/P2-split-bucket-usecase 2026-11-11 14:06:40 +08:00
Chris An 8172df44ec fix(ci): preserve guard and reference parity tests on merge 2026-11-10 22:55:10 -05:00
overtrue 7b3701ddc6 test(guards): avoid a typos false positive in a fixture 2026-11-11 10:40:32 +08:00
overtrue 2df55c6dea test(guards): read file plus split directory in text guards
Text guards that read one file with include_str! or by path broke, or silently stopped seeing the code they guard, when that code moved to another file. They now read the file they read today, which stays required, plus the sibling directory a mechanical split will populate (storage/legacy_edge, app/bucket_usecase, app/multipart_usecase, app/lifecycle_transition_api_test). Counts are summed over the set and split markers must occur exactly once across it. Refs rustfs/backlog#2765.
2026-11-11 10:23:20 +08:00
155 changed files with 9681 additions and 19426 deletions
-5
View File
@@ -70,11 +70,6 @@ s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
@echo "📦 Checking s3s footprint ratchet..."
./scripts/check_s3s_footprint.sh
.PHONY: gateway-stack-release-boundary-check
gateway-stack-release-boundary-check: ## Check no release build can enable the gateway-stack feature
@echo "🚧 Checking gateway-stack release boundary..."
./scripts/check_gateway_stack_release_boundary.sh
.PHONY: test-count-ratchet-check
test-count-ratchet-check: ## Check no workspace crate fell below its test floor or rose above its #[ignore] ceiling in the committed baseline
@echo "🧪 Checking per-crate test-count ratchet..."
+2 -2
View File
@@ -20,11 +20,11 @@ planning-docs-check: ## Check that no planning-type documents are committed
./scripts/check_no_planning_docs.sh
.PHONY: pre-commit
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check gateway-stack-release-boundary-check test-count-ratchet-check test-seams-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check test-seams-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
@echo "✅ All pre-commit checks passed!"
.PHONY: pre-pr
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check gateway-stack-release-boundary-check test-count-ratchet-check test-seams-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check test-seams-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
@echo "✅ All pre-PR checks passed!"
.PHONY: dev-check
+3 -1
View File
@@ -61,10 +61,11 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/test_check_doc_paths.py
./scripts/check_embedded_secrets.sh --self-test
./scripts/check_s3s_footprint.sh --self-test
./scripts/check_gateway_stack_release_boundary.sh --self-test
./scripts/test_check_test_count_ratchet.sh
./scripts/test_guard_source_set.sh
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_bucket_usecase.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_lifecycle_transition_api_test.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_seams.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
@@ -96,6 +97,7 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/test_junit_diff.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_api_coverage.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_reference_parity.py
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
+1 -1
View File
@@ -13,5 +13,5 @@
# header's rule clears (rustfs_s3_types's macro); fails closed
# ecstore_files Rust files referencing s3s paths under crates/ecstore/src
files=65
s3_error_lines=217
s3_error_lines=219
ecstore_files=0
+1 -1
View File
@@ -12,7 +12,7 @@
# Regenerate with:
# scripts/check_test_count_ratchet.sh --update
e2e_test 1026 20
rustfs 6449 64
rustfs 6440 64
rustfs-audit 70 0
rustfs-checksums 28 1
rustfs-common 36 0
-4
View File
@@ -97,10 +97,6 @@ runs:
shell: bash
run: ./scripts/check_s3s_footprint.sh
- name: Check gateway-stack release boundary
shell: bash
run: ./scripts/check_gateway_stack_release_boundary.sh
- name: Check per-crate test-count ratchet
shell: bash
run: ./scripts/check_test_count_ratchet.sh
+1 -50
View File
@@ -115,7 +115,7 @@ jobs:
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
echo "Base CI policy unavailable; running the full matrix."
fi
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=4$' "$GITHUB_OUTPUT"; then
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=3$' "$GITHUB_OUTPUT"; then
printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' 'rehearse_shards=false' 'packages=' 'plan=' >> "$GITHUB_OUTPUT"
echo "Base policy does not cover this workflow contract; retaining the full matrix."
fi
@@ -705,53 +705,6 @@ jobs:
retention-days: 3
if-no-files-found: warn
test-and-lint-gateway-stack:
name: Test and Lint (gateway-stack)
# The gateway S3 stack (rustfs/backlog#2734) sits behind the rustfs
# `gateway-stack` feature, which no other pull-request lane lints or tests.
# Unlike the dormant feature lanes above, this one runs for every code
# change: each operation slice puts its handler tests, its authorization
# census and its end-to-end refusal test behind the feature, and a lane that
# ran only on demand would let them rot behind a green pull request.
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
# Self-hosted for build scale and cache ownership: a workspace-scale Cargo
# build that reads the ci-dev cache written on this image.
runs-on: sm-standard-4
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # 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'
# A feature that does not compile fails here; nothing below is skipped
# into a pass.
- name: Run clippy with gateway-stack
run: cargo clippy --locked -p rustfs --all-targets --features gateway-stack -- -D warnings
# The gateway stack's own tests: the assembly, the authorizer table and
# its census, the handler template, the migrated use cases, and the
# embedded server driven through the whole middleware stack.
- name: Run tests with gateway-stack
env:
# The same bounded link concurrency as the other feature lanes.
CARGO_BUILD_JOBS: "2"
run: |
cargo nextest run --profile ci --locked -p rustfs --features gateway-stack --no-tests fail \
--status-level all --final-status-level all \
-E 'test(/gateway_stack|gateway_handlers|server::stack::|list_buckets|legacy_stack_is_always_selectable/) | binary(embedded_gateway_stack_test)'
build-rustfs-debug-binary:
name: Build RustFS Debug Binary
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
@@ -1626,7 +1579,6 @@ jobs:
- fips-provider
- test-and-lint-rio-v2
- test-and-lint-protocols
- test-and-lint-gateway-stack
- build-rustfs-debug-binary
- uring-integration
- e2e-tests
@@ -1661,7 +1613,6 @@ jobs:
- core-contract
- test-and-lint-rio-v2
- test-and-lint-protocols
- test-and-lint-gateway-stack
- build-rustfs-debug-binary
- build-rustfs-debug-binary-rio-v2
- uring-integration
-4
View File
@@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Notifications and audit
- Notify and audit targets that keep the default `queue_dir` now work on native Windows: the defaults `/opt/rustfs/events` and `/opt/rustfs/audit` resolve per node, to `%ProgramData%\RustFS\events` and `%ProgramData%\RustFS\audit` on Windows, instead of failing validation as non-absolute paths. The new `RUSTFS_TARGET_STORE_DIR` (Helm `config.rustfs.target_store_dir`) relocates both default queues on any platform. Linux and macOS behavior is unchanged when it is unset, persisted configuration is unchanged, and pending entries are not migrated when the root changes. See `docs/operations/target-queue-storage.md`.
### Deployment
- Existing volumes created by older root-running container images must be writable by the non-root runtime identity before upgrading. The default container UID/GID is `10001:10001`; stop the affected instances, verify host mount paths, and for ordinary bind mounts run `sudo chown -R 10001:10001 /actual/data /actual/logs` on each RustFS data/log mount. Account for ACLs, NFS, Kubernetes, and rootless UID mappings. Image-build ownership changes do not migrate mounted data. See the Docker Quick Start in [README.md](README.md).
Generated
+12 -15
View File
@@ -9639,8 +9639,6 @@ dependencies = [
"rustfs-ecstore",
"rustfs-extension-schema",
"rustfs-filemeta",
"rustfs-gateway",
"rustfs-gateway-adapter",
"rustfs-gateway-types",
"rustfs-heal",
"rustfs-heal-contracts",
@@ -10033,8 +10031,8 @@ dependencies = [
[[package]]
name = "rustfs-gateway"
version = "0.62.0"
source = "git+https://github.com/rustfs/gateway.git?rev=7915d06950f88611d883f6ab3d95efc90c65dec0#7915d06950f88611d883f6ab3d95efc90c65dec0"
version = "0.57.0"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"arc-swap",
"bytes",
@@ -10078,11 +10076,10 @@ dependencies = [
[[package]]
name = "rustfs-gateway-core"
version = "0.60.0"
source = "git+https://github.com/rustfs/gateway.git?rev=7915d06950f88611d883f6ab3d95efc90c65dec0#7915d06950f88611d883f6ab3d95efc90c65dec0"
version = "0.54.0"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"bytes",
"futures-core",
"http 1.5.0",
"md-5 0.11.0",
"percent-encoding",
@@ -10100,7 +10097,7 @@ dependencies = [
[[package]]
name = "rustfs-gateway-http"
version = "0.1.4"
source = "git+https://github.com/rustfs/gateway.git?rev=7915d06950f88611d883f6ab3d95efc90c65dec0#7915d06950f88611d883f6ab3d95efc90c65dec0"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"hmac 0.13.0",
"http 1.5.0",
@@ -10118,7 +10115,7 @@ dependencies = [
[[package]]
name = "rustfs-gateway-macros"
version = "0.1.4"
source = "git+https://github.com/rustfs/gateway.git?rev=7915d06950f88611d883f6ab3d95efc90c65dec0#7915d06950f88611d883f6ab3d95efc90c65dec0"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"proc-macro2",
"quote",
@@ -10127,8 +10124,8 @@ dependencies = [
[[package]]
name = "rustfs-gateway-sig"
version = "0.15.0"
source = "git+https://github.com/rustfs/gateway.git?rev=7915d06950f88611d883f6ab3d95efc90c65dec0#7915d06950f88611d883f6ab3d95efc90c65dec0"
version = "0.13.0"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"hmac 0.13.0",
"http 1.5.0",
@@ -10143,7 +10140,7 @@ dependencies = [
[[package]]
name = "rustfs-gateway-stream"
version = "0.2.0"
source = "git+https://github.com/rustfs/gateway.git?rev=7915d06950f88611d883f6ab3d95efc90c65dec0#7915d06950f88611d883f6ab3d95efc90c65dec0"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"bitflags 2.13.2",
"bytes",
@@ -10153,8 +10150,8 @@ dependencies = [
[[package]]
name = "rustfs-gateway-types"
version = "0.40.0+aws.2026-10-02"
source = "git+https://github.com/rustfs/gateway.git?rev=7915d06950f88611d883f6ab3d95efc90c65dec0#7915d06950f88611d883f6ab3d95efc90c65dec0"
version = "0.32.0+aws.2026-10-02"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"bytes",
"crc-fast",
@@ -10177,7 +10174,7 @@ dependencies = [
[[package]]
name = "rustfs-gateway-xml"
version = "0.4.0"
source = "git+https://github.com/rustfs/gateway.git?rev=7915d06950f88611d883f6ab3d95efc90c65dec0#7915d06950f88611d883f6ab3d95efc90c65dec0"
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
dependencies = [
"quick-xml 0.41.0",
]
+4 -4
View File
@@ -149,14 +149,14 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.5" }
# engine crates move onto from s3s (rustfs/backlog#2745). The one pin of the
# gateway in this workspace; no crate may enable a `compat-s3s*` feature except
# the s3s-facing edge of the rustfs binary.
rustfs-gateway-types = { git = "https://github.com/rustfs/gateway.git", rev = "7915d06950f88611d883f6ab3d95efc90c65dec0", version = "0.40.0", default-features = false }
rustfs-gateway-stream = { git = "https://github.com/rustfs/gateway.git", rev = "7915d06950f88611d883f6ab3d95efc90c65dec0", version = "0.2.0", default-features = false }
rustfs-gateway-types = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.32.0", default-features = false }
rustfs-gateway-stream = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.2.0", default-features = false }
# The gateway framework itself, consumed for its extension traits by the ring-2
# adapter. `default-features = false` keeps the `server` feature, and with it the
# server crate, out of the build.
rustfs-gateway = { git = "https://github.com/rustfs/gateway.git", rev = "7915d06950f88611d883f6ab3d95efc90c65dec0", version = "0.62.0", default-features = false }
rustfs-gateway = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.57.0", default-features = false }
# Test-only: builds the `EffectiveHost` a host-resolution test queries with.
rustfs-gateway-http = { git = "https://github.com/rustfs/gateway.git", rev = "7915d06950f88611d883f6ab3d95efc90c65dec0", default-features = false }
rustfs-gateway-http = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", default-features = false }
# Async Runtime and Networking
async-channel = "2.5.0"
+1 -1
View File
@@ -199,7 +199,7 @@ docker run -d --name rustfs -p 9000:9000 \
Notes:
- `RUSTFS_NOTIFY_ENABLE=true` enables the global notify module switch.
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
- If queue dir is omitted, the official image maps `/opt/rustfs/events` into the persistent `/data` volume so it is writable by the runtime user and pending events survive container recreation. Override `queue_dir` when using another deployment layout, or set `RUSTFS_TARGET_STORE_DIR` to move every default queue; native Windows defaults to `%ProgramData%\RustFS`. See [Target queue storage](docs/operations/target-queue-storage.md).
- If queue dir is omitted, the official image maps `/opt/rustfs/events` into the persistent `/data` volume so it is writable by the runtime user and pending events survive container recreation. Override `queue_dir` when using another deployment layout.
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
- Since `1.0.0-beta.11`, webhook endpoints on private or container networks
(`Docker Compose service names`, `host.docker.internal`, RFC 1918 addresses) are
-7
View File
@@ -276,13 +276,6 @@ Drive timeout profile preset:
- values must be positive; `0` or an invalid value falls back to the default, and values above `60` are clamped to `60`.
- the setting is read by the aggregating node only; it does not change the internode RPC wire contract. Any retry shares one round deadline rather than receiving a fresh timeout.
## Target queue directory
- `RUSTFS_TARGET_STORE_DIR`
- absolute root for notify and audit target queues whose `queue_dir` is the default `/opt/rustfs/events` or `/opt/rustfs/audit`; they open `<root>/events` and `<root>/audit` on this node.
- unset keeps those paths on Linux and macOS; native Windows uses `%ProgramData%\RustFS\events` and `%ProgramData%\RustFS\audit`.
- explicit `queue_dir` values are never relocated, and the persisted target configuration is unchanged. Changing the root does not migrate pending entries. See `docs/operations/target-queue-storage.md`.
## Startup filesystem boundary policy
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
-6
View File
@@ -144,12 +144,6 @@ pub const POSTGRES_QUEUE_LIMIT: &str = "queue_limit";
/// Applies to both notify and audit target queue stores.
pub const ENV_TARGET_STORE_COMPRESS: &str = "RUSTFS_TARGET_STORE_COMPRESS";
/// Environment variable that relocates the root of the default notify and audit queue directories.
///
/// It applies only to targets whose `queue_dir` is the logical default (`EVENT_DEFAULT_DIR` or
/// `AUDIT_DEFAULT_DIR`); their queues move to `<root>/events` and `<root>/audit` on this node.
pub const ENV_TARGET_STORE_DIR: &str = "RUSTFS_TARGET_STORE_DIR";
/// Queue-store compression is enabled by default to reduce disk footprint.
pub const DEFAULT_TARGET_STORE_COMPRESS: bool = true;
+1 -6
View File
@@ -91,12 +91,7 @@ pub const ENV_OBS_LOG_MATCH_MODE: &str = "RUSTFS_OBS_LOG_MATCH_MODE";
/// Default values for log cleanup
pub const DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB
pub const DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES: u64 = 0; // No per-file trim by the cleaner
/// Size at which the active log file is rolled when
/// `RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES` is unset. Without a size roll
/// the active file is bounded only by the rotation period, and the cleaner
/// never touches the active file. An explicit `0` keeps size rolling off.
pub const DEFAULT_OBS_LOG_ACTIVE_FILE_ROLL_SIZE_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
pub const DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES: u64 = 0; // No single file limit
pub const DEFAULT_OBS_LOG_COMPRESS_OLD_FILES: bool = true;
pub const DEFAULT_OBS_LOG_GZIP_COMPRESSION_LEVEL: u32 = 6;
pub const DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM_GZIP: &str = "gzip";
+3 -38
View File
@@ -17,7 +17,7 @@
use crate::bucket::metadata::BUCKET_METADATA_FILE;
use crate::bucket::replication::ReplicationMigrationBridge;
use crate::disk::{BUCKET_META_PREFIX, MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
use crate::error::{Error, Result, is_err_strict_not_found, is_err_strict_volume_not_found, to_object_err};
use crate::error::{Error, Result, is_err_strict_not_found, is_err_strict_volume_not_found};
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
use crate::storage_api_contracts::{
bucket::{BucketInfo, BucketOperations, BucketOptions},
@@ -334,14 +334,6 @@ async fn migration_target_exists<S: EcstoreObjectOperations>(store: &S, path: &s
}
}
/// Names the target record in a failed migration write, as the existence
/// probe's error already does. The commit path returns bare disk errors, and on
/// Windows a blocked record is first seen here: reading through a regular-file
/// ancestor reports the record missing instead of refusing access to it.
fn migration_write_error(err: Error, path: &str) -> Error {
to_object_err(err, vec![RUSTFS_META_BUCKET, path])
}
async fn migrate_one_if_missing<S>(
store: Arc<S>,
opts: &ObjectOptions,
@@ -377,10 +369,7 @@ where
.unwrap_or(data);
let mut put_data = PutObjReader::from_vec(data);
store
.put_object(RUSTFS_META_BUCKET, path, &mut put_data, opts)
.await
.map_err(|err| migration_write_error(err, path))?;
store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, opts).await?;
info!("Migrated {label}");
Ok(())
}
@@ -492,10 +481,7 @@ where
Err(_) => return Err(MigrationMetadataError::Incompatible(path.to_owned()).into()),
};
let mut put_data = PutObjReader::from_vec(data);
store
.put_object(RUSTFS_META_BUCKET, path, &mut put_data, &opts)
.await
.map_err(|err| migration_write_error(err, path))?;
store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, &opts).await?;
info!("Migrated IAM config: {path}");
total_migrated += 1;
}
@@ -545,27 +531,6 @@ mod tests {
));
}
#[test]
fn migration_write_errors_name_the_target_record_without_fragmenting_groups() {
use super::{Error, migration_write_error};
let first = migration_write_error(Error::FileAccessDenied, "buckets/first/.metadata.bin");
let second = migration_write_error(Error::FileAccessDenied, "config/iam/format.json");
assert!(matches!(first, Error::PrefixAccessDenied(..)), "unexpected error: {first:?}");
assert!(
first.to_string().contains(".rustfs.sys/buckets/first/.metadata.bin"),
"the startup error must name the blocked record: {first}"
);
assert_eq!(first, second, "record paths must not fragment error grouping");
let unrelated = migration_write_error(Error::other("disk offline"), "buckets/first/.metadata.bin");
assert_eq!(
unrelated,
Error::other("disk offline"),
"errors without a record form pass through unchanged"
);
}
#[test]
fn migration_errors_group_by_cause_and_retain_typed_record_context() {
use super::{Error, MigrationMetadataError};
+10 -320
View File
@@ -41,12 +41,8 @@ use std::{
future::Future,
io::ErrorKind,
pin::Pin,
sync::{
Arc, LazyLock, Mutex,
atomic::{AtomicU8, Ordering},
},
sync::{LazyLock, Mutex},
task::{Context, Poll},
time::Duration,
};
use tonic::{service::interceptor::InterceptedService, transport::Channel};
use tower::Service;
@@ -74,7 +70,7 @@ pub(crate) async fn scanner_control_time_out_client(
let channel = get_channel_for_class(addr, ChannelClass::Control)
.await
.map_err(|err| crate::error::Error::other(err.to_string()))?;
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
let limit = rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES as usize;
Ok(ScannerControlServiceClient::with_interceptor(channel, interceptor)
.max_decoding_message_size(limit)
@@ -88,7 +84,7 @@ pub async fn heal_control_time_out_client(
let interceptor = interceptor.with_rpc_audience(addr)?;
let channel = get_channel_for_class(addr, ChannelClass::Control).await?;
let max_message_size = rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE;
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
Ok(HealControlServiceClient::with_interceptor(channel, interceptor)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size))
@@ -101,7 +97,7 @@ pub async fn tier_mutation_control_time_out_client(
let interceptor = interceptor.with_rpc_audience(addr)?;
let channel = get_channel_for_class(addr, ChannelClass::Control).await?;
let max_message_size = rustfs_protos::TIER_MUTATION_RPC_MAX_MESSAGE_SIZE;
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
Ok(TierMutationControlServiceClient::with_interceptor(channel, interceptor)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size))
@@ -122,7 +118,7 @@ pub async fn node_service_time_out_client_for_class(
let channel = get_channel_for_class(addr, class).await?;
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
Ok(NodeServiceClient::with_interceptor(channel, interceptor)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size))
@@ -137,7 +133,7 @@ pub(crate) async fn node_service_delete_recovery_client(
let channel = rustfs_protos::create_channel_with_timeout(addr, timeout)
.await
.map_err(|error| DiskError::RemoteClientUnavailable(error.to_string()))?;
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
Ok(NodeServiceClient::with_interceptor(channel, interceptor)
.max_decoding_message_size(max_message_size)
@@ -253,95 +249,7 @@ pub struct ReplayScopeChannel<S> {
}
/// The channel type used by internode clients after v2 authentication and replay-scope handling.
pub type AuthenticatedChannel = ReplayScopeChannel<BatchLockDeadlineChannel<Channel>>;
/// Gate lock-batch payloads after the transport's internal readiness queues.
/// Other RPC bodies pass through without an additional wrapper.
#[derive(Clone, Debug)]
pub struct BatchLockDeadlineChannel<S> {
inner: S,
}
impl<S> BatchLockDeadlineChannel<S> {
fn new(inner: S) -> Self {
Self { inner }
}
}
impl<S> Service<HttpRequest<tonic::body::Body>> for BatchLockDeadlineChannel<S>
where
S: Service<HttpRequest<tonic::body::Body>>,
{
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut request: HttpRequest<tonic::body::Body>) -> Self::Future {
if let Some(deadline) = request.extensions().get::<BatchLockRpcDeadline>().cloned() {
let body = std::mem::replace(request.body_mut(), tonic::body::Body::empty());
*request.body_mut() = tonic::body::Body::new(BatchLockDeadlineBody {
inner: body,
deadline,
dispatched: false,
failed: false,
});
}
self.inner.call(request)
}
}
struct BatchLockDeadlineBody {
inner: tonic::body::Body,
deadline: BatchLockRpcDeadline,
dispatched: bool,
failed: bool,
}
impl http_body::Body for BatchLockDeadlineBody {
type Data = bytes::Bytes;
type Error = tonic::Status;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
if self.failed {
return Poll::Ready(None);
}
if !self.dispatched
&& let Err(error) = self.deadline.remaining_before_dispatch()
{
self.failed = true;
return Poll::Ready(Some(Err(error)));
}
let frame = std::task::ready!(Pin::new(&mut self.inner).poll_frame(cx));
if let Some(Ok(frame)) = &frame
&& frame.is_data()
&& !self.dispatched
{
// Codec work can consume the deadline in this poll. Arbitrate only
// after encoding, immediately before any payload leaves the body.
if let Err(error) = self.deadline.mark_dispatched() {
self.failed = true;
return Poll::Ready(Some(Err(error)));
}
self.dispatched = true;
}
Poll::Ready(frame)
}
fn is_end_stream(&self) -> bool {
self.failed || self.inner.is_end_stream()
}
fn size_hint(&self) -> http_body::SizeHint {
self.inner.size_hint()
}
}
pub type AuthenticatedChannel = ReplayScopeChannel<Channel>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PeerReplayCapability {
@@ -518,9 +426,7 @@ where
)
}) {
Ok(headers) => request.headers_mut().extend(headers),
Err(error) => {
debug!(error = %error, "could not attach replay-scoped RPC signature")
}
Err(error) => debug!(error = %error, "could not attach replay-scoped RPC signature"),
}
}
}
@@ -582,82 +488,6 @@ where
}
}
/// A batch may leave local readiness queues only while its original budget is live.
/// Cancellation and submission arbitrate once so a cancelled caller cannot send later.
#[derive(Clone, Debug)]
pub(crate) struct BatchLockRpcDeadline {
deadline: tokio::time::Instant,
max_acquire_wait: Duration,
dispatch: Arc<AtomicU8>,
}
impl BatchLockRpcDeadline {
const PENDING: u8 = 0;
const DISPATCHED: u8 = 1;
const CANCELLED: u8 = 2;
pub(crate) fn new(deadline: tokio::time::Instant, max_acquire_wait: Duration) -> Self {
Self {
deadline,
max_acquire_wait,
dispatch: Arc::new(AtomicU8::new(Self::PENDING)),
}
}
/// True proves that this batch cannot send payload or enter the server lock handler.
/// Transport headers may already have left an internal channel queue.
pub(crate) fn cancel_before_dispatch(&self) -> bool {
match self
.dispatch
.compare_exchange(Self::PENDING, Self::CANCELLED, Ordering::AcqRel, Ordering::Acquire)
{
Ok(_) => true,
Err(state) => state == Self::CANCELLED,
}
}
pub(crate) fn was_dispatched(&self) -> bool {
self.dispatch.load(Ordering::Acquire) == Self::DISPATCHED
}
fn remaining_before_dispatch(&self) -> Result<Duration, tonic::Status> {
let remaining = self.deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining <= self.max_acquire_wait || self.dispatch.load(Ordering::Acquire) == Self::CANCELLED {
return Err(tonic::Status::deadline_exceeded("Lock batch budget exhausted before payload dispatch"));
}
Ok(remaining)
}
fn update_timeout(&self, request: &mut tonic::Request<()>) -> Result<(), tonic::Status> {
request.set_timeout(self.remaining_before_dispatch()?);
Ok(())
}
fn mark_dispatched(&self) -> Result<(), tonic::Status> {
self.remaining_before_dispatch()?;
self.dispatch
.compare_exchange(Self::PENDING, Self::DISPATCHED, Ordering::AcqRel, Ordering::Acquire)
.map(|_| ())
.map_err(|_| tonic::Status::deadline_exceeded("Lock batch cancelled before dispatch"))
}
#[cfg(test)]
pub(super) async fn dispatch_payload_for_test(&self) -> Result<(), tonic::Status> {
use http_body_util::BodyExt as _;
let mut body = BatchLockDeadlineBody {
inner: tonic::body::Body::new(http_body_util::Full::new(bytes::Bytes::from_static(b"payload"))),
deadline: self.clone(),
dispatched: false,
failed: false,
};
match body.frame().await {
Some(Ok(frame)) if frame.is_data() => Ok(()),
Some(Err(error)) => Err(error),
_ => Err(tonic::Status::internal("test lock payload did not produce a data frame")),
}
}
}
pub struct TonicSignatureInterceptor {
audience: Option<String>,
body_digest_strict: bool,
@@ -752,21 +582,11 @@ impl TonicInterceptor {
}
impl tonic::service::Interceptor for TonicInterceptor {
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
let batch_deadline = req.extensions().get::<BatchLockRpcDeadline>().cloned();
if let Some(deadline) = &batch_deadline {
deadline.update_timeout(&mut req)?;
}
let mut req = match self {
fn call(&mut self, req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
match self {
TonicInterceptor::Signature(interceptor) => interceptor.call(req),
TonicInterceptor::NoOp(interceptor) => interceptor.call(req),
}?;
if let Some(deadline) = batch_deadline {
// Signing consumes budget, but Channel readiness only reserves a
// queue slot. The payload body arbitrates actual dispatch later.
deadline.update_timeout(&mut req)?;
}
Ok(req)
}
}
@@ -962,136 +782,6 @@ mod tests {
test_request_for("Ping")
}
#[test]
fn batch_dispatch_and_cancellation_have_one_winner() {
for _ in 0..100 {
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_secs(1), Duration::ZERO);
let other = deadline.clone();
let barrier = Arc::new(std::sync::Barrier::new(2));
let other_barrier = barrier.clone();
let submit = std::thread::spawn(move || {
other_barrier.wait();
other.mark_dispatched().is_ok()
});
barrier.wait();
let cancelled = deadline.cancel_before_dispatch();
let submitted = submit.join().expect("submission thread");
assert_ne!(cancelled, submitted);
assert_eq!(deadline.was_dispatched(), submitted);
assert!(deadline.mark_dispatched().is_err());
}
}
#[test]
fn batch_interceptor_rejects_exhausted_wait_reserve_before_submission() {
let deadline =
BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_millis(20), Duration::from_millis(30));
let mut request = tonic::Request::new(());
request.extensions_mut().insert(deadline.clone());
let status = TonicInterceptor::NoOp(NoOpInterceptor)
.call(request)
.expect_err("insufficient wait reserve");
assert_eq!(status.code(), tonic::Code::DeadlineExceeded);
assert!(!deadline.was_dispatched());
assert!(deadline.cancel_before_dispatch());
}
#[test]
fn batch_interceptor_rejects_cancelled_ready_request_and_accepts_live_budget() {
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_secs(2), Duration::from_millis(1));
let mut request = tonic::Request::new(());
request.extensions_mut().insert(deadline.clone());
assert!(deadline.cancel_before_dispatch());
assert!(TonicInterceptor::NoOp(NoOpInterceptor).call(request).is_err());
assert!(!deadline.was_dispatched());
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_secs(2), Duration::from_millis(1));
let mut request = tonic::Request::new(());
request.extensions_mut().insert(deadline.clone());
let request = TonicInterceptor::NoOp(NoOpInterceptor).call(request).expect("live budget");
assert!(!deadline.was_dispatched(), "interceptor completion does not bypass the transport queue");
assert!(request.metadata().contains_key("grpc-timeout"));
assert!(deadline.cancel_before_dispatch());
}
#[tokio::test(start_paused = true)]
async fn batch_body_rejects_payload_after_channel_queue_deadline() {
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_millis(20), Duration::ZERO);
tokio::time::advance(Duration::from_millis(21)).await;
let error = deadline
.dispatch_payload_for_test()
.await
.expect_err("queued payload expired");
assert_eq!(error.code(), tonic::Code::DeadlineExceeded);
assert!(!deadline.was_dispatched());
assert!(deadline.cancel_before_dispatch());
}
#[tokio::test(start_paused = true)]
async fn batch_body_rejects_payload_when_encoding_crosses_deadline_in_one_poll() {
use http_body_util::BodyExt as _;
struct SlowEncodingBody {
deadline: tokio::time::Instant,
}
impl http_body::Body for SlowEncodingBody {
type Data = bytes::Bytes;
type Error = tonic::Status;
fn poll_frame(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
// Model codec work that begins before the deadline but blocks
// this single poll until the original budget has expired.
tokio::time::resume();
std::thread::sleep(
self.deadline.saturating_duration_since(tokio::time::Instant::now()) + Duration::from_millis(1),
);
Poll::Ready(Some(Ok(http_body::Frame::data(bytes::Bytes::from_static(b"encoded payload")))))
}
}
let expires = tokio::time::Instant::now() + Duration::from_millis(20);
let deadline = BatchLockRpcDeadline::new(expires, Duration::ZERO);
let mut body = BatchLockDeadlineBody {
inner: tonic::body::Body::new(SlowEncodingBody { deadline: expires }),
deadline: deadline.clone(),
dispatched: false,
failed: false,
};
let error = body
.frame()
.await
.expect("first frame result")
.expect_err("late encoded payload");
assert_eq!(error.code(), tonic::Code::DeadlineExceeded);
assert!(!deadline.was_dispatched());
assert!(deadline.cancel_before_dispatch());
assert!(body.frame().await.is_none(), "failed body must not expose a later frame");
}
#[tokio::test(start_paused = true)]
async fn batch_body_keeps_cleanup_responsibility_after_first_payload() {
use http_body_util::BodyExt as _;
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_millis(20), Duration::ZERO);
let frames = futures::stream::iter([
Ok::<_, tonic::Status>(http_body::Frame::data(bytes::Bytes::from_static(b"first"))),
Ok(http_body::Frame::data(bytes::Bytes::from_static(b"last"))),
]);
let mut body = BatchLockDeadlineBody {
inner: tonic::body::Body::new(http_body_util::StreamBody::new(frames)),
deadline: deadline.clone(),
dispatched: false,
failed: false,
};
assert!(body.frame().await.expect("first frame").expect("live payload").is_data());
assert!(deadline.was_dispatched());
assert!(!deadline.cancel_before_dispatch());
tokio::time::advance(Duration::from_millis(21)).await;
assert!(body.frame().await.expect("last frame").expect("late drain").is_data());
assert!(body.frame().await.is_none());
assert!(!deadline.cancel_before_dispatch(), "a sent payload requires late cleanup ownership");
}
fn test_request_for(method: &'static str) -> tonic::Request<()> {
let mut request = tonic::Request::new(());
request
+22 -222
View File
@@ -169,72 +169,6 @@ fn decode_delete_recovery_batch_errors(codes: Vec<Option<u32>>, expected_len: us
.collect()
}
fn log_delete_recovery_batch_result(
endpoint: &Endpoint,
expected_len: usize,
started: Instant,
result: &Result<Vec<Option<DiskError>>>,
) {
let elapsed_ms = started.elapsed().as_millis();
let mut disk_error_counts = std::collections::BTreeMap::<u32, usize>::new();
let (failure_scope, rpc_status) = match result {
Ok(errors) => {
for error in errors.iter().flatten() {
*disk_error_counts.entry(error.to_u32()).or_default() += 1;
}
("object_group", None)
}
Err(error) => {
disk_error_counts.insert(error.to_u32(), expected_len);
let rpc_status = match error {
DiskError::Io(source) => crate::cluster::rpc::client::embedded_tonic_status(source).map(tonic::Status::code),
_ => None,
};
("rpc_or_reply", rpc_status)
}
};
let failed_group_count = disk_error_counts.values().sum::<usize>();
let result = if result.is_err() || failed_group_count > 0 {
"failed"
} else {
"ok"
};
if result == "failed" || elapsed_ms >= 1_000 {
warn!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
result,
operation = "apply_versions",
failure_scope,
pool_index = endpoint.pool_idx,
set_index = endpoint.set_idx,
disk_index = endpoint.disk_idx,
attempted_group_count = expected_len,
failed_group_count,
disk_error_counts = ?disk_error_counts,
rpc_status = ?rpc_status,
elapsed_ms,
"Recoverable batch delete RPC completed"
);
} else {
debug!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
result,
operation = "apply_versions",
pool_index = endpoint.pool_idx,
set_index = endpoint.set_idx,
disk_index = endpoint.disk_idx,
attempted_group_count = expected_len,
failed_group_count,
elapsed_ms,
"Recoverable batch delete RPC completed"
);
}
}
fn validate_delete_recovery_inspection(
volume: &str,
object: &str,
@@ -997,16 +931,8 @@ pub struct RemoteDisk {
#[cfg(test)]
recovery_monitor_teardown_hook: Arc<tokio::sync::Mutex<Option<Arc<RecoveryMonitorTeardownHook>>>>,
data_transport: Arc<dyn InternodeDataTransport>,
/// Bound the per-operation failure warnings of this peer. An unreachable
/// peer fails every in-flight shard operation; the counters keep the exact
/// numbers per operation. Per handle, so one peer cannot hide another's.
timeout_logs: rustfs_utils::LogThrottle,
network_error_logs: rustfs_utils::LogThrottle,
}
/// Shortest gap between two failure warnings of one kind from one remote disk.
const REMOTE_DISK_RPC_FAILURE_LOG_INTERVAL_MS: u64 = 5_000;
struct RecoveryMonitorLease {
active: Arc<AtomicBool>,
}
@@ -1438,8 +1364,6 @@ impl RemoteDisk {
#[cfg(test)]
recovery_monitor_teardown_hook: Arc::new(tokio::sync::Mutex::new(None)),
data_transport,
timeout_logs: rustfs_utils::LogThrottle::new(REMOTE_DISK_RPC_FAILURE_LOG_INTERVAL_MS),
network_error_logs: rustfs_utils::LogThrottle::new(REMOTE_DISK_RPC_FAILURE_LOG_INTERVAL_MS),
};
record_drive_runtime_state(ep, RuntimeDriveHealthState::Online);
@@ -2166,20 +2090,17 @@ impl RemoteDisk {
"op" => op.to_string()
)
.increment(1);
if let Some(suppressed) = self.timeout_logs.claim() {
warn!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
addr = %self.addr,
op,
timeout_ms = timeout_duration.as_millis(),
suppressed,
state = "timeout",
"Remote disk operation timed out"
);
}
warn!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
addr = %self.addr,
op,
timeout_ms = timeout_duration.as_millis(),
state = "timeout",
"Remote disk operation timed out"
);
}
async fn handle_network_like_error<T>(
@@ -2199,20 +2120,17 @@ impl RemoteDisk {
"op" => op.to_string()
)
.increment(1);
if let Some(suppressed) = self.network_error_logs.claim() {
warn!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
addr = %self.addr,
op,
timeout_ms = timeout_duration.as_millis(),
suppressed,
state = "network_like_error",
"Remote disk operation returned network-like error"
);
}
warn!(
event = EVENT_REMOTE_DISK_RPC,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
endpoint = %self.endpoint,
addr = %self.addr,
op,
timeout_ms = timeout_duration.as_millis(),
state = "network_like_error",
"Remote disk operation returned network-like error"
);
if failure_health_action == FailureHealthAction::MarkFailure {
self.mark_faulty_and_evict("operation_network_error").await;
}
@@ -4455,7 +4373,6 @@ impl DiskAPI for RemoteDisk {
opts: DeleteOptions,
) -> Vec<Option<DiskError>> {
let expected_len = versions.len();
let started = Instant::now();
let result = match self
.recover_delete_transaction(volume, DeleteRecoveryRpcOperation::ApplyVersions { versions, opts })
.await
@@ -4464,7 +4381,6 @@ impl DiskAPI for RemoteDisk {
Ok(_) => Err(Error::other("unexpected recoverable batch delete reply")),
Err(error) => Err(error),
};
log_delete_recovery_batch_result(&self.endpoint, expected_len, started, &result);
match result {
Ok(errors) => errors,
Err(error) => vec![Some(error); expected_len],
@@ -5757,62 +5673,6 @@ mod tests {
assert!(decode_delete_recovery_batch_errors(vec![Some(u32::MAX)], 1).is_err());
}
#[test]
fn recoverable_batch_delete_diagnostics_preserve_context_and_redact_error_details() {
let logs = CapturedLogs::default();
let subscriber = Registry::default().with(
tracing_subscriber::fmt::layer()
.with_writer(logs.clone())
.with_ansi(false)
.without_time()
.json()
.flatten_event(true)
.with_span_list(true),
);
let _guard = tracing::subscriber::set_default(subscriber);
let _callsite_pin = crate::test_tracing::pin_callsite_interest_for_test();
let endpoint = Endpoint {
url: url::Url::parse("http://127.0.0.1:9000/private-disk-path").expect("valid diagnostic endpoint"),
is_local: false,
pool_idx: 1,
set_idx: 2,
disk_idx: 3,
};
let rpc_failure = Err(DiskError::from(Status::deadline_exceeded("private-error-detail")));
let group_failures = Ok(vec![None, Some(DiskError::FileNotFound), Some(DiskError::FileNotFound)]);
tracing::info_span!("request-span", request_id = "req-batch-delete-diagnostics").in_scope(|| {
log_delete_recovery_batch_result(&endpoint, 1000, Instant::now(), &rpc_failure);
log_delete_recovery_batch_result(&endpoint, 3, Instant::now(), &group_failures);
});
let entries = logs.lines();
assert_eq!(entries.len(), 2, "one summary per disk batch, never per object");
assert_eq!(entries[0]["failure_scope"], "rpc_or_reply");
assert_eq!(entries[0]["failed_group_count"], 1000);
assert_eq!(entries[0]["rpc_status"], "Some(DeadlineExceeded)");
assert_eq!(entries[1]["failure_scope"], "object_group");
assert_eq!(entries[1]["failed_group_count"], 2);
for entry in entries {
assert_eq!(entry["pool_index"], 1);
assert_eq!(entry["set_index"], 2);
assert_eq!(entry["disk_index"], 3);
assert!(
entry["spans"]
.as_array()
.expect("request spans recorded")
.iter()
.any(|span| { span["request_id"] == "req-batch-delete-diagnostics" })
);
let text = entry.to_string();
assert!(!text.contains("private-error-detail"));
assert!(!text.contains("private-disk-path"));
}
assert!(rpc_failure.is_err(), "diagnostics borrow and preserve the original failure");
assert_eq!(
group_failures,
Ok(vec![None, Some(DiskError::FileNotFound), Some(DiskError::FileNotFound)])
);
}
#[test]
fn delete_recovery_inspection_rejects_wrong_transaction_object_and_protocol() {
let transaction = Uuid::new_v4();
@@ -11828,66 +11688,6 @@ mod tests {
);
}
/// A peer that fails every operation logs one warning per interval, each
/// operation still gets its own error, and another peer's first warning is
/// not held back by the noisy one.
#[tokio::test]
async fn rpc_failure_warnings_are_bounded_per_remote_disk() {
async fn remote_disk(port: u16) -> RemoteDisk {
let url = url::Url::parse(&format!("http://127.0.0.1:{port}/data")).expect("endpoint url");
let endpoint = Endpoint {
url,
is_local: false,
pool_idx: 0,
set_idx: 0,
disk_idx: 0,
};
RemoteDisk::new(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
Arc::new(TcpHttpInternodeDataTransport),
)
.await
.expect("remote disk handle")
}
let noisy = remote_disk(59981).await;
let quiet = remote_disk(59982).await;
for _ in 0..50 {
let error = noisy
.execute_with_timeout_for_op_and_health_action(
"read_all",
|| async {
Err::<(), Error>(DiskError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"connection refused",
)))
},
Duration::from_secs(1),
FailureHealthAction::IgnoreFailure,
)
.await
.expect_err("every failing operation still fails");
assert!(matches!(error, DiskError::Io(io) if io.kind() == std::io::ErrorKind::ConnectionRefused));
noisy.record_timeout("read_all", Duration::from_secs(1));
}
assert!(
noisy.network_error_logs.claim().is_none(),
"the burst used this peer's network-error window"
);
assert!(noisy.timeout_logs.claim().is_none(), "the burst used this peer's timeout window");
assert_eq!(
quiet.network_error_logs.claim(),
Some(0),
"another peer's first warning is not suppressed"
);
assert_eq!(quiet.timeout_logs.claim(), Some(0));
}
#[tokio::test]
async fn test_execute_with_timeout_keeps_remote_disk_online_for_business_error() {
let addr = "http://127.0.0.1:59994".to_string();
File diff suppressed because it is too large Load Diff
@@ -183,10 +183,6 @@ impl RebuildCursor {
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
// The head is decoded; release the source pin before indexing. On
// Windows the pin denies delete sharing, so holding it would make a
// concurrent abort of this upload fail with a sharing violation.
drop(source);
let source_root = relative
.file_name()
.and_then(|name| name.to_str())
+31 -72
View File
@@ -15,7 +15,7 @@
use crate::disk::error::Error as DiskError;
use crate::erasure::codec::buffer_pool::{get_ec_buffer, return_ec_buffer};
use pin_project_lite::pin_project;
use rustfs_utils::{HashAlgorithm, LogThrottle};
use rustfs_utils::HashAlgorithm;
use std::future::poll_fn;
use std::io::IoSlice;
use std::pin::Pin;
@@ -28,13 +28,6 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_ERASURE: &str = "erasure";
const EVENT_BITROT_SHORT_SHARD_READ: &str = "bitrot_short_shard_read";
const EVENT_BITROT_HASH_MISMATCH: &str = "bitrot_hash_mismatch";
/// A failing drive raises these once per shard block of every read it serves.
/// The events carry no object identity (the caller logs that), so one line per
/// interval with the suppressed count loses nothing but repetition.
const BITROT_FAILURE_LOG_INTERVAL_MS: u64 = 5_000;
static BITROT_SHORT_SHARD_READ_LOGS: LogThrottle = LogThrottle::new(BITROT_FAILURE_LOG_INTERVAL_MS);
static BITROT_HASH_MISMATCH_LOGS: LogThrottle = LogThrottle::new(BITROT_FAILURE_LOG_INTERVAL_MS);
static BITROT_READ_ERROR_LOGS: LogThrottle = LogThrottle::new(BITROT_FAILURE_LOG_INTERVAL_MS);
const MAX_RETAINED_CHUNKS_PER_BLOCK: usize = 64;
const MAX_CHUNK_POLLS_PER_YIELD: usize = MAX_RETAINED_CHUNKS_PER_BLOCK + 1;
@@ -266,41 +259,18 @@ fn reserve_bitrot_scratch(
/// A truncated shard is `UnexpectedEof`, not a short success (backlog#799 B2).
fn short_shard_read(got: usize, want: usize) -> std::io::Error {
if let Some(suppressed) = BITROT_SHORT_SHARD_READ_LOGS.claim() {
error!(
event = EVENT_BITROT_SHORT_SHARD_READ,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_ERASURE,
state = "failed",
got,
want,
suppressed,
"short shard read: got {got} of {want} bytes"
);
}
error!(
event = EVENT_BITROT_SHORT_SHARD_READ,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_ERASURE,
state = "failed",
got,
want,
"short shard read: got {got} of {want} bytes"
);
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, DiskError::FileCorrupt)
}
fn log_hash_mismatch(data_len: usize) {
if let Some(suppressed) = BITROT_HASH_MISMATCH_LOGS.claim() {
error!(
event = EVENT_BITROT_HASH_MISMATCH,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_ERASURE,
state = "failed",
data_len,
suppressed,
"bitrot hash mismatch"
);
}
}
fn log_read_error(error: &std::io::Error) {
if let Some(suppressed) = BITROT_READ_ERROR_LOGS.claim() {
error!(suppressed, "bitrot reader read error: {error}");
}
}
/// Split a `[hash][data]` block, verify the hash (unless `skip_verify`), and
/// return the data slice plus the time spent hashing. The caller writes `data`
/// into its sink **only after** this returns `Ok`, so a shard that fails the
@@ -316,7 +286,14 @@ fn split_and_verify<'a>(hash_algo: &HashAlgorithm, skip_verify: bool, block: &'a
let actual_hash = hash_algo.hash_encode(data);
let verify = verify_start.elapsed();
if actual_hash.as_ref() != hash {
log_hash_mismatch(data.len());
error!(
event = EVENT_BITROT_HASH_MISMATCH,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_ERASURE,
state = "failed",
data_len = data.len(),
"bitrot hash mismatch"
);
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, DiskError::FileCorrupt));
}
Ok((data, verify))
@@ -368,7 +345,7 @@ where
.inner
.read_buf(&mut (&mut *out).limit(remaining))
.await
.inspect_err(log_read_error)?;
.inspect_err(|e| error!("bitrot reader read error: {e}"))?;
if n == 0 {
break;
}
@@ -482,7 +459,14 @@ where
for chunk in block_chunks() {
let take = remaining.min(chunk.len());
if actual_hash.as_ref()[hash_offset..hash_offset + take] != chunk[..take] {
log_hash_mismatch(want);
error!(
event = EVENT_BITROT_HASH_MISMATCH,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_ERASURE,
state = "failed",
data_len = want,
"bitrot hash mismatch"
);
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, DiskError::FileCorrupt));
}
hash_offset += take;
@@ -610,7 +594,10 @@ where
{
let mut filled = 0;
while filled < buf.len() {
let n = reader.read(&mut buf[filled..]).await.inspect_err(log_read_error)?;
let n = reader.read(&mut buf[filled..]).await.map_err(|e| {
error!("bitrot reader read error: {}", e);
e
})?;
if n == 0 {
break;
}
@@ -1271,34 +1258,6 @@ mod tests {
));
}
/// The log line is bounded; the rejection is not. Every damaged block is
/// refused with the same typed error however many came before it.
#[test]
fn repeated_bitrot_failures_are_all_rejected_while_their_log_is_bounded() {
use crate::disk::error::Error as DiskError;
let algo = HashAlgorithm::HighwayHash256;
let data = b"one of many damaged blocks";
let mut frame = algo.hash_encode(data).as_ref().to_vec();
frame.extend_from_slice(data);
*frame.last_mut().expect("test frame has payload") ^= 1;
for _ in 0..1_000 {
let error = super::split_and_verify(&algo, false, &frame).expect_err("every mismatch rejects its shard");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert_eq!(DiskError::from(error), DiskError::FileCorrupt);
let error = super::short_shard_read(3, 4);
assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof);
assert_eq!(DiskError::from(error), DiskError::FileCorrupt);
super::log_read_error(&io::Error::other("drive read failed"));
}
// Each burst used its own window, so a further event of the same kind
// inside the interval is suppressed.
assert!(super::BITROT_HASH_MISMATCH_LOGS.claim().is_none());
assert!(super::BITROT_SHORT_SHARD_READ_LOGS.claim().is_none());
assert!(super::BITROT_READ_ERROR_LOGS.claim().is_none());
}
struct FragmentedSource {
chunks: VecDeque<Bytes>,
}
+5 -39
View File
@@ -894,13 +894,6 @@ fn record_shard_read_result(
}
}
/// A slow or misaligned shard reader is retired once per stripe of every read
/// it lags on. The lines name only a shard index, so one per interval with the
/// suppressed count is enough.
const RETIRED_READER_LOG_INTERVAL_MS: u64 = 5_000;
static ABANDONED_READER_LOGS: rustfs_utils::LogThrottle = rustfs_utils::LogThrottle::new(RETIRED_READER_LOG_INTERVAL_MS);
static UNALIGNED_PARITY_READER_LOGS: rustfs_utils::LogThrottle = rustfs_utils::LogThrottle::new(RETIRED_READER_LOG_INTERVAL_MS);
fn retire_abandoned_readers(errs: &mut [Option<Error>], retire_readers: &mut ShardIndexes, active_readers: &[bool]) {
for (i, active) in active_readers.iter().enumerate() {
if !*active {
@@ -911,9 +904,7 @@ fn retire_abandoned_readers(errs: &mut [Option<Error>], retire_readers: &mut Sha
errs[i] = Some(Error::from(io::Error::new(ErrorKind::TimedOut, "shard read abandoned after read quorum")));
}
retire_readers.push(i);
if let Some(suppressed) = ABANDONED_READER_LOGS.claim() {
warn!(shard_index = i, suppressed, "retiring in-flight shard reader after read quorum");
}
warn!(shard_index = i, "retiring in-flight shard reader after read quorum");
}
}
@@ -2033,12 +2024,10 @@ where
self.engaged[idx] = true;
true
} else {
if let Some(suppressed) = UNALIGNED_PARITY_READER_LOGS.claim() {
warn!(
shard_index = idx,
stripe_index, suppressed, "retiring parity reader that cannot be aligned to the current stripe"
);
}
warn!(
shard_index = idx,
stripe_index, "retiring parity reader that cannot be aligned to the current stripe"
);
self.readers[idx] = None;
false
}
@@ -2855,29 +2844,6 @@ impl Erasure {
#[cfg(test)]
mod tests {
use super::*;
/// The warning is bounded; the retirement is not. Every call retires every
/// reader still in flight and records why, however many calls came before.
#[test]
fn abandoned_readers_are_retired_on_every_call_while_their_log_is_bounded() {
let active_readers = [true, false, true, true];
for _ in 0..1_000 {
let mut errs: Vec<Option<Error>> = vec![None, None, Some(Error::other("already failed")), None];
let mut retire_readers = ShardIndexes::new();
retire_abandoned_readers(&mut errs, &mut retire_readers, &active_readers);
assert_eq!(retire_readers.as_slice(), &[0, 2, 3]);
assert!(errs[1].is_none(), "an inactive reader is left alone");
assert!(errs[0].is_some() && errs[3].is_some(), "an abandoned reader gets its timeout error");
assert!(
errs[2]
.as_ref()
.is_some_and(|error| error.to_string().contains("already failed")),
"an existing error is kept"
);
}
assert!(ABANDONED_READER_LOGS.claim().is_none(), "the burst used the window");
}
use crate::{
disk::error::DiskError,
erasure::coding::{BitrotReader, BitrotWriter},
-24
View File
@@ -2149,15 +2149,6 @@ mod test {
}
}
/// Runs endpoint parsing cases whose local paths do not exist. Unix leaves
/// missing paths out of the physical-disk independence check, but Windows
/// resolves them to their volume, so paths such as `/d1` and `/d2` count as
/// one shared device there. These cases cover parsing, not disk
/// independence, which has its own tests below.
async fn with_disk_independence_check_bypassed<F: std::future::Future<Output = ()>>(cases: F) {
async_with_vars([(ENV_UNSAFE_BYPASS_DISK_CHECK, Some("true"))], cases).await;
}
#[test]
fn endpoint_parse_errors_do_not_echo_url_credentials() {
let err = Endpoints::try_from(["http://:topsecret@server/path"].as_slice()).unwrap_err();
@@ -2166,13 +2157,8 @@ mod test {
assert!(!err.to_string().contains("topsecret"));
}
#[serial]
#[tokio::test]
async fn explicit_local_endpoint_host_selects_only_exact_host_and_server_port() {
with_disk_independence_check_bypassed(explicit_local_endpoint_host_selects_only_exact_host_and_server_port_cases()).await;
}
async fn explicit_local_endpoint_host_selects_only_exact_host_and_server_port_cases() {
let args = vec![
"http://rustfs-0.rustfs-headless.ns.svc.cluster.local./data0",
"http://rustfs-0.rustfs-headless.ns.svc.cluster.local.:9443/data1",
@@ -2935,13 +2921,8 @@ mod test {
}
}
#[serial]
#[tokio::test]
async fn test_create_pool_endpoints() {
with_disk_independence_check_bypassed(test_create_pool_endpoints_cases()).await;
}
async fn test_create_pool_endpoints_cases() {
#[derive(Default)]
struct TestCase<'a> {
num: usize,
@@ -3560,13 +3541,8 @@ mod test {
(urls, local_flags)
}
#[serial]
#[tokio::test]
async fn test_create_server_endpoints() {
with_disk_independence_check_bypassed(test_create_server_endpoints_cases()).await;
}
async fn test_create_server_endpoints_cases() {
let test_cases = [
// Invalid input.
("", vec![], false),
+7 -173
View File
@@ -69,92 +69,6 @@ use super::super::{
use super::bitrot_self_verify::{BitrotSelfVerifyTarget, drop_failed_writer_disks, verify_written_bitrot_shards};
use super::delete_recovery::DeleteRecoveryContext;
const EVENT_SET_DISK_DELETE_OBJECTS_STAGE: &str = "set_disk_delete_objects_stage";
const DELETE_OBJECTS_STAGE_SLOW_MS: u128 = 1_000;
/// Counts use the stage's actual inputs, not failures inherited from an earlier stage.
struct DeleteObjectsStageSummary {
stage: &'static str,
started: Instant,
attempted_object_count: usize,
attempted_group_count: usize,
fence_lost: bool,
failed_object_count: usize,
failed_group_count: usize,
disk_error_counts: std::collections::BTreeMap<u32, usize>,
}
impl DeleteObjectsStageSummary {
fn new(stage: &'static str, attempted_object_count: usize, attempted_group_count: usize) -> Self {
Self {
stage,
started: Instant::now(),
attempted_object_count,
attempted_group_count,
fence_lost: false,
failed_object_count: 0,
failed_group_count: 0,
disk_error_counts: Default::default(),
}
}
fn record_disk_failure(&mut self, error: &DiskError, object_count: usize) {
self.failed_object_count += object_count;
self.failed_group_count += 1;
// Wire codes preserve typed disk causes without exposing error messages or paths.
*self.disk_error_counts.entry(error.to_u32()).or_default() += 1;
}
fn log(self, pool_index: usize, set_index: usize, disk_index: Option<usize>) {
let elapsed_ms = self.started.elapsed().as_millis();
let state = if self.fence_lost { "fence_lost" } else { "completed" };
let result = if self.fence_lost || self.failed_object_count > 0 {
"failed"
} else {
"ok"
};
if self.fence_lost || self.failed_object_count > 0 || elapsed_ms >= DELETE_OBJECTS_STAGE_SLOW_MS {
warn!(
event = EVENT_SET_DISK_DELETE_OBJECTS_STAGE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
state,
result,
stage = self.stage,
pool_index,
set_index,
disk_index,
attempted_object_count = self.attempted_object_count,
attempted_group_count = self.attempted_group_count,
failed_object_count = self.failed_object_count,
failed_group_count = self.failed_group_count,
disk_error_counts = ?self.disk_error_counts,
elapsed_ms,
"Batch delete stage completed"
);
} else {
debug!(
event = EVENT_SET_DISK_DELETE_OBJECTS_STAGE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_SET_DISK,
state,
result,
stage = self.stage,
pool_index,
set_index,
disk_index,
attempted_object_count = self.attempted_object_count,
attempted_group_count = self.attempted_group_count,
failed_object_count = self.failed_object_count,
failed_group_count = self.failed_group_count,
disk_error_counts = ?self.disk_error_counts,
elapsed_ms,
"Batch delete stage completed"
);
}
}
}
#[derive(Default)]
struct DeleteCommitContext<'a> {
incarnation: Option<Uuid>,
@@ -10061,7 +9975,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
let dist_erasure = self.ctx.is_dist_erasure().await;
let mut dist_batch_lock_ids = vec![Vec::new(); self.lockers.len()];
let mut raw_lock_summary = DeleteObjectsStageSummary::new("raw_acquire", objects.len(), unique_lock_count);
if opts.no_lock {
locked_objects = unique_objects;
} else if dist_erasure {
@@ -10101,13 +10014,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
for (i, lock_name) in lock_names.iter().enumerate() {
if let Some(err) = failed_map.get(&(bucket.to_string(), lock_name.to_string())) {
del_errs[i] = Some(Error::other(err.to_string()));
raw_lock_summary.failed_object_count += 1;
}
}
if !opts.no_lock {
raw_lock_summary.failed_group_count = failed_map.len();
raw_lock_summary.log(self.pool_index, self.set_index, None);
}
let outer_namespace_lock_fence = opts.namespace_lock_fence.clone();
let mut transaction_guards = std::collections::BTreeMap::new();
@@ -10115,18 +10023,12 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
// Every raw batch lock is acquired first. Canonical sorting also
// gives aliases of one disk key exactly one transaction owner.
let keys: std::collections::BTreeSet<_> = locked_objects.iter().cloned().collect();
// Batch unresolved keys per peer under one total acquisition budget.
// Confirmed cleanup precedes retries; each key keeps its own quorum
// and failure result at the set layer.
// Bulk delete is the only multi-key transaction-lock holder; one
// batched lock round replaces one quorum round-trip per key and is
// deadlock-free by construction (a single `lock_batch` RPC per lock
// client, global (shard, key) order at each server). Per-key
// failures keep the set layer's per-key error reporting.
let key_names: Vec<&str> = keys.iter().map(|key| key.as_str()).collect();
let mut transaction_summary = DeleteObjectsStageSummary::new(
"transaction_acquire",
lock_names
.iter()
.filter(|name| locked_objects.contains(name.as_ref()))
.count(),
key_names.len(),
);
let acquired = self
.acquire_delete_transaction_locks_batch(bucket, &key_names, super::super::get_lock_acquire_timeout())
.await;
@@ -10137,17 +10039,14 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
transaction_guards.insert(key, guard);
}
Err(error) => {
transaction_summary.failed_group_count += 1;
for (index, lock_name) in lock_names.iter().enumerate() {
if lock_name.as_ref() == key {
del_errs[index] = Some(error.clone());
transaction_summary.failed_object_count += 1;
}
}
}
}
}
transaction_summary.log(self.pool_index, self.set_index, None);
}
let transaction_guards = Arc::new(transaction_guards);
let current_generations = opts.conditional_delete_generations.as_deref();
@@ -10605,18 +10504,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
.as_ref()
.is_some_and(NamespaceLockFence::is_lock_lost)
{
let mut fence_summary = DeleteObjectsStageSummary::new(
"fence_before_prepare",
del_errs.iter().filter(|error| error.is_none()).count(),
0,
);
fence_summary.fence_lost = true;
if dist_erasure {
self.release_dist_delete_object_locks_batch(dist_batch_lock_ids).await;
}
for (index, object) in objects.iter().enumerate() {
if del_errs[index].is_none() {
fence_summary.failed_object_count += 1;
del_errs[index] = Some(Error::NamespaceLockQuorumUnavailable {
mode: "delete_objects_commit",
bucket: bucket.to_string(),
@@ -10626,7 +10518,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
});
}
}
fence_summary.log(self.pool_index, self.set_index, None);
return (del_objects, del_errs, accounting);
}
@@ -10693,9 +10584,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
// let mut errors = Vec::with_capacity(disks.len());
for (disk_index, disk) in disks.iter().enumerate() {
for disk in disks.iter() {
let disk_namespace_owner = namespace_owner.clone();
let group_inputs = &vers;
let vers = vers.clone();
let namespace_lock_fence = opts.namespace_lock_fence.clone();
let lock_lost_during_commit = Arc::clone(&lock_lost_during_commit);
@@ -10704,12 +10594,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
lock_lost_during_commit.store(true, std::sync::atomic::Ordering::Release);
return (0..vers.len()).map(|_| Some(DiskError::DiskOngoingReq)).collect();
}
let mut prepare_summary = DeleteObjectsStageSummary::new(
"prepare",
group_inputs.iter().map(|group| group.versions.len()).sum(),
group_inputs.len(),
);
let errors = if let Some(disk) = disk {
if let Some(disk) = disk {
let options = DeleteOptions {
old_data_dir: Some(rollback_dir),
delete_recovery_bucket_incarnation: opts.expected_bucket_incarnation_id,
@@ -10777,28 +10662,14 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
errs.push(Some(DiskError::DiskNotFound));
}
errs
};
for (error, group) in errors.iter().zip(group_inputs) {
if let Some(error) = error {
prepare_summary.record_disk_failure(error, group.versions.len());
}
}
prepare_summary.log(self.pool_index, self.set_index, Some(disk_index));
errors
});
}
let results = join_all(futures).await;
if transaction_guards.values().any(|guard| guard.is_lock_lost()) {
let mut fence_summary = DeleteObjectsStageSummary::new(
"transaction_fence_after_prepare",
del_errs.iter().filter(|error| error.is_none()).count(),
0,
);
fence_summary.fence_lost = true;
for (index, object) in objects.iter().enumerate() {
if del_errs[index].is_none() {
fence_summary.failed_object_count += 1;
accounting[index] = None;
del_errs[index] = Some(StorageError::NamespaceLockQuorumUnavailable {
mode: "delete_transaction",
@@ -10809,7 +10680,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
});
}
}
fence_summary.log(self.pool_index, self.set_index, None);
if dist_erasure {
self.release_dist_delete_object_locks_batch(dist_batch_lock_ids).await;
}
@@ -10876,15 +10746,8 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
}
if lock_lost_during_commit.load(std::sync::atomic::Ordering::Acquire) {
let mut fence_summary = DeleteObjectsStageSummary::new(
"fence_during_prepare",
del_errs.iter().filter(|error| error.is_none()).count(),
0,
);
fence_summary.fence_lost = true;
for (index, object) in objects.iter().enumerate() {
if del_errs[index].is_none() {
fence_summary.failed_object_count += 1;
del_errs[index] = Some(Error::NamespaceLockQuorumUnavailable {
mode: "delete_objects_commit",
bucket: bucket.to_string(),
@@ -10894,7 +10757,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
});
}
}
fence_summary.log(self.pool_index, self.set_index, None);
}
self.record_capacity_scope_if_needed(opts.capacity_scope_token, &disks);
@@ -10913,11 +10775,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
// Mark all attempted groups before starting publication. A lost
// response can hide a persisted decision and must never roll back.
let groups = groups.collect::<Vec<_>>();
let mut receipt_summary = DeleteObjectsStageSummary::new(
"receipt",
groups.iter().map(|index| vers[*index].versions.len()).sum(),
groups.len(),
);
published_groups.extend(groups.iter().map(|index| vers[*index].name.clone()));
// Journal publish fans out per object per drive; keep enough
// publications in flight to feed the drives' durable-mutation
@@ -10965,13 +10822,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
for (index, result) in publications {
let group = &vers[index];
if let Err(error) = result {
receipt_summary.record_disk_failure(&error, group.versions.len());
for version in &group.versions {
del_errs[version.idx] = Some(to_object_err(error.clone().into(), vec![bucket, &group.name]));
}
}
}
receipt_summary.log(self.pool_index, self.set_index, None);
}
let mut rollback_futures = Vec::new();
let committed_receipt_indices =
@@ -29042,24 +28897,3 @@ mod delete_recovery_inventory_tests {
#[cfg(all(test, feature = "test-util"))]
mod data_movement_prefix_cleanup_tests;
#[cfg(test)]
mod delete_objects_stage_summary_tests {
use super::*;
#[test]
fn batch_delete_diagnostics_count_groups_and_versions_separately() {
let mut summary = DeleteObjectsStageSummary::new("receipt", 6, 3);
summary.record_disk_failure(&DiskError::ErasureWriteQuorum, 3);
summary.record_disk_failure(&DiskError::ErasureWriteQuorum, 2);
assert_eq!(summary.attempted_object_count, 6);
assert_eq!(summary.attempted_group_count, 3);
assert_eq!(summary.failed_object_count, 5);
assert_eq!(summary.failed_group_count, 2);
assert_eq!(
summary.disk_error_counts,
std::collections::BTreeMap::from([(DiskError::ErasureWriteQuorum.to_u32(), 2)])
);
}
}
+8 -36
View File
@@ -2533,7 +2533,6 @@ fn return_batch_delete_lock_error_with_accounting(
}
const EVENT_DETACHED_DELETE_FAILED: &str = "detached_delete_failed";
const EVENT_BULK_DELETE_LOCK_ACQUIRE: &str = "bulk_delete_lock_acquire";
const LOG_SUBSYSTEM_STORE_DELETE: &str = "store_delete";
/// A detached delete only fails to join when it panicked or the runtime is
@@ -3568,10 +3567,12 @@ impl ECStore {
}
/// Acquire the per-object write locks a bulk delete needs before any
/// mutation. Each peer receives one batch of unresolved keys per round;
/// retries share the original deadline and require confirmed cleanup of
/// partial grants. Outcomes remain independent per key so other keys can
/// proceed when a contended key exhausts its budget.
/// mutation, one batched lock round for the whole key set. Bulk delete is
/// the only multi-key write-lock holder, and the batched round is
/// deadlock-free by construction (one `lock_batch` RPC per lock client,
/// global key order at each server), so no optimistic wave or serial
/// fallback is needed. Outcomes are per key: a locked-out key fails alone
/// and the rest of the batch proceeds.
async fn acquire_delete_objects_write_locks(
&self,
bucket: &str,
@@ -3583,7 +3584,6 @@ impl ECStore {
}
let object_names = sorted_unique_delete_object_names(objects);
let acquire_started = Instant::now();
let acquired = self.pools[0].disk_set[0]
.acquire_object_write_locks_batch(bucket, &object_names, get_lock_acquire_timeout())
.await;
@@ -3614,35 +3614,6 @@ impl ECStore {
}
}
}
if failures.is_empty() {
debug!(
event = EVENT_BULK_DELETE_LOCK_ACQUIRE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_DELETE,
result = "success",
stage = "raw_acquire",
requested_objects = objects.len(),
unique_keys = object_names.len(),
successful_keys = guards.len(),
failed_keys = failures.len(),
elapsed_ms = acquire_started.elapsed().as_millis(),
"Bulk delete namespace lock acquisition completed"
);
} else {
warn!(
event = EVENT_BULK_DELETE_LOCK_ACQUIRE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_STORE_DELETE,
result = "partial_failure",
stage = "raw_acquire",
requested_objects = objects.len(),
unique_keys = object_names.len(),
successful_keys = guards.len(),
failed_keys = failures.len(),
elapsed_ms = acquire_started.elapsed().as_millis(),
"Bulk delete namespace lock acquisition failed for some keys"
);
}
opts.no_lock = true;
for signal in guards.iter().filter_map(ObjectLockDiagGuard::lock_lost_signal) {
opts.add_namespace_lock_lost_signal(signal);
@@ -14434,7 +14405,8 @@ mod batch_delete_lock_tests {
let second = run(second);
// Whatever mix of per-key lock outcomes the overlap produces, both
// batches must finish within their acquisition and mutation budgets.
// batches must finish: the batched round is deadlock-free by
// construction (global key order at each lock server).
for batch in [first, second] {
let (_, errors, _) = tokio::time::timeout(Duration::from_secs(30), batch)
.await
+1 -7
View File
@@ -385,15 +385,9 @@ mod tests {
.is_err(),
"a stale worker cannot mutate a recreated bucket"
);
// Name targets as the store's endpoints render them (`C:/...` on Windows),
// not as raw OS paths.
let targets = directories
.iter()
.map(|dir| {
crate::heal::Endpoint::try_from(dir.path().to_str().expect("UTF-8 temp disk path"))
.expect("temp disk endpoint")
.to_string()
})
.map(|dir| dir.path().to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(
storage
+2 -3
View File
@@ -917,10 +917,9 @@ mod tests {
#[test]
fn test_configure_request_development_defaults_require_opt_in() {
let key_dir = std::env::temp_dir().join("kms-key-dir");
let local_raw = serde_json::json!({
"backend_type": "local",
"key_dir": key_dir
"key_dir": "/tmp/kms-key-dir"
});
let request: ConfigureKmsRequest = serde_json::from_value(local_raw).expect("local request should deserialize");
let config = request.to_kms_config();
@@ -928,7 +927,7 @@ mod tests {
let local_opt_in_raw = serde_json::json!({
"backend_type": "local",
"key_dir": key_dir,
"key_dir": "/tmp/kms-key-dir",
"allow_insecure_dev_defaults": true
});
let request: ConfigureKmsRequest = serde_json::from_value(local_opt_in_raw).expect("local request should deserialize");
+1 -5
View File
@@ -1608,14 +1608,10 @@ mod tests {
assert!(config.validate().is_err());
assert!(config.with_insecure_development_defaults().validate().is_ok());
#[cfg(windows)]
let production_key_dir = PathBuf::from(r"C:\rustfs\kms");
#[cfg(not(windows))]
let production_key_dir = PathBuf::from("/var/lib/rustfs/kms");
let production_config = KmsConfig {
backend: KmsBackend::Local,
backend_config: BackendConfig::Local(LocalConfig {
key_dir: production_key_dir,
key_dir: PathBuf::from("/var/lib/rustfs/kms"),
master_key: Some("production-master-key".to_string()),
file_permissions: Some(0o600),
}),
+1 -5
View File
@@ -336,11 +336,7 @@ async fn invalid_configurations_are_rejected_before_anything_starts() {
assert_configuration_error(relative.validate(), "must be an absolute path");
// Production-shaped Local config: no master key is refused without opt-in.
#[cfg(windows)]
let production_key_dir = r"C:\rustfs\kms-behavior";
#[cfg(not(windows))]
let production_key_dir = "/var/lib/rustfs/kms-behavior";
let production_without_master_key = KmsConfig::local(production_key_dir.into());
let production_without_master_key = KmsConfig::local("/var/lib/rustfs/kms-behavior".into());
assert_configuration_error(production_without_master_key.validate(), "requires a master key");
// ...and so is a key directory under the process temp dir.
+140 -376
View File
@@ -143,49 +143,6 @@ struct LocalGuardEntry {
ttl: Duration,
}
/// The shared acquisition algorithm reports facts before either API formats them.
enum LocalAcquireFailure {
Timeout,
Conflict {
current_owner: Arc<str>,
current_mode: crate::LockMode,
},
InvalidResource,
}
impl LocalAcquireFailure {
fn into_response(self, request: &LockRequest) -> LockResponse {
match self {
Self::Timeout => LockResponse::failure("Lock acquisition timeout", request.acquire_timeout),
Self::Conflict {
current_owner,
current_mode,
} => LockResponse::failure(
format!("Lock conflict: resource held by {current_owner} in {current_mode:?} mode"),
Duration::ZERO,
),
Self::InvalidResource => {
LockResponse::failure("Lock id resource does not match the requested resource", Duration::ZERO)
}
}
}
fn into_batch_outcome(self, request: &LockRequest) -> super::BatchAcquireOutcome {
use super::BatchAcquireOutcome;
match self {
Self::Timeout => BatchAcquireOutcome::RetryableNoGrant {
error: crate::LockError::timeout(request.resource.to_string(), request.acquire_timeout),
},
Self::Conflict { current_owner, .. } => BatchAcquireOutcome::RetryableNoGrant {
error: crate::LockError::already_locked(request.resource.to_string(), current_owner.to_string()),
},
Self::InvalidResource => BatchAcquireOutcome::TerminalNoGrant {
error: crate::LockError::invalid_handle(request.lock_id.to_string()),
},
}
}
}
impl LocalGuardEntry {
fn new(guard: FastLockGuard, ttl: Duration) -> Self {
let acquired_at = SystemTime::now();
@@ -214,158 +171,6 @@ impl LocalGuardEntry {
}
impl LocalClient {
async fn acquire_lock_until(
&self,
request: &LockRequest,
acquire_deadline: Instant,
) -> std::result::Result<LockInfo, LocalAcquireFailure> {
let started = Instant::now();
self.ensure_reaper();
if request.lock_id.resource != request.resource {
return Err(LocalAcquireFailure::InvalidResource);
}
let lock_manager = self.get_lock_manager();
let initial_remaining = acquire_deadline.saturating_duration_since(Instant::now());
let reclaimed_before_acquire = if initial_remaining.is_zero() {
0
} else {
timeout(initial_remaining, self.reclaim_expired_guards_for_resource(&request.resource))
.await
.unwrap_or(0)
};
let reclaim_elapsed = started.elapsed();
let build_lock_request = |acquire_timeout| match request.lock_type {
LockType::Exclusive => crate::ObjectLockRequest::new_write(request.resource.clone(), request.owner.clone())
.with_acquire_timeout(acquire_timeout),
LockType::Shared => crate::ObjectLockRequest::new_read(request.resource.clone(), request.owner.clone())
.with_acquire_timeout(acquire_timeout),
};
let mut retried_after_reclaim = reclaimed_before_acquire > 0;
loop {
let remaining = acquire_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(LocalAcquireFailure::Timeout);
}
let manager_started = Instant::now();
// The backend uses its own relative wait; enforce the caller's
// absolute budget across that entire future as well. Dropping an
// uncompleted manager acquisition releases its publication ticket.
let acquired = match timeout_at(acquire_deadline, lock_manager.acquire_lock(build_lock_request(remaining))).await {
Ok(acquired) => acquired,
Err(_) => return Err(LocalAcquireFailure::Timeout),
};
match acquired {
Ok(guard) => {
let manager_elapsed = manager_started.elapsed();
let registration_started = Instant::now();
let physical_guard_id = guard.guard_id();
let lock_id = request.lock_id.clone();
let entry = LocalGuardEntry::new(guard, request.ttl);
let acquired_at = entry.acquired_at;
let expires_at = entry.expires_at;
let (replaced_entry, registration_wait, registration_mutation, registry_entries, registry_capacity) = {
let shard = self.get_shard(&lock_id);
let Ok(mut guards) = timeout_at(acquire_deadline, shard.write()).await else {
return Err(LocalAcquireFailure::Timeout);
};
// A ready table cannot publish a grant after the peer
// budget expired while its acquisition task was idle.
if Instant::now() >= acquire_deadline {
drop(guards);
return Err(LocalAcquireFailure::Timeout);
}
let registration_wait = registration_started.elapsed();
let mutation_started = Instant::now();
let replaced_entry = guards.insert(lock_id.clone(), entry);
(
replaced_entry,
registration_wait,
mutation_started.elapsed(),
guards.len(),
guards.capacity(),
)
};
// Repeated shared requests can replace an entry. Releasing
// its physical guard must not reenter locks under the table.
drop(replaced_entry);
let registration_elapsed = registration_started.elapsed();
if started.elapsed() >= Duration::from_millis(100) {
tracing::trace!(
target: "rustfs_lock::namespace_owner",
event = EVENT_NAMESPACE_LOCK_ACQUIRE,
component = LOG_COMPONENT_LOCK,
subsystem = LOG_SUBSYSTEM_NAMESPACE,
state = "local_completed",
resource = %request.resource,
lock_id = %lock_id,
mode = ?request.lock_type,
physical_guard_id,
reclaim_ms = reclaim_elapsed.as_millis(),
manager_ms = manager_elapsed.as_millis(),
registration_ms = registration_elapsed.as_millis(),
registration_wait_ms = registration_wait.as_millis(),
registration_mutation_ms = registration_mutation.as_millis(),
registry_entries,
registry_capacity,
acquire_ms = started.elapsed().as_millis(),
"Namespace local lock acquisition completed slowly"
);
}
let lock_info = LockInfo {
id: lock_id,
resource: request.resource.clone(),
lock_type: request.lock_type,
status: crate::types::LockStatus::Acquired,
owner: request.owner.clone(),
acquired_at,
expires_at,
last_refreshed: acquired_at,
metadata: request.metadata.clone(),
priority: request.priority,
wait_start_time: None,
};
return Ok(lock_info);
}
Err(crate::fast_lock::LockResult::Timeout) => {
if !retried_after_reclaim {
let remaining = acquire_deadline.saturating_duration_since(Instant::now());
if !remaining.is_zero() {
let _ = timeout(remaining, self.reclaim_expired_guards_for_resource(&request.resource)).await;
}
retried_after_reclaim = true;
continue;
}
return Err(LocalAcquireFailure::Timeout);
}
Err(crate::fast_lock::LockResult::Conflict {
current_owner,
current_mode,
}) => {
if !retried_after_reclaim {
let remaining = acquire_deadline.saturating_duration_since(Instant::now());
if !remaining.is_zero() {
let _ = timeout(remaining, self.reclaim_expired_guards_for_resource(&request.resource)).await;
}
retried_after_reclaim = true;
continue;
}
return Err(LocalAcquireFailure::Conflict {
current_owner,
current_mode,
});
}
Err(crate::fast_lock::LockResult::Acquired) => {
unreachable!("Acquired should not be an error")
}
}
}
}
/// Create new local client with default shard count
pub fn new() -> Self {
Self::with_shard_count(DEFAULT_GUARD_SHARD_COUNT)
@@ -515,29 +320,149 @@ impl Default for LocalClient {
impl LockClient for LocalClient {
async fn acquire_lock(&self, request: &LockRequest) -> Result<LockResponse> {
let started = Instant::now();
let deadline = started.checked_add(request.acquire_timeout).unwrap_or(started);
Ok(match self.acquire_lock_until(request, deadline).await {
Ok(info) => LockResponse::success(info, Duration::ZERO),
Err(failure) => failure.into_response(request),
})
}
self.ensure_reaper();
if request.lock_id.resource != request.resource {
return Ok(LockResponse::failure(
"Lock id resource does not match the requested resource",
Duration::ZERO,
));
}
let acquire_deadline = Instant::now()
.checked_add(request.acquire_timeout)
.unwrap_or_else(Instant::now);
let lock_manager = self.get_lock_manager();
let initial_remaining = acquire_deadline.saturating_duration_since(Instant::now());
let reclaimed_before_acquire = if initial_remaining.is_zero() {
0
} else {
timeout(initial_remaining, self.reclaim_expired_guards_for_resource(&request.resource))
.await
.unwrap_or(0)
};
let reclaim_elapsed = started.elapsed();
async fn acquire_locks_batch_until(&self, requests: &[LockRequest], deadline: Instant) -> Vec<super::BatchAcquireOutcome> {
// Every item shares the entry time, including items first polled later.
let started = Instant::now();
futures::future::join_all(requests.iter().map(|request| {
let item_deadline = deadline.min(started.checked_add(request.acquire_timeout).unwrap_or(started));
async move {
match self.acquire_lock_until(request, item_deadline).await {
Ok(info) => super::BatchAcquireOutcome::Granted {
lock_info: Some(Box::new(info)),
wait_time: Duration::ZERO,
},
Err(failure) => failure.into_batch_outcome(request),
let build_lock_request = |acquire_timeout| match request.lock_type {
LockType::Exclusive => crate::ObjectLockRequest::new_write(request.resource.clone(), request.owner.clone())
.with_acquire_timeout(acquire_timeout),
LockType::Shared => crate::ObjectLockRequest::new_read(request.resource.clone(), request.owner.clone())
.with_acquire_timeout(acquire_timeout),
};
let mut retried_after_reclaim = reclaimed_before_acquire > 0;
loop {
let remaining = acquire_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(LockResponse::failure("Lock acquisition timeout", request.acquire_timeout));
}
let manager_started = Instant::now();
match lock_manager.acquire_lock(build_lock_request(remaining)).await {
Ok(guard) => {
let manager_elapsed = manager_started.elapsed();
let registration_started = Instant::now();
let physical_guard_id = guard.guard_id();
let lock_id = request.lock_id.clone();
let entry = LocalGuardEntry::new(guard, request.ttl);
let acquired_at = entry.acquired_at;
let expires_at = entry.expires_at;
let (replaced_entry, registration_wait, registration_mutation, registry_entries, registry_capacity) = {
let shard = self.get_shard(&lock_id);
let Ok(mut guards) = timeout_at(acquire_deadline, shard.write()).await else {
return Ok(LockResponse::failure("Lock acquisition timeout", request.acquire_timeout));
};
// A ready table cannot publish a grant after the peer
// budget expired while its acquisition task was idle.
if Instant::now() >= acquire_deadline {
drop(guards);
return Ok(LockResponse::failure("Lock acquisition timeout", request.acquire_timeout));
}
let registration_wait = registration_started.elapsed();
let mutation_started = Instant::now();
let replaced_entry = guards.insert(lock_id.clone(), entry);
(
replaced_entry,
registration_wait,
mutation_started.elapsed(),
guards.len(),
guards.capacity(),
)
};
// Repeated shared requests can replace an entry. Releasing
// its physical guard must not reenter locks under the table.
drop(replaced_entry);
let registration_elapsed = registration_started.elapsed();
if started.elapsed() >= Duration::from_millis(100) {
tracing::trace!(
target: "rustfs_lock::namespace_owner",
event = EVENT_NAMESPACE_LOCK_ACQUIRE,
component = LOG_COMPONENT_LOCK,
subsystem = LOG_SUBSYSTEM_NAMESPACE,
state = "local_completed",
resource = %request.resource,
lock_id = %lock_id,
mode = ?request.lock_type,
physical_guard_id,
reclaim_ms = reclaim_elapsed.as_millis(),
manager_ms = manager_elapsed.as_millis(),
registration_ms = registration_elapsed.as_millis(),
registration_wait_ms = registration_wait.as_millis(),
registration_mutation_ms = registration_mutation.as_millis(),
registry_entries,
registry_capacity,
acquire_ms = started.elapsed().as_millis(),
"Namespace local lock acquisition completed slowly"
);
}
let lock_info = LockInfo {
id: lock_id,
resource: request.resource.clone(),
lock_type: request.lock_type,
status: crate::types::LockStatus::Acquired,
owner: request.owner.clone(),
acquired_at,
expires_at,
last_refreshed: acquired_at,
metadata: request.metadata.clone(),
priority: request.priority,
wait_start_time: None,
};
return Ok(LockResponse::success(lock_info, Duration::ZERO));
}
Err(crate::fast_lock::LockResult::Timeout) => {
if !retried_after_reclaim {
let remaining = acquire_deadline.saturating_duration_since(Instant::now());
if !remaining.is_zero() {
let _ = timeout(remaining, self.reclaim_expired_guards_for_resource(&request.resource)).await;
}
retried_after_reclaim = true;
continue;
}
return Ok(LockResponse::failure("Lock acquisition timeout", request.acquire_timeout));
}
Err(crate::fast_lock::LockResult::Conflict {
current_owner,
current_mode,
}) => {
if !retried_after_reclaim {
let remaining = acquire_deadline.saturating_duration_since(Instant::now());
if !remaining.is_zero() {
let _ = timeout(remaining, self.reclaim_expired_guards_for_resource(&request.resource)).await;
}
retried_after_reclaim = true;
continue;
}
return Ok(LockResponse::failure(
format!("Lock conflict: resource held by {current_owner} in {current_mode:?} mode"),
Duration::ZERO,
));
}
Err(crate::fast_lock::LockResult::Acquired) => {
unreachable!("Acquired should not be an error")
}
}
}))
.await
}
}
async fn release(&self, lock_id: &LockId) -> Result<bool> {
@@ -684,167 +609,6 @@ mod tests {
use futures::FutureExt;
use std::task::Poll;
#[tokio::test(start_paused = true)]
async fn typed_batch_preserves_free_key_grants_beside_a_contended_key() {
let client = LocalClient::with_manager(Arc::new(GlobalLockManager::new()));
let hot = crate::ObjectKey::new("bucket", "hot");
let holder = request(hot.clone(), "holder", Duration::from_secs(30));
assert!(client.acquire_lock(&holder).await.expect("hold contended key").success);
let requests = [
request(crate::ObjectKey::new("bucket", "free"), "batch", Duration::from_secs(30)),
request(hot, "batch", Duration::from_secs(30)),
];
let outcomes = client
.acquire_locks_batch_until(&requests, Instant::now() + Duration::from_secs(1))
.await;
assert_eq!(outcomes.len(), 2);
assert!(matches!(outcomes[0], super::super::BatchAcquireOutcome::Granted { .. }));
assert!(matches!(outcomes[1], super::super::BatchAcquireOutcome::RetryableNoGrant { .. }));
assert!(
client
.check_status(&requests[0].lock_id)
.await
.expect("check free grant")
.is_some()
);
assert!(
client
.check_status(&requests[1].lock_id)
.await
.expect("check rejected attempt")
.is_none()
);
assert!(client.release(&holder.lock_id).await.expect("release holder"));
assert!(client.release(&requests[0].lock_id).await.expect("release free key"));
assert!(client.list_lock_leases().await.is_empty());
}
#[tokio::test(start_paused = true)]
async fn typed_batch_manager_wait_obeys_absolute_deadline_without_releasing_holder() {
let manager = Arc::new(GlobalLockManager::new());
let client = LocalClient::with_manager(manager.clone());
let resource = crate::ObjectKey::new("bucket", "manager-deadline");
let holder = manager
.acquire_lock(crate::ObjectLockRequest::new_write(resource.clone(), "holder"))
.await
.expect("hold the physical lock independently of a backend lease");
let requests = [request(resource.clone(), "batch", Duration::from_secs(30)).with_acquire_timeout(Duration::from_secs(5))];
let started = Instant::now();
let deadline = started + Duration::from_millis(100);
let outcomes = timeout_at(deadline + Duration::from_millis(1), client.acquire_locks_batch_until(&requests, deadline))
.await
.expect("the manager wait must finish at the absolute caller deadline");
assert!(started.elapsed() <= Duration::from_millis(100));
assert!(matches!(
outcomes.as_slice(),
[super::super::BatchAcquireOutcome::RetryableNoGrant { .. }]
));
assert!(client.list_lock_leases().await.is_empty(), "no failed attempt is registered");
assert_eq!(
manager
.as_fast_lock_manager()
.expect("enabled manager")
.get_shard(&resource)
.active_guard_count(),
1,
"the original holder is never released by a timed-out attempt"
);
drop(holder);
let successor = manager
.acquire_lock(crate::ObjectLockRequest::new_write(resource.clone(), "successor"))
.await
.expect("no cancelled manager waiter may take the released lock later");
drop(successor);
assert_eq!(
manager
.as_fast_lock_manager()
.expect("enabled manager")
.get_shard(&resource)
.active_guard_count(),
0
);
}
#[tokio::test(start_paused = true)]
async fn typed_batch_rejects_late_poll_without_resetting_the_caller_deadline() {
let client = LocalClient::with_manager(Arc::new(GlobalLockManager::new()));
let requests = [
request(crate::ObjectKey::new("bucket", "late"), "batch", Duration::from_secs(30))
.with_acquire_timeout(Duration::from_secs(5)),
];
let deadline = Instant::now() + Duration::from_millis(20);
let acquire = client.acquire_locks_batch_until(&requests, deadline);
tokio::time::advance(Duration::from_millis(21)).await;
assert!(matches!(
acquire.await.as_slice(),
[super::super::BatchAcquireOutcome::RetryableNoGrant {
error: crate::LockError::Timeout { .. }
}]
));
assert!(client.list_lock_leases().await.is_empty(), "an expired item must never register a grant");
}
#[tokio::test(start_paused = true)]
async fn typed_batch_registration_timeout_drops_the_unpublished_physical_guard() {
let manager = Arc::new(GlobalLockManager::new());
let client = LocalClient::with_manager(manager.clone());
client.reaper_started.store(true, Ordering::Release);
let resource = crate::ObjectKey::new("bucket", "typed-registration-timeout");
let blocker = manager
.acquire_lock(crate::ObjectLockRequest::new_write(resource.clone(), "holder"))
.await
.expect("hold physical lock until preflight completes");
let requests = [request(resource.clone(), "batch", Duration::from_secs(30)).with_acquire_timeout(Duration::from_secs(5))];
let deadline = Instant::now() + Duration::from_millis(20);
let mut acquire = Box::pin(tokio::task::unconstrained(client.acquire_locks_batch_until(&requests, deadline)));
assert!(matches!(futures::poll!(&mut acquire), Poll::Pending));
let (release_table, table_owner) = hold_guard_shard(client.get_shard(&requests[0].lock_id).clone());
drop(blocker);
assert!(matches!(futures::poll!(&mut acquire), Poll::Pending));
assert_eq!(
manager
.as_fast_lock_manager()
.expect("enabled manager")
.get_shard(&resource)
.active_guard_count(),
1
);
tokio::time::advance(Duration::from_millis(21)).await;
let result = acquire.await;
assert!(matches!(result.as_slice(), [super::super::BatchAcquireOutcome::RetryableNoGrant { .. }]));
assert_eq!(
manager
.as_fast_lock_manager()
.expect("enabled manager")
.get_shard(&resource)
.active_guard_count(),
0,
"NoGrant must only be returned after the unpublished physical guard is dropped"
);
release_table.send(()).expect("release table");
table_owner.join().expect("table owner finishes");
assert!(client.list_lock_leases().await.is_empty());
}
#[test]
fn legacy_acquisition_messages_remain_compatible() {
let request = request(crate::ObjectKey::new("bucket", "legacy"), "owner", Duration::from_secs(30));
assert_eq!(
LocalAcquireFailure::Timeout.into_response(&request).error.as_deref(),
Some("Lock acquisition timeout")
);
assert_eq!(
LocalAcquireFailure::Conflict {
current_owner: Arc::from("holder"),
current_mode: crate::LockMode::Exclusive
}
.into_response(&request)
.error
.as_deref(),
Some("Lock conflict: resource held by holder in Exclusive mode")
);
}
fn request(resource: crate::ObjectKey, owner: &str, ttl: Duration) -> LockRequest {
LockRequest::new(resource, LockType::Exclusive, owner)
.with_ttl(ttl)
+1 -115
View File
@@ -15,49 +15,10 @@
pub mod local;
// pub mod remote;
use crate::{LockError, LockId, LockInfo, LockLeaseInfo, LockRequest, LockResponse, LockStats, Result};
use crate::{LockId, LockInfo, LockLeaseInfo, LockRequest, LockResponse, LockStats, Result};
use async_trait::async_trait;
use futures::future::join_all;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::Instant;
/// A batch peer's result for one request, in the original request order.
#[derive(Clone, Debug)]
pub enum BatchAcquireOutcome {
/// The peer confirmed a grant. Missing legacy metadata uses the requested ID.
Granted {
lock_info: Option<Box<LockInfo>>,
wait_time: Duration,
},
/// This attempt cannot grant later, and another attempt may succeed.
RetryableNoGrant { error: LockError },
/// This attempt cannot grant later, and this peer must not be retried.
TerminalNoGrant { error: LockError },
/// A grant may exist or arrive later. Stop using this peer for this key.
Ambiguous {
error: LockError,
/// The client already retains exact-ID cleanup until the attempt settles.
/// Otherwise the coordinator must retain uncertain cleanup ownership.
cleanup_owned_by_client: bool,
},
}
impl BatchAcquireOutcome {
fn from_legacy_response(response: LockResponse) -> Self {
if response.success {
Self::Granted {
lock_info: response.lock_info.map(Box::new),
wait_time: response.wait_time,
}
} else {
Self::Ambiguous {
error: LockError::internal(response.error.unwrap_or_else(|| "Legacy batch acquisition failed".to_owned())),
cleanup_owned_by_client: false,
}
}
}
}
/// Outcome of cleaning up a lock whose acquisition has already completed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -84,45 +45,6 @@ pub trait LockClient: Send + Sync + std::fmt::Debug {
.collect::<Result<Vec<_>>>()?)
}
/// Acquire a batch within the caller's absolute deadline, preserving per-key outcomes.
///
/// Legacy clients cannot prove that failed or missing responses did not grant.
/// Their calls retain completion ownership in the coordinator if the caller
/// stops waiting. Implementations may narrow each request's backend wait;
/// they must never reset the deadline after connection or queue delays.
async fn acquire_locks_batch_until(&self, requests: &[LockRequest], deadline: Instant) -> Vec<BatchAcquireOutcome> {
if Instant::now() >= deadline {
return requests
.iter()
.map(|request| BatchAcquireOutcome::RetryableNoGrant {
error: LockError::timeout(request.resource.to_string(), request.acquire_timeout),
})
.collect();
}
match self.acquire_locks_batch(requests).await {
Ok(responses) => {
let mut responses = responses.into_iter();
requests
.iter()
.map(|_| match responses.next() {
Some(response) => BatchAcquireOutcome::from_legacy_response(response),
None => BatchAcquireOutcome::Ambiguous {
error: LockError::internal("Legacy peer returned an incomplete batch response"),
cleanup_owned_by_client: false,
},
})
.collect()
}
Err(error) => requests
.iter()
.map(|_| BatchAcquireOutcome::Ambiguous {
error: error.clone(),
cleanup_owned_by_client: false,
})
.collect(),
}
}
/// Release lock
async fn release(&self, lock_id: &LockId) -> Result<bool>;
@@ -200,39 +122,3 @@ impl ClientFactory {
// Arc::new(remote::RemoteClient::new(endpoint))
// }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_failure_text_never_proves_no_grant() {
for message in [
"Lock acquisition timeout",
"Lock conflict: resource held by holder",
"Remote lock RPC timed out",
] {
assert!(matches!(
BatchAcquireOutcome::from_legacy_response(LockResponse::failure(message, Duration::ZERO)),
BatchAcquireOutcome::Ambiguous {
cleanup_owned_by_client: false,
..
}
));
}
}
#[test]
fn legacy_success_without_metadata_remains_a_grant() {
assert!(matches!(
BatchAcquireOutcome::from_legacy_response(LockResponse {
success: true,
lock_info: None,
error: None,
wait_time: Duration::ZERO,
position_in_queue: None,
}),
BatchAcquireOutcome::Granted { lock_info: None, .. }
));
}
}
File diff suppressed because it is too large Load Diff
@@ -1,451 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{DistributedLock, LockBatchAcquireRound};
use crate::{
GlobalLockManager, LocalClient, LockCleanupOutcome, LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats,
LockType, ObjectKey, client::LockClient,
};
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::time::Duration;
use tokio::sync::{Notify, Semaphore};
use tokio::task::JoinSet;
#[derive(Debug)]
struct ObservedBatchClient {
backend: LocalClient,
deny_acquire: bool,
acquire_gate: Option<Arc<Semaphore>>,
cleanup_gate: Option<Arc<Semaphore>>,
granted: AtomicUsize,
grant_changed: Notify,
released: AtomicUsize,
release_changed: Notify,
cleanup_entered: Notify,
cleanup_calls: AtomicUsize,
cleanup_changed: Notify,
cleanup_ids: Mutex<Vec<LockId>>,
omit_lock_info: AtomicBool,
lose_release_ack: AtomicBool,
release_ack_gate: Semaphore,
}
impl ObservedBatchClient {
fn new(deny_acquire: bool, acquire_gate: Option<Arc<Semaphore>>, cleanup_gate: Option<Arc<Semaphore>>) -> Arc<Self> {
Arc::new(Self {
backend: LocalClient::with_manager(Arc::new(GlobalLockManager::new())),
deny_acquire,
acquire_gate,
cleanup_gate,
granted: AtomicUsize::new(0),
grant_changed: Notify::new(),
released: AtomicUsize::new(0),
release_changed: Notify::new(),
cleanup_entered: Notify::new(),
cleanup_calls: AtomicUsize::new(0),
cleanup_changed: Notify::new(),
cleanup_ids: Mutex::new(Vec::new()),
omit_lock_info: AtomicBool::new(false),
lose_release_ack: AtomicBool::new(false),
release_ack_gate: Semaphore::new(0),
})
}
async fn wait_for_count(counter: &AtomicUsize, changed: &Notify, expected: usize) {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let notified = changed.notified();
if counter.load(Ordering::SeqCst) >= expected {
return;
}
notified.await;
}
})
.await
.expect("expected real backend grant/release event before the test deadline");
}
async fn wait_for_grants(&self, expected: usize) {
Self::wait_for_count(&self.granted, &self.grant_changed, expected).await;
}
async fn wait_for_releases(&self, expected: usize) {
Self::wait_for_count(&self.released, &self.release_changed, expected).await;
}
}
#[async_trait::async_trait]
impl LockClient for ObservedBatchClient {
async fn acquire_lock(&self, request: &LockRequest) -> crate::Result<LockResponse> {
if self.deny_acquire {
return Ok(LockResponse::failure("injected peer refusal", Duration::ZERO));
}
if let Some(gate) = &self.acquire_gate {
gate.acquire().await.expect("test acquisition gate remains open").forget();
}
let mut response = self.backend.acquire_lock(request).await?;
if response.success {
self.granted.fetch_add(1, Ordering::SeqCst);
self.grant_changed.notify_one();
if self.omit_lock_info.load(Ordering::SeqCst) {
response.lock_info = None;
}
}
Ok(response)
}
async fn cleanup_grant(&self, lock_id: &LockId) -> crate::Result<LockCleanupOutcome> {
self.cleanup_ids
.lock()
.expect("test cleanup ID log is available")
.push(lock_id.clone());
self.cleanup_calls.fetch_add(1, Ordering::SeqCst);
self.cleanup_changed.notify_one();
self.cleanup_entered.notify_one();
if let Some(gate) = &self.cleanup_gate {
gate.acquire().await.expect("test cleanup gate remains open").forget();
}
let outcome = self.backend.cleanup_grant(lock_id).await?;
if outcome == LockCleanupOutcome::Released {
self.released.fetch_add(1, Ordering::SeqCst);
self.release_changed.notify_one();
if self.lose_release_ack.swap(false, Ordering::SeqCst) {
self.release_ack_gate
.acquire()
.await
.expect("test ACK gate remains open")
.forget();
return Err(LockError::internal("injected lost release ACK"));
}
}
Ok(outcome)
}
async fn release(&self, lock_id: &LockId) -> crate::Result<bool> {
self.cleanup_grant(lock_id)
.await
.map(|outcome| outcome == LockCleanupOutcome::Released)
}
async fn refresh(&self, lock_id: &LockId) -> crate::Result<bool> {
self.backend.refresh(lock_id).await
}
async fn force_release(&self, lock_id: &LockId) -> crate::Result<bool> {
self.backend.force_release(lock_id).await
}
async fn check_status(&self, lock_id: &LockId) -> crate::Result<Option<LockInfo>> {
self.backend.check_status(lock_id).await
}
async fn get_stats(&self) -> crate::Result<LockStats> {
self.backend.get_stats().await
}
async fn close(&self) -> crate::Result<()> {
self.backend.close().await
}
async fn is_online(&self) -> bool {
true
}
async fn is_local(&self) -> bool {
true
}
}
#[tokio::test(start_paused = true)]
async fn cancelled_batch_acquisition_cleans_completed_and_late_grants() {
let late_gate = Arc::new(Semaphore::new(0));
let completed = ObservedBatchClient::new(false, None, None);
let late = ObservedBatchClient::new(false, Some(late_gate.clone()), None);
let lock = DistributedLock::new("cancelled-batch".to_string(), vec![completed.clone(), late.clone()], 2);
let resources = (0..1000)
.map(|key| ObjectKey::new("cancelled-batch", format!("key-{key}")))
.collect::<Vec<_>>();
let operation = tokio::spawn(async move {
lock.acquire_guard_batch(&resources, "owner", Duration::from_secs(5), Duration::from_secs(30))
.await
});
completed.wait_for_grants(1000).await;
assert_eq!(completed.backend.list_lock_leases().await.len(), 1000);
assert!(!operation.is_finished(), "the other peer still cannot complete its batch");
operation.abort();
assert!(operation.await.expect_err("caller was cancelled").is_cancelled());
late_gate.add_permits(1000);
completed.wait_for_releases(1000).await;
late.wait_for_releases(1000).await;
assert!(completed.backend.list_lock_leases().await.is_empty());
assert!(late.backend.list_lock_leases().await.is_empty());
}
#[tokio::test(start_paused = true)]
async fn cancelled_batch_rollback_keeps_its_cleanup_owner() {
let cleanup_gate = Arc::new(Semaphore::new(0));
let granted = ObservedBatchClient::new(false, None, Some(cleanup_gate.clone()));
let refused = ObservedBatchClient::new(true, None, None);
let lock = DistributedLock::new("cancelled-rollback".to_string(), vec![granted.clone(), refused], 2);
let operation = tokio::spawn(async move {
lock.acquire_guard_batch(
&[ObjectKey::new("cancelled-rollback", "object")],
"owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await
});
tokio::time::timeout(Duration::from_secs(1), granted.cleanup_entered.notified())
.await
.expect("below-quorum rollback entered the real grant cleanup");
assert_eq!(granted.backend.list_lock_leases().await.len(), 1);
operation.abort();
assert!(
operation
.await
.expect_err("caller was cancelled during rollback")
.is_cancelled()
);
cleanup_gate.add_permits(1);
granted.wait_for_releases(1).await;
assert!(granted.backend.list_lock_leases().await.is_empty());
}
#[tokio::test(start_paused = true)]
async fn cancelled_batch_round_drains_ready_unjoined_replies_with_original_ids() {
for omit_lock_info in [false, true] {
let first = ObservedBatchClient::new(false, None, None);
let second = ObservedBatchClient::new(false, None, None);
first.omit_lock_info.store(omit_lock_info, Ordering::SeqCst);
second.omit_lock_info.store(omit_lock_info, Ordering::SeqCst);
let clients: Arc<[Arc<dyn LockClient>]> = vec![first.clone() as Arc<dyn LockClient>, second.clone()].into();
let requests: Arc<[LockRequest]> = vec![
LockRequest::new(ObjectKey::new("ready-batch", "object"), LockType::Exclusive, "owner")
.with_acquire_timeout(Duration::from_secs(5))
.with_ttl(Duration::from_secs(30)),
]
.into();
let mut round = LockBatchAcquireRound {
pending: JoinSet::new(),
grants: vec![Vec::new()],
clients,
span: tracing::Span::current(),
};
let mut tasks = Vec::new();
for (peer_index, client) in round.clients.iter().enumerate() {
let client = client.clone();
let requests = requests.clone();
tasks.push(round.pending.spawn(async move {
let started = tokio::time::Instant::now();
let outcomes = client
.acquire_locks_batch_until(&requests, started + Duration::from_secs(5))
.await;
super::LockBatchAcquireTaskResult {
peer_index,
key_indices: vec![0],
requests,
outcomes,
started,
elapsed: Duration::ZERO,
}
}));
}
first.wait_for_grants(1).await;
second.wait_for_grants(1).await;
assert!(tasks.iter().all(tokio::task::AbortHandle::is_finished));
assert_eq!(round.pending.len(), 2, "both ready replies remain unjoined");
assert!(round.grants.iter().all(Vec::is_empty), "no reply has been transferred yet");
drop(round);
first.wait_for_releases(1).await;
second.wait_for_releases(1).await;
assert!(first.backend.list_lock_leases().await.is_empty());
assert!(second.backend.list_lock_leases().await.is_empty());
}
}
#[tokio::test(start_paused = true)]
async fn cancelled_batch_preserves_a_locally_partially_completed_batch() {
let client = ObservedBatchClient::new(false, None, None);
let blocked = ObjectKey::new("local-partial-batch", "blocked");
let blocker = LockRequest::new(blocked.clone(), LockType::Exclusive, "blocker")
.with_acquire_timeout(Duration::from_secs(5))
.with_ttl(Duration::from_secs(30));
assert!(
client
.backend
.acquire_lock(&blocker)
.await
.expect("hold one local key")
.success
);
let lock = DistributedLock::new("local-partial-batch".to_string(), vec![client.clone()], 1);
let operation = tokio::spawn(async move {
lock.acquire_guard_batch(
&[
blocked,
ObjectKey::new("local-partial-batch", "free-one"),
ObjectKey::new("local-partial-batch", "free-two"),
],
"batch-owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await
});
client.wait_for_grants(2).await;
assert_eq!(client.backend.list_lock_leases().await.len(), 3, "blocker plus two partial grants");
assert!(!operation.is_finished());
operation.abort();
assert!(
operation
.await
.expect_err("cancel the partially completed batch")
.is_cancelled()
);
assert_eq!(
client
.backend
.cleanup_grant(&blocker.lock_id)
.await
.expect("release the original holder"),
LockCleanupOutcome::Released
);
client.wait_for_grants(3).await;
client.wait_for_releases(3).await;
assert!(client.backend.list_lock_leases().await.is_empty());
}
#[tokio::test(start_paused = true)]
async fn successful_batch_guards_retain_their_real_grants_until_drop() {
let client = ObservedBatchClient::new(false, None, None);
let lock = DistributedLock::new("successful-batch".to_string(), vec![client.clone()], 1);
let results = lock
.acquire_guard_batch(
&[
ObjectKey::new("successful-batch", "one"),
ObjectKey::new("successful-batch", "two"),
],
"owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await;
assert!(results.iter().all(Result::is_ok));
assert_eq!(client.backend.list_lock_leases().await.len(), 2);
assert_eq!(client.cleanup_calls.load(Ordering::SeqCst), 0, "round drop cannot clean live guards");
drop(results);
client.wait_for_releases(2).await;
assert!(client.backend.list_lock_leases().await.is_empty());
}
#[tokio::test(start_paused = true)]
async fn batch_guard_and_rollback_use_the_request_id_when_lock_info_is_missing() {
for deny_other_peer in [false, true] {
let granted = ObservedBatchClient::new(false, None, None);
granted.omit_lock_info.store(true, Ordering::SeqCst);
let other = ObservedBatchClient::new(deny_other_peer, None, None);
let lock = DistributedLock::new("missing-batch-info".to_string(), vec![granted.clone(), other.clone()], 2);
let results = lock
.acquire_guard_batch(
&[ObjectKey::new("missing-batch-info", "object")],
"owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await;
assert_eq!(results[0].is_err(), deny_other_peer);
if !deny_other_peer {
assert_eq!(granted.backend.list_lock_leases().await.len(), 1);
assert_eq!(granted.cleanup_calls.load(Ordering::SeqCst), 0);
}
drop(results);
granted.wait_for_releases(1).await;
if !deny_other_peer {
other.wait_for_releases(1).await;
}
assert!(granted.backend.list_lock_leases().await.is_empty());
assert!(other.backend.list_lock_leases().await.is_empty());
}
}
#[tokio::test(start_paused = true)]
async fn cancelled_batch_rollback_retries_lost_ack_without_releasing_a_successor() {
let granted = ObservedBatchClient::new(false, None, None);
granted.lose_release_ack.store(true, Ordering::SeqCst);
let refused = ObservedBatchClient::new(true, None, None);
let resource = ObjectKey::new("batch-lost-ack", "object");
let lock = DistributedLock::new("batch-lost-ack".to_string(), vec![granted.clone(), refused], 2);
let acquire_resource = resource.clone();
let operation = tokio::spawn(async move {
lock.acquire_guard_batch(&[acquire_resource], "predecessor", Duration::from_secs(5), Duration::from_secs(30))
.await
});
granted.wait_for_releases(1).await;
let successor = LockRequest::new(resource, LockType::Exclusive, "successor")
.with_acquire_timeout(Duration::from_secs(5))
.with_ttl(Duration::from_secs(30));
assert!(
granted
.backend
.acquire_lock(&successor)
.await
.expect("successor acquires after physical release")
.success
);
operation.abort();
assert!(
operation
.await
.expect_err("cancel caller while release ACK is blocked")
.is_cancelled()
);
granted.release_ack_gate.add_permits(1);
ObservedBatchClient::wait_for_count(&granted.cleanup_calls, &granted.cleanup_changed, 2).await;
let cleanup_ids = granted.cleanup_ids.lock().expect("cleanup ID log").clone();
assert!(cleanup_ids.len() >= 2, "the uncertain ACK must be retried");
assert!(
cleanup_ids.iter().all(|id| id == &cleanup_ids[0]),
"cleanup retains the exact predecessor ID"
);
assert_ne!(cleanup_ids[0], successor.lock_id);
assert!(
granted
.backend
.check_status(&successor.lock_id)
.await
.expect("check successor")
.is_some()
);
assert_eq!(granted.backend.list_lock_leases().await.len(), 1);
assert_eq!(
granted
.backend
.cleanup_grant(&successor.lock_id)
.await
.expect("clean successor test grant"),
LockCleanupOutcome::Released
);
}
@@ -1,898 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{BatchLockPeerDiagnostics, DistributedLock};
use crate::{
BatchAcquireOutcome, GlobalLockManager, LocalClient, LockCleanupOutcome, LockError, LockId, LockInfo, LockRequest,
LockResponse, LockStats, ObjectKey, client::LockClient,
};
use std::{
collections::VecDeque,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
time::Duration,
};
use tokio::{
sync::{Notify, Semaphore},
time::Instant,
};
#[derive(Clone, Debug)]
enum Plan {
Acquire,
Retryable,
Terminal,
Ambiguous,
ReplyGate(Arc<Semaphore>),
}
#[derive(Debug)]
struct TypedClient {
backend: LocalClient,
plans: Mutex<VecDeque<Plan>>,
calls: Mutex<Vec<Vec<LockId>>>,
deadlines: Mutex<Vec<Instant>>,
changed: Notify,
grants: AtomicUsize,
cleanup_calls: AtomicUsize,
released: AtomicUsize,
refreshes: AtomicUsize,
refresh_allowed: AtomicBool,
cleanup_failures: AtomicUsize,
cleanup_gate: Option<Arc<Semaphore>>,
cleanup_key_gate: Mutex<Option<(ObjectKey, Arc<Semaphore>)>>,
local: bool,
local_gate: Mutex<Option<Arc<Semaphore>>>,
}
impl TypedClient {
fn new(plans: Vec<Plan>, local: bool, cleanup_gate: Option<Arc<Semaphore>>) -> Arc<Self> {
Arc::new(Self {
backend: LocalClient::with_manager(Arc::new(GlobalLockManager::new())),
plans: Mutex::new(plans.into()),
calls: Mutex::new(Vec::new()),
deadlines: Mutex::new(Vec::new()),
changed: Notify::new(),
grants: AtomicUsize::new(0),
cleanup_calls: AtomicUsize::new(0),
released: AtomicUsize::new(0),
refreshes: AtomicUsize::new(0),
refresh_allowed: AtomicBool::new(true),
cleanup_failures: AtomicUsize::new(0),
cleanup_gate,
cleanup_key_gate: Mutex::new(None),
local,
local_gate: Mutex::new(None),
})
}
async fn wait_until(&self, predicate: impl Fn() -> bool) {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let notified = self.changed.notified();
if predicate() {
return;
}
notified.await;
}
})
.await
.expect("expected a real acquisition or cleanup event");
}
async fn wait_calls(&self, count: usize) {
self.wait_until(|| self.calls.lock().expect("call log").len() >= count).await;
}
async fn wait_grants(&self, count: usize) {
self.wait_until(|| self.grants.load(Ordering::SeqCst) >= count).await;
}
async fn wait_releases(&self, count: usize) {
self.wait_until(|| self.released.load(Ordering::SeqCst) >= count).await;
}
}
#[async_trait::async_trait]
impl LockClient for TypedClient {
async fn acquire_lock(&self, request: &LockRequest) -> crate::Result<LockResponse> {
self.backend.acquire_lock(request).await
}
async fn acquire_locks_batch_until(&self, requests: &[LockRequest], deadline: Instant) -> Vec<BatchAcquireOutcome> {
self.calls
.lock()
.expect("call log")
.push(requests.iter().map(|request| request.lock_id.clone()).collect());
self.deadlines.lock().expect("deadline log").push(deadline);
self.changed.notify_one();
let plan = self.plans.lock().expect("plan queue").pop_front().unwrap_or(Plan::Acquire);
match plan {
Plan::Retryable => requests
.iter()
.map(|_| BatchAcquireOutcome::RetryableNoGrant {
error: LockError::internal("explicit no grant"),
})
.collect(),
Plan::Terminal => requests
.iter()
.map(|_| BatchAcquireOutcome::TerminalNoGrant {
error: LockError::permission_denied("explicit refusal"),
})
.collect(),
Plan::Ambiguous => requests
.iter()
.map(|_| BatchAcquireOutcome::Ambiguous {
error: LockError::internal("unknown outcome"),
cleanup_owned_by_client: true,
})
.collect(),
Plan::Acquire | Plan::ReplyGate(_) => {
let outcomes = self.backend.acquire_locks_batch_until(requests, deadline).await;
self.grants.fetch_add(
outcomes
.iter()
.filter(|outcome| matches!(outcome, BatchAcquireOutcome::Granted { .. }))
.count(),
Ordering::SeqCst,
);
self.changed.notify_one();
if let Plan::ReplyGate(gate) = plan {
gate.acquire().await.expect("response gate").forget();
}
outcomes
}
}
}
async fn cleanup_grant(&self, id: &LockId) -> crate::Result<LockCleanupOutcome> {
self.cleanup_calls.fetch_add(1, Ordering::SeqCst);
self.changed.notify_one();
if let Some(gate) = &self.cleanup_gate {
gate.acquire().await.expect("cleanup gate").forget();
}
let key_gate = self
.cleanup_key_gate
.lock()
.expect("key cleanup gate")
.as_ref()
.filter(|(key, _)| *key == id.resource)
.map(|(_, gate)| gate.clone());
if let Some(gate) = key_gate {
gate.acquire().await.expect("key cleanup gate").forget();
}
if self
.cleanup_failures
.try_update(Ordering::SeqCst, Ordering::SeqCst, |count| count.checked_sub(1))
.is_ok()
{
return Err(LockError::internal("injected cleanup failure"));
}
let result = self.backend.cleanup_grant(id).await?;
if result == LockCleanupOutcome::Released {
self.released.fetch_add(1, Ordering::SeqCst);
self.changed.notify_one();
}
Ok(result)
}
async fn release(&self, id: &LockId) -> crate::Result<bool> {
self.cleanup_grant(id)
.await
.map(|outcome| outcome == LockCleanupOutcome::Released)
}
async fn refresh(&self, id: &LockId) -> crate::Result<bool> {
self.refreshes.fetch_add(1, Ordering::SeqCst);
self.changed.notify_one();
if self.refresh_allowed.load(Ordering::SeqCst) {
self.backend.refresh(id).await
} else {
Ok(false)
}
}
async fn force_release(&self, id: &LockId) -> crate::Result<bool> {
self.backend.force_release(id).await
}
async fn check_status(&self, id: &LockId) -> crate::Result<Option<LockInfo>> {
self.backend.check_status(id).await
}
async fn get_stats(&self) -> crate::Result<LockStats> {
self.backend.get_stats().await
}
async fn close(&self) -> crate::Result<()> {
self.backend.close().await
}
async fn is_online(&self) -> bool {
true
}
async fn is_local(&self) -> bool {
let gate = self.local_gate.lock().expect("local lookup gate").take();
if let Some(gate) = gate {
gate.acquire().await.expect("local lookup gate open").forget();
}
self.local
}
}
fn lock_for(clients: &[Arc<TypedClient>], quorum: usize) -> DistributedLock {
DistributedLock::new(
"batch-retry".to_owned(),
clients.iter().cloned().map(|client| client as Arc<dyn LockClient>).collect(),
quorum,
)
}
#[tokio::test(start_paused = true)]
async fn healthy_thousand_key_batch_keeps_one_call_per_peer() {
let peers: Vec<_> = (0..4).map(|_| TypedClient::new(Vec::new(), true, None)).collect();
let resources: Vec<_> = (0..1000)
.map(|index| ObjectKey::new("healthy-batch", format!("{index}")))
.collect();
let results = lock_for(&peers, 3)
.acquire_guard_batch(&resources, "owner", Duration::from_secs(5), Duration::from_secs(30))
.await;
assert!(results.iter().all(Result::is_ok));
for peer in &peers {
peer.wait_calls(1).await;
let calls = peer.calls.lock().expect("call log");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].len(), 1000);
}
drop(results);
}
#[tokio::test(start_paused = true)]
async fn third_pending_vote_is_waited_for_even_with_an_ambiguous_peer() {
for before_deadline in [true, false] {
let gate = Arc::new(Semaphore::new(0));
let peers = vec![
TypedClient::new(Vec::new(), true, None),
TypedClient::new(Vec::new(), true, None),
TypedClient::new(vec![Plan::ReplyGate(gate.clone())], false, None),
TypedClient::new(vec![Plan::Ambiguous], false, None),
];
let lock = lock_for(&peers, 3);
let start = Instant::now();
let operation = tokio::spawn(async move {
lock.acquire_guard_batch(
&[ObjectKey::new("pending-third", "key")],
"owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await
});
for peer in &peers[..3] {
peer.wait_grants(1).await;
}
assert!(!operation.is_finished(), "two votes do not discard a pending third vote");
if before_deadline {
gate.add_permits(1);
let result = operation.await.expect("batch task");
assert!(result[0].is_ok());
assert!(start.elapsed() < Duration::from_secs(5));
drop(result);
} else {
tokio::time::advance(Duration::from_secs(5)).await;
let result = operation.await.expect("deadline returns without the delayed reply");
assert!(result[0].is_err());
gate.add_permits(1);
}
for peer in &peers[..3] {
peer.wait_releases(1).await;
assert!(peer.backend.list_lock_leases().await.is_empty());
}
}
}
#[tokio::test(start_paused = true)]
async fn ambiguous_peer_is_excluded_and_confirmed_subset_retries_with_fresh_ids() {
for terminal in [false, true] {
let peers = vec![
TypedClient::new(vec![Plan::Ambiguous], false, None),
TypedClient::new(Vec::new(), true, None),
TypedClient::new(Vec::new(), true, None),
TypedClient::new(vec![if terminal { Plan::Terminal } else { Plan::Retryable }], true, None),
];
peers[1].cleanup_failures.store(1, Ordering::SeqCst);
let result = lock_for(&peers, 3)
.acquire_guard_batch(
&[ObjectKey::new("subset-retry", "key")],
"owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await;
assert_eq!(result[0].is_err(), terminal);
assert_eq!(
peers[0].calls.lock().expect("ambiguous call log").len(),
1,
"uncertain peer never receives another attempt"
);
for peer in &peers[1..] {
let calls = peer.calls.lock().expect("healthy call log");
assert_eq!(calls.len(), if terminal { 1 } else { 2 });
if !terminal {
assert_ne!(calls[0][0], calls[1][0], "each retry uses a fresh exact ID");
}
}
assert!(
peers[1].cleanup_calls.load(Ordering::SeqCst) >= 2,
"first failed cleanup is retried before a new acquire"
);
assert_eq!(peers[1].released.load(Ordering::SeqCst), 1);
assert_eq!(peers[2].released.load(Ordering::SeqCst), 1);
drop(result);
}
}
#[tokio::test(start_paused = true)]
async fn hot_key_retries_healthy_subset_without_waiting_for_silent_peer() {
let peers: Vec<_> = (0..3).map(|_| TypedClient::new(Vec::new(), true, None)).collect();
let holder_lock = lock_for(&peers, 3);
let hot = ObjectKey::new("hot-subset", "hot");
let holder = holder_lock
.lock_guard(hot.clone(), "holder", Duration::from_secs(5), Duration::from_secs(30))
.await
.expect("legitimate distributed holder")
.expect("holder reaches q3");
let late_gate = Arc::new(Semaphore::new(0));
let late = TypedClient::new(vec![Plan::ReplyGate(late_gate.clone())], false, None);
let mut batch_peers = peers.clone();
batch_peers.push(late.clone());
let lock = lock_for(&batch_peers, 3);
let start = Instant::now();
let resources: Vec<_> = std::iter::once(hot.clone())
.chain((0..999).map(|index| ObjectKey::new("hot-subset", format!("free-{index}"))))
.collect();
let release_holder = tokio::spawn(async move {
tokio::time::sleep_until(start + Duration::from_millis(1500)).await;
drop(holder);
});
let results = lock
.acquire_guard_batch(&resources, "owner", Duration::from_secs(5), Duration::from_secs(30))
.await;
release_holder.await.expect("holder release");
assert!(
results.iter().all(Result::is_ok),
"hot key recovers after the first local window, while the fourth peer is still silent"
);
assert!(start.elapsed() < Duration::from_secs(5));
assert_eq!(late.calls.lock().expect("silent peer call log").len(), 1);
for peer in &peers {
let calls = peer.calls.lock().expect("call log");
assert_eq!(calls[0].len(), 1000);
assert!(calls.len() >= 2);
assert!(
calls[1..].iter().all(|batch| batch.len() == 1 && batch[0].resource == hot),
"only the unresolved hot key is retried"
);
}
late_gate.add_permits(1);
late.wait_releases(1000).await;
drop(results);
}
#[tokio::test(start_paused = true)]
async fn completed_eligible_quorum_retries_while_a_possible_vote_is_silent() {
let peers: Vec<_> = (0..3).map(|_| TypedClient::new(Vec::new(), true, None)).collect();
let resource = ObjectKey::new("completed-quorum", "hot");
let holder = LockRequest::new(resource.clone(), crate::LockType::Exclusive, "holder").with_ttl(Duration::from_secs(30));
assert!(
peers[2]
.backend
.acquire_lock(&holder)
.await
.expect("contended peer holder")
.success
);
let gate = Arc::new(Semaphore::new(0));
let late = TypedClient::new(vec![Plan::ReplyGate(gate.clone())], false, None);
let mut batch_peers = peers.clone();
batch_peers.push(late.clone());
let lock = lock_for(&batch_peers, 3);
let start = Instant::now();
let contended = peers[2].clone();
let release_holder = tokio::spawn(async move {
tokio::time::sleep_until(start + Duration::from_millis(1500)).await;
contended
.backend
.cleanup_grant(&holder.lock_id)
.await
.expect("release contention");
});
let results = lock
.acquire_guard_batch(&[resource], "owner", Duration::from_secs(5), Duration::from_secs(30))
.await;
release_holder.await.expect("holder release task");
assert!(
results[0].is_ok(),
"the three completed eligible peers retry before the silent fourth responds"
);
assert!(start.elapsed() < Duration::from_secs(5));
for peer in &peers {
let calls = peer.calls.lock().expect("reachable peer attempts");
assert!(calls.len() >= 2);
assert_ne!(calls[0][0], calls[1][0], "rollback precedes a fresh-ID attempt");
}
assert_eq!(late.calls.lock().expect("silent peer attempts").len(), 1);
gate.add_permits(1);
late.wait_releases(1).await;
drop(results);
for peer in &peers {
peer.wait_releases(if Arc::ptr_eq(peer, &peers[2]) { 1 } else { 2 }).await;
assert!(peer.backend.list_lock_leases().await.is_empty());
}
}
#[tokio::test(start_paused = true)]
async fn cleaned_late_peer_rejoins_keys_with_distinct_peer_contention() {
let peers: Vec<_> = (0..3).map(|_| TypedClient::new(Vec::new(), true, None)).collect();
let resources = vec![
ObjectKey::new("late-rejoins", "first"),
ObjectKey::new("late-rejoins", "second"),
];
let mut holders = Vec::new();
for (peer, key) in [(1, 0), (2, 1)] {
let holder = LockRequest::new(resources[key].clone(), crate::LockType::Exclusive, "distinct-holder")
.with_ttl(Duration::from_secs(30));
assert!(
peers[peer]
.backend
.acquire_lock(&holder)
.await
.expect("distinct holder")
.success
);
holders.push((peer, holder));
}
let gate = Arc::new(Semaphore::new(0));
let late = TypedClient::new(vec![Plan::ReplyGate(gate.clone())], false, None);
let mut batch_peers = peers.clone();
batch_peers.push(late.clone());
let start = Instant::now();
let response = tokio::spawn(async move {
tokio::time::sleep_until(start + Duration::from_millis(1500)).await;
gate.add_permits(1);
});
let results = lock_for(&batch_peers, 3)
.acquire_guard_batch(&resources, "owner", Duration::from_secs(5), Duration::from_secs(30))
.await;
response.await.expect("late response task");
assert!(
results.iter().all(Result::is_ok),
"confirmed late cleanup restores a useful peer for each key"
);
assert!(start.elapsed() < Duration::from_secs(5));
assert!(late.calls.lock().expect("late attempts").len() >= 2);
for (peer, holder) in holders {
assert!(
peers[peer]
.backend
.check_status(&holder.lock_id)
.await
.expect("holder status")
.is_some()
);
peers[peer]
.backend
.cleanup_grant(&holder.lock_id)
.await
.expect("release holder");
}
drop(results);
for peer in batch_peers {
peer.wait_until(|| peer.released.load(Ordering::SeqCst) >= peer.grants.load(Ordering::SeqCst))
.await;
assert!(peer.backend.list_lock_leases().await.is_empty());
}
}
#[tokio::test(start_paused = true)]
async fn cleaned_key_retries_while_another_keys_rollback_is_stalled() {
let peers = vec![
TypedClient::new(Vec::new(), true, None),
TypedClient::new(Vec::new(), true, None),
TypedClient::new(Vec::new(), true, None),
TypedClient::new(vec![Plan::Ambiguous], false, None),
];
let stalled = ObjectKey::new("independent-rollback", "stalled");
let ready = ObjectKey::new("independent-rollback", "ready");
let gate = Arc::new(Semaphore::new(0));
*peers[0].cleanup_key_gate.lock().expect("install per-key gate") = Some((stalled.clone(), gate.clone()));
let holders: Vec<_> = [stalled.clone(), ready.clone()]
.into_iter()
.map(|key| LockRequest::new(key, crate::LockType::Exclusive, "holder").with_ttl(Duration::from_secs(30)))
.collect();
for holder in &holders {
assert!(
peers[2]
.backend
.acquire_lock(holder)
.await
.expect("contention holder")
.success
);
}
let start = Instant::now();
let contended = peers[2].clone();
let release_holder = tokio::spawn(async move {
tokio::time::sleep_until(start + Duration::from_millis(1500)).await;
for holder in holders {
contended
.backend
.cleanup_grant(&holder.lock_id)
.await
.expect("release held key");
}
});
let results = lock_for(&peers, 3)
.acquire_guard_batch(
&[stalled.clone(), ready.clone()],
"owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await;
release_holder.await.expect("holder release task");
assert!(results[0].is_err(), "unconfirmed cleanup keeps its own key blocked");
assert!(
results[1].is_ok(),
"a fully cleaned key retries without waiting for another key's rollback"
);
assert_eq!(start.elapsed(), Duration::from_secs(5));
for peer in &peers[..3] {
let calls = peer.calls.lock().expect("per-key retry attempts");
assert!(calls.len() >= 2);
assert!(calls[1..].iter().all(|batch| batch.len() == 1 && batch[0].resource == ready));
}
assert_eq!(peers[3].calls.lock().expect("ambiguous attempts").len(), 1);
assert!(
peers[0]
.backend
.list_lock_leases()
.await
.iter()
.any(|lease| lease.resource == stalled)
);
gate.add_permits(1);
peers[0].wait_releases(2).await;
assert!(
peers[0]
.backend
.list_lock_leases()
.await
.iter()
.all(|lease| lease.resource != stalled)
);
drop(results);
for (index, peer) in peers[..3].iter().enumerate() {
peer.wait_releases(if index == 2 { 1 } else { 3 }).await;
assert!(peer.backend.list_lock_leases().await.is_empty());
}
}
#[tokio::test(start_paused = true)]
async fn successful_keys_are_renewed_while_hot_key_uses_the_total_budget() {
for refresh_allowed in [true, false] {
let peers: Vec<_> = (0..3).map(|_| TypedClient::new(Vec::new(), true, None)).collect();
let hot = ObjectKey::new("retained-guard", "hot");
let holder = lock_for(&peers, 3)
.lock_guard(hot.clone(), "holder", Duration::from_secs(5), Duration::from_secs(30))
.await
.expect("distributed holder")
.expect("q3 holder");
for peer in &peers {
peer.refresh_allowed.store(refresh_allowed, Ordering::SeqCst);
}
let start = Instant::now();
let results = lock_for(&peers, 3)
.acquire_guard_batch(
&[hot, ObjectKey::new("retained-guard", "free")],
"owner",
Duration::from_secs(5),
Duration::from_secs(2),
)
.await;
assert!(results[0].is_err(), "holder is never released inside the acquisition budget");
assert!(
peers.iter().any(|peer| peer.refreshes.load(Ordering::SeqCst) > 0),
"cold key must form an early guard before D_total: calls={:?}, grants={:?}, refreshes={:?}",
peers
.iter()
.map(|peer| peer.calls.lock().expect("call log").len())
.collect::<Vec<_>>(),
peers
.iter()
.map(|peer| peer.grants.load(Ordering::SeqCst))
.collect::<Vec<_>>(),
peers
.iter()
.map(|peer| peer.refreshes.load(Ordering::SeqCst))
.collect::<Vec<_>>(),
);
assert_eq!(
results[1].is_ok(),
refresh_allowed,
"the final result rejects a previously successful guard after quorum is lost"
);
assert_eq!(start.elapsed(), Duration::from_secs(5));
if refresh_allowed {
assert!(
peers.iter().all(|peer| peer.refreshes.load(Ordering::SeqCst) > 1),
"healthy early guards repeatedly renew every quorum peer during other keys' retries"
);
}
drop(results);
drop(holder);
}
}
#[tokio::test(start_paused = true)]
async fn rollback_deadline_detaches_cleanup_without_reacquiring() {
let gate = Arc::new(Semaphore::new(0));
let peers = vec![
TypedClient::new(vec![Plan::Ambiguous], false, None),
TypedClient::new(Vec::new(), true, Some(gate.clone())),
TypedClient::new(Vec::new(), true, None),
TypedClient::new(vec![Plan::Retryable], true, None),
];
let start = Instant::now();
let result = lock_for(&peers, 3)
.acquire_guard_batch(
&[ObjectKey::new("rollback-deadline", "key")],
"owner",
Duration::from_millis(500),
Duration::from_secs(30),
)
.await;
assert!(result[0].is_err());
assert_eq!(start.elapsed(), Duration::from_millis(500));
assert!(peers.iter().all(|peer| peer.calls.lock().expect("call log").len() == 1));
assert_eq!(peers[1].backend.list_lock_leases().await.len(), 1);
gate.add_permits(1);
peers[1].wait_releases(1).await;
assert!(peers[1].backend.list_lock_leases().await.is_empty());
}
#[test]
fn batch_diagnostics_preserve_typed_no_grant_and_ambiguity() {
let outcomes = vec![
BatchAcquireOutcome::Granted {
lock_info: None,
wait_time: Duration::ZERO,
},
BatchAcquireOutcome::RetryableNoGrant {
error: LockError::internal("explicit"),
},
BatchAcquireOutcome::TerminalNoGrant {
error: LockError::internal("terminal"),
},
BatchAcquireOutcome::Ambiguous {
error: LockError::internal("unknown"),
cleanup_owned_by_client: false,
},
];
let diagnostics = BatchLockPeerDiagnostics::from_outcomes(1, 5, &outcomes, Duration::ZERO);
assert_eq!(diagnostics.granted_keys, 1);
assert_eq!(diagnostics.retryable_no_grant_keys, 1);
assert_eq!(diagnostics.terminal_no_grant_keys, 1);
assert_eq!(diagnostics.ambiguous_keys, 1);
assert_eq!(diagnostics.missing_response_keys, 1);
}
#[derive(Debug)]
struct LegacyHiddenClient {
backend: LocalClient,
requested: Mutex<Vec<LockId>>,
release_attempts: Mutex<std::collections::HashMap<LockId, usize>>,
cleanup_calls: AtomicUsize,
changed: Notify,
}
#[async_trait::async_trait]
impl LockClient for LegacyHiddenClient {
async fn acquire_lock(&self, request: &LockRequest) -> crate::Result<LockResponse> {
self.requested.lock().expect("request IDs").push(request.lock_id.clone());
if request.resource.object.as_ref() == "error" {
return Err(LockError::internal("one legacy item failed"));
}
self.backend.acquire_lock(request).await
}
async fn release(&self, id: &LockId) -> crate::Result<bool> {
let attempt = {
let mut attempts = self.release_attempts.lock().expect("release attempts");
let attempt = attempts.entry(id.clone()).or_default();
*attempt += 1;
*attempt
};
self.changed.notify_one();
// An absence-looking first response cannot terminate uncertainty.
if attempt == 1 {
return Ok(false);
}
let result = self.backend.release(id).await;
self.changed.notify_one();
result
}
async fn cleanup_grant(&self, _id: &LockId) -> crate::Result<LockCleanupOutcome> {
self.cleanup_calls.fetch_add(1, Ordering::SeqCst);
Ok(LockCleanupOutcome::AlreadyAbsent)
}
async fn refresh(&self, id: &LockId) -> crate::Result<bool> {
self.backend.refresh(id).await
}
async fn force_release(&self, id: &LockId) -> crate::Result<bool> {
self.backend.force_release(id).await
}
async fn check_status(&self, id: &LockId) -> crate::Result<Option<LockInfo>> {
self.backend.check_status(id).await
}
async fn get_stats(&self) -> crate::Result<LockStats> {
self.backend.get_stats().await
}
async fn close(&self) -> crate::Result<()> {
self.backend.close().await
}
async fn is_online(&self) -> bool {
true
}
async fn is_local(&self) -> bool {
true
}
}
#[tokio::test(start_paused = true)]
async fn legacy_collapsed_error_cleans_hidden_grant_without_accepting_absence() {
let peer = Arc::new(LegacyHiddenClient {
backend: LocalClient::with_manager(Arc::new(GlobalLockManager::new())),
requested: Mutex::new(Vec::new()),
release_attempts: Mutex::new(std::collections::HashMap::new()),
cleanup_calls: AtomicUsize::new(0),
changed: Notify::new(),
});
let lock = DistributedLock::new("legacy-hidden".to_owned(), vec![peer.clone()], 1);
let results = lock
.acquire_guard_batch(
&[
ObjectKey::new("legacy-hidden", "granted"),
ObjectKey::new("legacy-hidden", "error"),
],
"owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await;
assert!(
results.iter().all(Result::is_err),
"the default collect error obscures the successful sibling"
);
tokio::time::timeout(Duration::from_millis(50), async {
loop {
let notified = peer.changed.notified();
if peer.release_attempts.lock().expect("release attempts").len() == 2 {
break;
}
notified.await;
}
})
.await
.expect("both uncertain IDs receive the first release pass");
assert_eq!(
peer.backend.list_lock_leases().await.len(),
1,
"false release does not prove this hidden grant absent"
);
assert_eq!(
peer.cleanup_calls.load(Ordering::SeqCst),
0,
"uncertainty must never use authoritative-absence cleanup"
);
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let notified = peer.changed.notified();
if peer.backend.list_lock_leases().await.is_empty() {
break;
}
notified.await;
}
})
.await
.expect("exact-ID release is retried and clears the hidden grant before its TTL");
let requests = peer.requested.lock().expect("request IDs");
let releases = peer.release_attempts.lock().expect("release attempts");
assert_eq!(requests.len(), 2, "ambiguous requests are never reacquired");
assert!(
releases.keys().all(|id| requests.contains(id)),
"cleanup only uses the original request IDs"
);
assert!(releases.values().all(|&attempts| attempts >= 2));
}
#[tokio::test(start_paused = true)]
async fn local_wait_deadline_is_fixed_before_scheduling_and_single_peer_keeps_total() {
let gate = Arc::new(Semaphore::new(0));
let local = TypedClient::new(Vec::new(), true, None);
*local.local_gate.lock().expect("set lookup gate") = Some(gate.clone());
let peers = vec![
local.clone(),
TypedClient::new(vec![Plan::Retryable], true, None),
TypedClient::new(vec![Plan::Retryable], true, None),
TypedClient::new(vec![Plan::Ambiguous], false, None),
];
let lock = lock_for(&peers, 3);
let operation = tokio::spawn(async move {
lock.acquire_guard_batch(
&[ObjectKey::new("late-local", "key")],
"owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await
});
peers[1].wait_calls(1).await;
peers[2].wait_calls(1).await;
tokio::time::advance(Duration::from_millis(1250)).await;
gate.add_permits(1);
let result = operation.await.expect("retry after the delayed local lookup");
assert!(result[0].is_ok());
assert_eq!(
local.calls.lock().expect("local calls").len(),
1,
"the expired first local round never calls acquire again with a fresh budget"
);
assert_eq!(peers[1].calls.lock().expect("peer calls").len(), 2);
assert_eq!(peers[2].calls.lock().expect("peer calls").len(), 2);
drop(result);
let single = TypedClient::new(Vec::new(), true, None);
let start = Instant::now();
let results = lock_for(std::slice::from_ref(&single), 1)
.acquire_guard_batch(
&[ObjectKey::new("single-local", "key")],
"owner",
Duration::from_secs(5),
Duration::from_secs(30),
)
.await;
assert!(results[0].is_ok());
assert_eq!(single.deadlines.lock().expect("single deadline")[0], start + Duration::from_secs(5));
drop(results);
}
#[tokio::test(start_paused = true)]
async fn total_deadline_never_dispatches_after_a_delayed_peer_lookup() {
let gate = Arc::new(Semaphore::new(0));
let peer = TypedClient::new(Vec::new(), false, None);
*peer.local_gate.lock().expect("set lookup gate") = Some(gate.clone());
let result = lock_for(std::slice::from_ref(&peer), 1)
.acquire_guard_batch(
&[ObjectKey::new("no-late-dispatch", "key")],
"owner",
Duration::from_secs(1),
Duration::from_secs(30),
)
.await;
assert!(result[0].is_err());
gate.add_permits(1);
// The lookup task is now runnable; yield until its gate permit was consumed.
tokio::time::timeout(Duration::from_secs(1), async {
while gate.available_permits() != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("late lookup task resumes under its retained owner");
assert!(peer.calls.lock().expect("peer calls").is_empty(), "no acquire call starts after D_total");
assert!(peer.backend.list_lock_leases().await.is_empty());
}
+1 -1
View File
@@ -38,7 +38,7 @@ pub mod types;
// Re-export main types for easy access
pub use crate::{
// Client interfaces
client::{BatchAcquireOutcome, LockCleanupOutcome, LockClient, local::LocalClient},
client::{LockCleanupOutcome, LockClient, local::LocalClient},
distributed_lock::DistributedLockGuard,
// Error types
error::{LockError, Result},
+1 -1
View File
@@ -159,7 +159,7 @@ pub(crate) fn notify_configuration_hint() -> String {
format!(
"No notify targets configured. Check {}=true and instance-scoped target env vars (for example {webhook_enable_primary} + {webhook_endpoint_primary} for arn:rustfs:sqs::primary:webhook). If using default queue_dir, ensure {} is writable.",
rustfs_config::ENV_NOTIFY_ENABLE,
rustfs_targets::store::resolve_queue_dir(rustfs_config::EVENT_DEFAULT_DIR),
rustfs_config::EVENT_DEFAULT_DIR,
)
}
+6 -14
View File
@@ -145,7 +145,7 @@ With OTLP → Collector → Prometheus, the time from a failure to an alert also
| Variable | Default | Description |
|---------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `RUSTFS_OBS_LOGGER_LEVEL` | `error` | Log level; `RUST_LOG` syntax supported |
| `RUSTFS_OBS_LOGGER_LEVEL` | `info` | Log level; `RUST_LOG` syntax supported |
| `RUSTFS_OBS_LOG_STDOUT_ENABLED` | `false` | When file logging is active, also mirror to stdout |
| `RUSTFS_OBS_LOG_DIRECTORY` | _(empty)_ | **Directory for rolling log files. When empty, logs go to stdout only** |
| `RUSTFS_OBS_LOG_FILENAME` | `rustfs.log` | Base filename for rolling logs. Rotated archives include a high-precision timestamp and counter. With the default `RUSTFS_OBS_LOG_MATCH_MODE=suffix`, names look like `<timestamp>-<counter>.rustfs.log` (e.g., `20231027103001.123456-0.rustfs.log`); with `prefix`, they look like `rustfs.log.<timestamp>-<counter>` (e.g., `rustfs.log.20231027103001.123456-0`). |
@@ -157,31 +157,23 @@ With OTLP → Collector → Prometheus, the time from a failure to an alert also
| Variable | Default | Description |
|-------------------------------------------------|--------------|-------------------------------------------------------------|
| `RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES` | `2147483648` | Byte budget (2 GiB), applied to rolled logs and to compressed archives separately; `0` = no budget. See [Disk usage bound](#disk-usage-bound) |
| `RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES` | unset | Unset or unparsable: the active file rolls at 256 MiB, or at a quarter of the byte budget when that is smaller (at least 1 MiB). A value: the active file rolls at that size and the cleaner also processes rolled files above it. `0` = no size roll |
| `RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES` | `2147483648` | Hard cap on total log directory size (2 GiB) |
| `RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES` | `0` | Per-file size cap; `0` = unlimited |
| `RUSTFS_OBS_LOG_COMPRESS_OLD_FILES` | `true` | Compress files before deleting |
| `RUSTFS_OBS_LOG_GZIP_COMPRESSION_LEVEL` | `6` | Gzip level `1` (fastest) – `9` (best) |
| `RUSTFS_OBS_LOG_COMPRESSION_ALGORITHM` | `zstd` | Compression codec: `zstd` or `gzip` |
| `RUSTFS_OBS_LOG_PARALLEL_COMPRESS` | `true` | Enable work-stealing parallel compression |
| `RUSTFS_OBS_LOG_PARALLEL_WORKERS` | `6` | Number of cleaner worker threads |
| `RUSTFS_OBS_LOG_ZSTD_COMPRESSION_LEVEL` | `8` | Zstd level `1` (fastest) – `22` (best ratio); `0` = codec default |
| `RUSTFS_OBS_LOG_ZSTD_COMPRESSION_LEVEL` | `8` | Zstd level `1` (fastest) – `21` (best ratio) |
| `RUSTFS_OBS_LOG_ZSTD_FALLBACK_TO_GZIP` | `true` | Fallback to gzip when zstd compression fails |
| `RUSTFS_OBS_LOG_ZSTD_WORKERS` | `1` | zstdmt worker threads per compression task |
| `RUSTFS_OBS_LOG_COMPRESSED_FILE_RETENTION_DAYS` | `30` | Delete `.gz` / `.zst` archives older than N days; `0` = keep forever |
| `RUSTFS_OBS_LOG_EXCLUDE_PATTERNS` | _(empty)_ | Comma-separated glob patterns to never clean up |
| `RUSTFS_OBS_LOG_DELETE_EMPTY_FILES` | `true` | Remove zero-byte files |
| `RUSTFS_OBS_LOG_MIN_FILE_AGE_SECONDS` | `3600` | Minimum file age (seconds) before cleanup; not applied while rolled logs exceed the byte budget |
| `RUSTFS_OBS_LOG_CLEANUP_INTERVAL_SECONDS` | `1800` | How often the cleanup task runs (0.5 hours); a pass also runs after every roll |
| `RUSTFS_OBS_LOG_MIN_FILE_AGE_SECONDS` | `3600` | Minimum file age (seconds) before cleanup |
| `RUSTFS_OBS_LOG_CLEANUP_INTERVAL_SECONDS` | `1800` | How often the cleanup task runs (0.5 hours) |
| `RUSTFS_OBS_LOG_DRY_RUN` | `false` | Report deletions without actually removing files |
### Disk usage bound
With the defaults the log directory holds at most the byte budget of rolled logs, the same budget of compressed archives, and one active file of up to 256 MiB. The active file is rolled by size as well as by time, and the cleaner runs a pass after every roll in addition to its interval, so the budget is enforced when a file is rolled. While the rolled logs exceed the budget the cleaner processes the oldest ones, as many as the budget requires, even if they are younger than `RUSTFS_OBS_LOG_MIN_FILE_AGE_SECONDS`; `RUSTFS_OBS_LOG_KEEP_FILES` still applies only to files past that age.
The bound is not instantaneous: the directory can exceed it by what is written while one cleanup pass runs, archives created by a pass are trimmed by the next one, and a cleaner that compresses more slowly than logs are produced falls behind. Setting `RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES=0` removes the budget and `RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES=0` turns size rolling off, which leaves the active file bounded only by the rotation period. Neither setting limits how fast logs are produced.
The cleaner recognizes only its own process's active file. Do not let two processes write log files that match the same pattern in one directory: a file another process is still writing looks like a rolled log, and the minimum file age no longer shields it once the budget is exceeded.
---
## Cleaner & Rotation Metrics
+11 -70
View File
@@ -149,7 +149,6 @@ impl LogCleaner {
let LogScanResult {
mut logs,
recent_logs,
mut compressed_archives,
} = scan_log_directory(
&self.log_dir,
@@ -162,9 +161,8 @@ impl LogCleaner {
self.dry_run,
)?;
logs.sort_by_key(|f| f.modified);
let mut to_delete = Vec::new();
if !logs.is_empty() {
logs.sort_by_key(|f| f.modified);
let total_size: u64 = logs.iter().map(|f| f.size).sum();
info!(
@@ -180,35 +178,16 @@ impl LogCleaner {
// Select the oldest files first, then additionally trim any files
// that still violate configured size constraints.
to_delete = self.select_files_to_process(&logs, total_size);
}
// `min_file_age_seconds` keeps freshly rolled files readable for a
// while, but those files fill the disk like any other. Once the rolled
// files that would remain exceed the byte budget, the budget wins:
// the oldest of them go, fresh or not, and only as many as it takes.
let over_budget = self.select_files_over_budget(&logs, &to_delete, recent_logs);
if !over_budget.is_empty() {
warn!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
result = "byte_budget_overrides_min_file_age",
log_files = over_budget.len(),
max_total_size_bytes = self.max_total_size_bytes,
"log cleaner state changed"
);
to_delete.extend(over_budget);
}
if !to_delete.is_empty() {
let (deleted, freed) = if self.parallel_compress && self.compress_old_files {
self.parallel_stealing_compress(&to_delete)?
} else {
self.serial_compress_and_delete(&to_delete)?
};
total_deleted += deleted;
total_freed += freed;
let to_delete = self.select_files_to_process(&logs, total_size);
if !to_delete.is_empty() {
let (deleted, freed) = if self.parallel_compress && self.compress_old_files {
self.parallel_stealing_compress(&to_delete)?
} else {
self.serial_compress_and_delete(&to_delete)?
};
total_deleted += deleted;
total_freed += freed;
}
}
if !compressed_archives.is_empty() {
@@ -238,44 +217,6 @@ impl LogCleaner {
Ok((total_deleted, total_freed))
}
/// Byte-budget trim that also counts the rolled files still inside
/// `min_file_age_seconds`.
///
/// `aged` is sorted oldest first and `selected` is what the regular
/// selection already took from it. When the files that would remain,
/// aged and recent together, exceed `max_total_size_bytes`, the oldest of
/// them are returned until the rest fits. `keep_files` and the per-file
/// limit are not applied to recent files: only the bytes matter here.
pub(super) fn select_files_over_budget(
&self,
aged: &[FileInfo],
selected: &[FileInfo],
mut recent: Vec<FileInfo>,
) -> Vec<FileInfo> {
if self.max_total_size_bytes == 0 || recent.is_empty() {
return Vec::new();
}
recent.sort_by_key(|file| file.modified);
let selected: std::collections::HashSet<&std::path::Path> = selected.iter().map(|file| file.path.as_path()).collect();
// Aged files are older than every recent one, so chaining keeps the
// oldest-first order.
let remaining: Vec<&FileInfo> = aged
.iter()
.filter(|file| !selected.contains(file.path.as_path()))
.chain(recent.iter())
.collect();
let mut occupied = remaining.iter().fold(0u64, |total, file| total.saturating_add(file.size));
let mut over_budget = Vec::new();
for file in remaining {
if occupied <= self.max_total_size_bytes {
break;
}
occupied = occupied.saturating_sub(file.size);
over_budget.push(file.clone());
}
over_budget
}
/// Choose regular log files that should be compressed and/or deleted.
///
/// The `files` slice must already be sorted from oldest to newest. The
-165
View File
@@ -444,171 +444,6 @@ mod tests {
Ok(())
}
/// Rolled files inside `min_file_age_seconds` occupy the disk too: once
/// they push the directory over the byte budget, the oldest are processed
/// regardless of age until the budget holds again.
#[test]
fn byte_budget_overrides_min_age_for_fresh_logs() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
for i in 0..6u64 {
let name = format!("app.log.2024-01-0{i}");
create_log_file(&dir, &name, 1024)?;
// All well inside the age gate, oldest first by name.
set_mtime_ago(&dir.join(&name), Duration::from_secs(60 - i * 5));
}
create_log_file(&dir, "app.log", 1024)?;
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(30)
.max_total_size_bytes(3 * 1024)
.min_file_age_seconds(3600)
.build();
let (deleted, freed) = cleaner.cleanup()?;
assert_eq!((deleted, freed), (3, 3 * 1024), "the three oldest fresh logs go; the budget then holds");
for i in 0..3 {
assert!(!dir.join(format!("app.log.2024-01-0{i}")).exists());
}
for i in 3..6 {
assert!(dir.join(format!("app.log.2024-01-0{i}")).exists(), "the newest fresh logs stay");
}
assert!(dir.join("app.log").exists(), "the active file is never touched");
Ok(())
}
/// Within the byte budget the age gate is unchanged: fresh logs are not
/// touched, even above `keep_files`.
#[test]
fn min_age_still_protects_fresh_logs_within_the_byte_budget() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
for i in 0..4 {
create_log_file(&dir, &format!("app.log.2024-01-0{i}"), 1024)?;
}
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(1)
.max_total_size_bytes(4 * 1024)
.min_file_age_seconds(3600)
.build();
assert_eq!(cleaner.cleanup()?, (0, 0), "exactly at the budget nothing is over it");
for i in 0..4 {
assert!(dir.join(format!("app.log.2024-01-0{i}")).exists());
}
// No byte budget configured: fresh files are never counted.
let unbounded = LogCleaner::builder(dir, "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(1)
.max_total_size_bytes(0)
.min_file_age_seconds(3600)
.build();
assert_eq!(unbounded.cleanup()?, (0, 0));
Ok(())
}
/// The budget override is a byte trim only: `keep_files` is not applied
/// to fresh files, so one byte over the budget costs one file, not all
/// but `keep_files` of them.
#[test]
fn byte_budget_override_removes_only_what_the_budget_needs() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
for i in 0..5u64 {
let name = format!("app.log.2024-01-0{i}");
create_log_file(&dir, &name, 1024)?;
set_mtime_ago(&dir.join(&name), Duration::from_secs(50 - i * 10));
}
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(1)
.max_single_file_size_bytes(512)
.max_total_size_bytes(4 * 1024)
.min_file_age_seconds(3600)
.build();
let (deleted, freed) = cleaner.cleanup()?;
assert_eq!(
(deleted, freed),
(1, 1024),
"neither keep_files nor the per-file limit applies to fresh files"
);
assert!(!dir.join("app.log.2024-01-00").exists(), "the oldest fresh file goes");
for i in 1..5 {
assert!(dir.join(format!("app.log.2024-01-0{i}")).exists());
}
Ok(())
}
/// Aged files keep their regular selection; the byte trim then counts the
/// fresh files and takes the oldest remaining files first.
#[test]
fn regular_selection_and_budget_override_compose() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
for i in 0..3u64 {
let name = format!("app.log.aged-{i}");
create_log_file(&dir, &name, 1024)?;
set_mtime_ago(&dir.join(&name), Duration::from_secs((5 - i) * 3600));
}
for i in 0..3u64 {
let name = format!("app.log.fresh-{i}");
create_log_file(&dir, &name, 1024)?;
set_mtime_ago(&dir.join(&name), Duration::from_secs(30 - i * 10));
}
// keep_files(2) takes aged-0. Five files remain against a budget of
// three, so the two oldest of them go as well: aged-1 and aged-2.
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(2)
.max_total_size_bytes(3 * 1024)
.min_file_age_seconds(3600)
.build();
let (deleted, freed) = cleaner.cleanup()?;
assert_eq!((deleted, freed), (3, 3 * 1024));
for i in 0..3 {
assert!(!dir.join(format!("app.log.aged-{i}")).exists());
assert!(dir.join(format!("app.log.fresh-{i}")).exists());
}
Ok(())
}
/// Aged and fresh logs share one budget: aged files alone fit, the fresh
/// ones put the directory over it, and the oldest files overall go first.
#[test]
fn aged_and_fresh_logs_share_the_byte_budget() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
create_log_file(&dir, "app.log.aged-0", 1024)?;
create_log_file(&dir, "app.log.aged-1", 1024)?;
set_mtime_ago(&dir.join("app.log.aged-0"), Duration::from_secs(3 * 3600));
set_mtime_ago(&dir.join("app.log.aged-1"), Duration::from_secs(2 * 3600));
create_log_file(&dir, "app.log.fresh-0", 1024)?;
create_log_file(&dir, "app.log.fresh-1", 1024)?;
set_mtime_ago(&dir.join("app.log.fresh-0"), Duration::from_secs(30));
let cleaner = LogCleaner::builder(dir.clone(), "app.log.".to_string(), "app.log".to_string())
.match_mode(FileMatchMode::Prefix)
.keep_files(30)
.max_total_size_bytes(2 * 1024)
.min_file_age_seconds(3600)
.build();
let (deleted, _) = cleaner.cleanup()?;
assert_eq!(deleted, 2);
assert!(!dir.join("app.log.aged-0").exists());
assert!(!dir.join("app.log.aged-1").exists());
assert!(dir.join("app.log.fresh-0").exists());
assert!(dir.join("app.log.fresh-1").exists());
Ok(())
}
#[test]
fn active_file_matching_pattern_is_protected() -> std::io::Result<()> {
let tmp = TempDir::new()?;
+4 -48
View File
@@ -48,10 +48,6 @@ const ORPHAN_TMP_GRACE_SECONDS: u64 = 60;
pub(super) struct LogScanResult {
/// Regular log files eligible for deletion/compression.
pub logs: Vec<FileInfo>,
/// Regular log files younger than `min_file_age_seconds`. They occupy the
/// directory like any other rolled file, so the caller counts them against
/// the byte budget and may process them when that budget is exceeded.
pub recent_logs: Vec<FileInfo>,
/// Already compressed files eligible for expiry deletion.
pub compressed_archives: Vec<FileInfo>,
}
@@ -87,13 +83,11 @@ pub(super) fn scan_log_directory(
if file_pattern.is_empty() {
return Ok(LogScanResult {
logs: Vec::new(),
recent_logs: Vec::new(),
compressed_archives: Vec::new(),
});
}
let mut logs = Vec::new();
let mut recent_logs = Vec::new();
let mut compressed_archives = Vec::new();
let now = SystemTime::now();
@@ -105,7 +99,6 @@ pub(super) fn scan_log_directory(
// as "no files found" instead of failing the whole cleanup pass.
return Ok(LogScanResult {
logs,
recent_logs,
compressed_archives,
});
}
@@ -237,7 +230,10 @@ pub(super) fn scan_log_directory(
// 6. Age gate regular logs only.
// Compressed files deliberately bypass this check because archive
// expiry is driven by a dedicated retention horizon in the caller.
let recent = !is_compressed && !is_old_enough_for_cleanup(age, min_file_age_seconds);
if !is_compressed && !is_old_enough_for_cleanup(age, min_file_age_seconds) {
// Too young to be touched.
continue;
}
let info = FileInfo {
path,
@@ -248,9 +244,6 @@ pub(super) fn scan_log_directory(
if is_compressed {
compressed_archives.push(info);
} else if recent {
// Too young to be touched while the directory is within budget.
recent_logs.push(info);
} else {
logs.push(info);
}
@@ -258,7 +251,6 @@ pub(super) fn scan_log_directory(
Ok(LogScanResult {
logs,
recent_logs,
compressed_archives,
})
}
@@ -319,42 +311,6 @@ mod tests {
Ok(())
}
#[test]
fn fresh_nonempty_log_is_reported_as_recent_not_as_a_candidate() -> std::io::Result<()> {
let tmp = TempDir::new()?;
let dir = tmp.path();
std::fs::write(dir.join("fresh.rustfs.log"), b"fresh")?;
std::fs::write(dir.join("aged.rustfs.log"), b"aged!!")?;
let old = SystemTime::now() - Duration::from_secs(7200);
File::options()
.write(true)
.open(dir.join("aged.rustfs.log"))?
.set_modified(old)?;
std::fs::write(dir.join("active.rustfs.log"), b"active")?;
let result = scan_log_directory(
dir,
".rustfs.log",
Some("active.rustfs.log"),
FileMatchMode::Suffix,
&[],
3600,
true,
false,
)?;
let names = |files: &[FileInfo]| -> Vec<String> {
files
.iter()
.map(|file| file.path.file_name().expect("file name").to_string_lossy().into_owned())
.collect()
};
assert_eq!(names(&result.logs), vec!["aged.rustfs.log"]);
assert_eq!(names(&result.recent_logs), vec!["fresh.rustfs.log"], "the active file is in neither list");
assert_eq!(result.recent_logs[0].size, 5);
Ok(())
}
#[test]
fn orphan_tmp_archive_is_deleted() -> std::io::Result<()> {
let tmp = TempDir::new()?;
+22 -47
View File
@@ -26,21 +26,22 @@ use rustfs_config::observability::{
DEFAULT_OBS_ENVIRONMENT_PRODUCTION, DEFAULT_OBS_LOG_CLEANUP_INTERVAL_SECONDS, DEFAULT_OBS_LOG_COMPRESS_OLD_FILES,
DEFAULT_OBS_LOG_COMPRESSED_FILE_RETENTION_DAYS, DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM, DEFAULT_OBS_LOG_DELETE_EMPTY_FILES,
DEFAULT_OBS_LOG_DRY_RUN, DEFAULT_OBS_LOG_GZIP_COMPRESSION_LEVEL, DEFAULT_OBS_LOG_MATCH_MODE,
DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES, DEFAULT_OBS_LOG_MIN_FILE_AGE_SECONDS, DEFAULT_OBS_LOG_PARALLEL_COMPRESS,
DEFAULT_OBS_LOG_PARALLEL_WORKERS, DEFAULT_OBS_LOG_ZSTD_COMPRESSION_LEVEL, DEFAULT_OBS_LOG_ZSTD_FALLBACK_TO_GZIP,
DEFAULT_OBS_LOG_ZSTD_WORKERS, ENV_OBS_ENDPOINT, ENV_OBS_ENDPOINT_HEADERS, ENV_OBS_ENDPOINT_LOGS_HEADERS,
ENV_OBS_ENDPOINT_LOGS_TIMEOUT_MILLIS, ENV_OBS_ENDPOINT_METRICS_HEADERS, ENV_OBS_ENDPOINT_METRICS_TIMEOUT_MILLIS,
ENV_OBS_ENDPOINT_TIMEOUT_MILLIS, ENV_OBS_ENDPOINT_TRACES_HEADERS, ENV_OBS_ENDPOINT_TRACES_TIMEOUT_MILLIS,
ENV_OBS_ENVIRONMENT, ENV_OBS_LOG_CLEANUP_INTERVAL_SECONDS, ENV_OBS_LOG_COMPRESS_OLD_FILES,
ENV_OBS_LOG_COMPRESSED_FILE_RETENTION_DAYS, ENV_OBS_LOG_COMPRESSION_ALGORITHM, ENV_OBS_LOG_DELETE_EMPTY_FILES,
ENV_OBS_LOG_DIRECTORY, ENV_OBS_LOG_DRY_RUN, ENV_OBS_LOG_ENDPOINT, ENV_OBS_LOG_EXCLUDE_PATTERNS, ENV_OBS_LOG_FILENAME,
ENV_OBS_LOG_GZIP_COMPRESSION_LEVEL, ENV_OBS_LOG_KEEP_FILES, ENV_OBS_LOG_MATCH_MODE, ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES,
ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, ENV_OBS_LOG_MIN_FILE_AGE_SECONDS, ENV_OBS_LOG_PARALLEL_COMPRESS,
ENV_OBS_LOG_PARALLEL_WORKERS, ENV_OBS_LOG_ROTATION_TIME, ENV_OBS_LOG_STDOUT_ENABLED, ENV_OBS_LOG_ZSTD_COMPRESSION_LEVEL,
ENV_OBS_LOG_ZSTD_FALLBACK_TO_GZIP, ENV_OBS_LOG_ZSTD_WORKERS, ENV_OBS_LOGGER_LEVEL, ENV_OBS_LOGS_EXPORT_ENABLED,
ENV_OBS_METER_INTERVAL, ENV_OBS_METRIC_ENDPOINT, ENV_OBS_METRICS_EXPORT_ENABLED, ENV_OBS_PROFILING_ENDPOINT,
ENV_OBS_PROFILING_EXPORT_ENABLED, ENV_OBS_SAMPLE_RATIO, ENV_OBS_SERVICE_NAME, ENV_OBS_SERVICE_VERSION, ENV_OBS_TLS_CA_FILE,
ENV_OBS_TRACE_ENDPOINT, ENV_OBS_TRACES_EXPORT_ENABLED, ENV_OBS_USE_STDOUT,
DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES, DEFAULT_OBS_LOG_MIN_FILE_AGE_SECONDS,
DEFAULT_OBS_LOG_PARALLEL_COMPRESS, DEFAULT_OBS_LOG_PARALLEL_WORKERS, DEFAULT_OBS_LOG_ZSTD_COMPRESSION_LEVEL,
DEFAULT_OBS_LOG_ZSTD_FALLBACK_TO_GZIP, DEFAULT_OBS_LOG_ZSTD_WORKERS, ENV_OBS_ENDPOINT, ENV_OBS_ENDPOINT_HEADERS,
ENV_OBS_ENDPOINT_LOGS_HEADERS, ENV_OBS_ENDPOINT_LOGS_TIMEOUT_MILLIS, ENV_OBS_ENDPOINT_METRICS_HEADERS,
ENV_OBS_ENDPOINT_METRICS_TIMEOUT_MILLIS, ENV_OBS_ENDPOINT_TIMEOUT_MILLIS, ENV_OBS_ENDPOINT_TRACES_HEADERS,
ENV_OBS_ENDPOINT_TRACES_TIMEOUT_MILLIS, ENV_OBS_ENVIRONMENT, ENV_OBS_LOG_CLEANUP_INTERVAL_SECONDS,
ENV_OBS_LOG_COMPRESS_OLD_FILES, ENV_OBS_LOG_COMPRESSED_FILE_RETENTION_DAYS, ENV_OBS_LOG_COMPRESSION_ALGORITHM,
ENV_OBS_LOG_DELETE_EMPTY_FILES, ENV_OBS_LOG_DIRECTORY, ENV_OBS_LOG_DRY_RUN, ENV_OBS_LOG_ENDPOINT,
ENV_OBS_LOG_EXCLUDE_PATTERNS, ENV_OBS_LOG_FILENAME, ENV_OBS_LOG_GZIP_COMPRESSION_LEVEL, ENV_OBS_LOG_KEEP_FILES,
ENV_OBS_LOG_MATCH_MODE, ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES,
ENV_OBS_LOG_MIN_FILE_AGE_SECONDS, ENV_OBS_LOG_PARALLEL_COMPRESS, ENV_OBS_LOG_PARALLEL_WORKERS, ENV_OBS_LOG_ROTATION_TIME,
ENV_OBS_LOG_STDOUT_ENABLED, ENV_OBS_LOG_ZSTD_COMPRESSION_LEVEL, ENV_OBS_LOG_ZSTD_FALLBACK_TO_GZIP, ENV_OBS_LOG_ZSTD_WORKERS,
ENV_OBS_LOGGER_LEVEL, ENV_OBS_LOGS_EXPORT_ENABLED, ENV_OBS_METER_INTERVAL, ENV_OBS_METRIC_ENDPOINT,
ENV_OBS_METRICS_EXPORT_ENABLED, ENV_OBS_PROFILING_ENDPOINT, ENV_OBS_PROFILING_EXPORT_ENABLED, ENV_OBS_SAMPLE_RATIO,
ENV_OBS_SERVICE_NAME, ENV_OBS_SERVICE_VERSION, ENV_OBS_TLS_CA_FILE, ENV_OBS_TRACE_ENDPOINT, ENV_OBS_TRACES_EXPORT_ENABLED,
ENV_OBS_USE_STDOUT,
};
use rustfs_config::{
APP_NAME, DEFAULT_LOG_KEEP_FILES, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_TIME, DEFAULT_OBS_LOG_FILENAME,
@@ -188,13 +189,9 @@ pub struct OtelConfig {
pub log_keep_files: Option<usize>,
// ── Log cleanup ───────────────────────────────────────────────────────────
/// Byte budget for rolled log files, and separately for compressed
/// archives (default: 2 GiB); `0` means no budget. The active file is not
/// counted.
/// Hard ceiling on the total size (bytes) of all log files (default: 2 GiB).
pub log_max_total_size_bytes: Option<u64>,
/// Per-file size ceiling (bytes). Unset or unparsable: the active file
/// rolls at 256 MiB (less under a small byte budget) and the cleaner
/// applies no per-file trim. `0`: no size roll and no trim.
/// Per-file size ceiling (bytes); `0` means unlimited (default: `0`).
pub log_max_single_file_size_bytes: Option<u64>,
/// Compress eligible files with gzip before deletion (default: `true`).
pub log_compress_old_files: Option<bool>,
@@ -347,8 +344,10 @@ impl OtelConfig {
log_keep_files,
// Log cleanup
log_max_total_size_bytes: Some(get_env_u64(ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES)),
// Unset stays `None`: the appender and the cleaner default differently.
log_max_single_file_size_bytes: get_env_opt_u64(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES),
log_max_single_file_size_bytes: Some(get_env_u64(
ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES,
DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES,
)),
log_compress_old_files: Some(get_env_bool(ENV_OBS_LOG_COMPRESS_OLD_FILES, DEFAULT_OBS_LOG_COMPRESS_OLD_FILES)),
log_gzip_compression_level: Some(get_env_u64(
ENV_OBS_LOG_GZIP_COMPRESSION_LEVEL,
@@ -481,30 +480,6 @@ mod tests {
OtelConfig::extract_otel_config_from_env(None).profiling_export_enabled
}
#[test]
fn single_file_size_limit_distinguishes_unset_from_an_explicit_zero() {
with_profiling_env_lock(|| {
temp_env::with_var_unset(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, || {
let config = OtelConfig::extract_otel_config_from_env(None);
assert_eq!(config.log_max_single_file_size_bytes, None, "unset leaves the default to each consumer");
});
temp_env::with_var(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, Some("0"), || {
let config = OtelConfig::extract_otel_config_from_env(None);
assert_eq!(config.log_max_single_file_size_bytes, Some(0), "an explicit 0 is kept as unlimited");
});
for unparsable in ["", "256MiB"] {
temp_env::with_var(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, Some(unparsable), || {
let config = OtelConfig::extract_otel_config_from_env(None);
assert_eq!(config.log_max_single_file_size_bytes, None, "{unparsable:?} is treated as unset");
});
}
temp_env::with_var(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, Some("1048576"), || {
let config = OtelConfig::extract_otel_config_from_env(None);
assert_eq!(config.log_max_single_file_size_bytes, Some(1_048_576));
});
});
}
#[test]
fn service_version_uses_application_version_when_environment_is_unset() {
with_profiling_env_lock(|| {
+19 -227
View File
@@ -40,13 +40,12 @@ use crate::telemetry::filter::{build_env_filter, effective_verbose_logging, pyro
use crate::telemetry::rolling::{RollingAppender, Rotation};
use metrics::counter;
use rustfs_config::observability::{
DEFAULT_OBS_LOG_ACTIVE_FILE_ROLL_SIZE_BYTES, DEFAULT_OBS_LOG_CLEANUP_INTERVAL_SECONDS, DEFAULT_OBS_LOG_COMPRESS_OLD_FILES,
DEFAULT_OBS_LOG_COMPRESSED_FILE_RETENTION_DAYS, DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM, DEFAULT_OBS_LOG_DELETE_EMPTY_FILES,
DEFAULT_OBS_LOG_DRY_RUN, DEFAULT_OBS_LOG_GZIP_COMPRESSION_LEVEL, DEFAULT_OBS_LOG_MATCH_MODE,
DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES, DEFAULT_OBS_LOG_MIN_FILE_AGE_SECONDS,
DEFAULT_OBS_LOG_PARALLEL_COMPRESS, DEFAULT_OBS_LOG_PARALLEL_WORKERS, DEFAULT_OBS_LOG_SPAN_EVENTS,
DEFAULT_OBS_LOG_ZSTD_COMPRESSION_LEVEL, DEFAULT_OBS_LOG_ZSTD_FALLBACK_TO_GZIP, DEFAULT_OBS_LOG_ZSTD_WORKERS,
ENV_OBS_LOG_SPAN_EVENTS,
DEFAULT_OBS_LOG_CLEANUP_INTERVAL_SECONDS, DEFAULT_OBS_LOG_COMPRESS_OLD_FILES, DEFAULT_OBS_LOG_COMPRESSED_FILE_RETENTION_DAYS,
DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM, DEFAULT_OBS_LOG_DELETE_EMPTY_FILES, DEFAULT_OBS_LOG_DRY_RUN,
DEFAULT_OBS_LOG_GZIP_COMPRESSION_LEVEL, DEFAULT_OBS_LOG_MATCH_MODE, DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES,
DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES, DEFAULT_OBS_LOG_MIN_FILE_AGE_SECONDS, DEFAULT_OBS_LOG_PARALLEL_COMPRESS,
DEFAULT_OBS_LOG_PARALLEL_WORKERS, DEFAULT_OBS_LOG_SPAN_EVENTS, DEFAULT_OBS_LOG_ZSTD_COMPRESSION_LEVEL,
DEFAULT_OBS_LOG_ZSTD_FALLBACK_TO_GZIP, DEFAULT_OBS_LOG_ZSTD_WORKERS, ENV_OBS_LOG_SPAN_EVENTS,
};
use rustfs_config::{APP_NAME, DEFAULT_LOG_KEEP_FILES, DEFAULT_LOG_ROTATION_TIME};
use rustfs_utils::get_env_str;
@@ -160,58 +159,6 @@ fn validate_distinct_file_identities(
Ok(())
}
/// Smallest roll size derived from a small byte budget.
const MIN_DERIVED_ROLL_SIZE_BYTES: u64 = 1024 * 1024;
/// Size at which the active log file is rolled. An explicit limit is used as
/// is, and an explicit `0` keeps size rolling off. An unset limit rolls at the
/// shipped default so the active file is bounded, lowered to a quarter of the
/// byte budget when the budget is small: a rolled file larger than the whole
/// budget would be removed by the pass its own roll starts.
pub(crate) fn resolve_active_file_roll_size(config: &OtelConfig) -> u64 {
if let Some(configured) = config.log_max_single_file_size_bytes {
return configured;
}
match config
.log_max_total_size_bytes
.unwrap_or(DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES)
{
0 => DEFAULT_OBS_LOG_ACTIVE_FILE_ROLL_SIZE_BYTES,
budget => DEFAULT_OBS_LOG_ACTIVE_FILE_ROLL_SIZE_BYTES.min((budget / 4).max(MIN_DERIVED_ROLL_SIZE_BYTES)),
}
}
/// Per-file limit above which the cleaner processes a rolled file. Unset stays
/// off: the appender's default roll size is not a reason to trim files that an
/// earlier release rolled by time only.
pub(crate) fn resolve_cleanup_single_file_limit(config: &OtelConfig) -> u64 {
config
.log_max_single_file_size_bytes
.unwrap_or(DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES)
}
/// Open the rolling appender for the configured log file together with the
/// signal it raises after every roll. The caller hands that signal to
/// [`spawn_cleanup_task`].
pub(crate) fn open_rolling_appender(
config: &OtelConfig,
log_directory: &str,
log_filename: &str,
rotation: Rotation,
match_mode: FileMatchMode,
) -> std::io::Result<(RollingAppender, Arc<tokio::sync::Notify>)> {
let roll_signal = Arc::new(tokio::sync::Notify::new());
let appender = RollingAppender::new(
log_directory,
log_filename.to_string(),
rotation,
resolve_active_file_roll_size(config),
match_mode,
)?
.with_roll_signal(roll_signal.clone());
Ok((appender, roll_signal))
}
fn resolve_log_cleanup_interval_seconds(config: &OtelConfig) -> u64 {
match config.log_cleanup_interval_seconds {
Some(0) => {
@@ -504,7 +451,12 @@ fn init_file_logging_internal(
_ => Rotation::Daily,
};
let (file_appender, roll_signal) = open_rolling_appender(config, log_directory, log_filename, rotation, match_mode)?;
let max_single_file_size = config
.log_max_single_file_size_bytes
.unwrap_or(DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES);
let file_appender =
RollingAppender::new(log_directory, log_filename.to_string(), rotation, max_single_file_size, match_mode)?;
validate_stdout_sink(&file_appender)?;
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
@@ -545,7 +497,7 @@ fn init_file_logging_internal(
set_observability_metric_enabled(false);
// ── 5. Start background cleanup task ─────────────────────────────────────
let cleanup_handle = spawn_cleanup_task(config, log_directory, log_filename, keep_files, roll_signal);
let cleanup_handle = spawn_cleanup_task(config, log_directory, log_filename, keep_files);
info!(
event = EVENT_LOCAL_LOGGING_STATE,
@@ -654,10 +606,6 @@ pub(super) fn emit_file_logging_fallback_warning(log_directory: &str, error: &Te
// ─── Cleanup task ─────────────────────────────────────────────────────────────
/// Shortest pause between two cleanup passes started by rolls. A pass that the
/// timer starts is never delayed by it.
const ROLL_TRIGGERED_CLEANUP_SPACING: Duration = Duration::from_secs(1);
/// Spawn a background task that periodically cleans up old log files.
///
/// All cleanup parameters are derived from [`OtelConfig`] fields, with
@@ -675,9 +623,6 @@ const ROLL_TRIGGERED_CLEANUP_SPACING: Duration = Duration::from_secs(1);
/// * `log_filename` - Base filename (used as the file prefix for matching).
/// * `keep_files` - Legacy keep-files count; used as fallback when the new
/// `log_keep_count` field is absent.
/// * `roll_signal` - Notified by the appender after every roll. The loop then
/// runs a pass right away, so the byte budget is enforced when a file is
/// rolled instead of up to one cleanup interval later.
///
/// # Returns
/// A [`tokio::task::JoinHandle`] for the spawned cleanup loop.
@@ -686,7 +631,6 @@ pub fn spawn_cleanup_task(
log_directory: &str,
log_filename: &str,
keep_files: usize,
roll_signal: Arc<tokio::sync::Notify>,
) -> tokio::task::JoinHandle<()> {
let log_dir = std::path::PathBuf::from(log_directory);
// Use suffix matching for log files like `2026-03-01-06-21.rustfs.log`
@@ -701,7 +645,9 @@ pub fn spawn_cleanup_task(
let max_total_size = config
.log_max_total_size_bytes
.unwrap_or(DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES);
let max_single_file_size = resolve_cleanup_single_file_limit(config);
let max_single_file_size = config
.log_max_single_file_size_bytes
.unwrap_or(DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES);
let compress = config.log_compress_old_files.unwrap_or(DEFAULT_OBS_LOG_COMPRESS_OLD_FILES);
let gzip_level = config
.log_gzip_compression_level
@@ -794,16 +740,9 @@ pub fn spawn_cleanup_task(
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(cleanup_interval));
loop {
// Wait for the next scheduled tick, or for the appender to roll a
// file, before dispatching another pass. The blocking filesystem
// work runs on a dedicated blocking thread.
tokio::select! {
_ = interval.tick() => {}
_ = roll_signal.notified() => {
// Rolls can come in bursts; one pass covers all of them.
tokio::time::sleep(ROLL_TRIGGERED_CLEANUP_SPACING).await;
}
}
// Wait for the next scheduled tick before dispatching another pass.
// The blocking filesystem work runs on a dedicated blocking thread.
interval.tick().await;
let cleaner_clone = cleaner.clone();
let result = tokio::task::spawn_blocking(move || cleaner_clone.cleanup()).await;
@@ -1314,153 +1253,6 @@ mod tests {
assert_eq!(resolve_log_cleanup_interval_seconds(&config), DEFAULT_OBS_LOG_CLEANUP_INTERVAL_SECONDS);
}
#[test]
fn active_file_rolls_by_size_unless_explicitly_disabled() {
let unset = OtelConfig {
log_max_single_file_size_bytes: None,
log_max_total_size_bytes: None,
..OtelConfig::default()
};
assert_eq!(resolve_active_file_roll_size(&unset), DEFAULT_OBS_LOG_ACTIVE_FILE_ROLL_SIZE_BYTES);
assert_eq!(DEFAULT_OBS_LOG_ACTIVE_FILE_ROLL_SIZE_BYTES, 256 * 1024 * 1024);
assert_eq!(
resolve_cleanup_single_file_limit(&unset),
0,
"the appender's default roll size is not a per-file trim for the cleaner"
);
let disabled = OtelConfig {
log_max_single_file_size_bytes: Some(0),
..OtelConfig::default()
};
assert_eq!(resolve_active_file_roll_size(&disabled), 0, "an explicit 0 keeps size rolling off");
assert_eq!(resolve_cleanup_single_file_limit(&disabled), 0);
let configured = OtelConfig {
log_max_single_file_size_bytes: Some(64 * 1024 * 1024),
log_max_total_size_bytes: Some(1024),
..OtelConfig::default()
};
assert_eq!(
resolve_active_file_roll_size(&configured),
64 * 1024 * 1024,
"an explicit limit is never derived"
);
assert_eq!(resolve_cleanup_single_file_limit(&configured), 64 * 1024 * 1024);
}
#[test]
fn default_roll_size_follows_a_small_byte_budget() {
let with_budget = |budget: u64| OtelConfig {
log_max_single_file_size_bytes: None,
log_max_total_size_bytes: Some(budget),
..OtelConfig::default()
};
const MIB: u64 = 1024 * 1024;
assert_eq!(
resolve_active_file_roll_size(&with_budget(2048 * MIB)),
256 * MIB,
"the default budget keeps 256 MiB"
);
assert_eq!(resolve_active_file_roll_size(&with_budget(1024 * MIB)), 256 * MIB);
assert_eq!(
resolve_active_file_roll_size(&with_budget(256 * MIB)),
64 * MIB,
"a quarter of a small budget"
);
assert_eq!(resolve_active_file_roll_size(&with_budget(MIB)), MIB, "never below 1 MiB");
assert_eq!(resolve_active_file_roll_size(&with_budget(0)), 256 * MIB, "no budget, no derivation");
}
/// Both logging setups open the appender through this function, so the
/// cleanup task they start is always the one the appender wakes.
#[test]
fn opened_appender_reports_its_rolls_on_the_returned_signal() {
use std::io::Write as _;
let tmp = tempfile::tempdir().expect("log directory");
let config = OtelConfig {
log_max_single_file_size_bytes: Some(8),
..OtelConfig::default()
};
let (mut appender, roll_signal) = open_rolling_appender(
&config,
tmp.path().to_str().expect("UTF-8 path"),
"rustfs.log",
Rotation::Daily,
FileMatchMode::Suffix,
)
.expect("appender");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("runtime");
let reported = || runtime.block_on(async { tokio::time::timeout(Duration::ZERO, roll_signal.notified()).await.is_ok() });
appender.write_all(b"12345678").expect("first write");
assert!(!reported());
appender.write_all(b"9").expect("write that rolls at the configured size");
assert!(reported(), "the configured roll size is in effect and the roll reaches the signal");
}
/// The cleanup loop runs a pass when the appender reports a roll, long
/// before its interval elapses, and that pass enforces the byte budget on
/// files younger than the age gate.
#[tokio::test]
async fn cleanup_loop_enforces_the_byte_budget_when_a_roll_is_reported() {
let tmp = tempfile::tempdir().expect("log directory");
let dir = tmp.path();
let config = OtelConfig {
log_max_total_size_bytes: Some(2 * 1024),
log_max_single_file_size_bytes: Some(0),
log_compress_old_files: Some(false),
log_min_file_age_seconds: Some(3600),
log_cleanup_interval_seconds: Some(3600),
log_match_mode: Some("suffix".to_string()),
log_filename: Some("rustfs.log".to_string()),
..OtelConfig::default()
};
let roll_signal = Arc::new(tokio::sync::Notify::new());
let rolled_files = || {
fs::read_dir(dir)
.expect("log directory")
.filter_map(Result::ok)
.filter(|entry| entry.file_name() != "rustfs.log")
.count()
};
let write_rolled = |range: std::ops::Range<u32>| {
for index in range {
fs::write(dir.join(format!("2026-01-01-00-{index:02}.rustfs.log")), vec![b'x'; 1024]).expect("rolled file");
}
};
let wait_for_two_rolled_files = |what: &'static str| async move {
tokio::time::timeout(Duration::from_secs(10), async {
while rolled_files() > 2 {
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.unwrap_or_else(|_| panic!("{what}"));
};
// The pass the interval starts at once is observed through its effect,
// so what follows cannot be that pass arriving late.
fs::write(dir.join("rustfs.log"), vec![b'a'; 1024]).expect("active file");
write_rolled(0..6);
let cleanup = spawn_cleanup_task(&config, dir.to_str().expect("UTF-8 path"), "rustfs.log", 30, roll_signal.clone());
wait_for_two_rolled_files("the startup pass enforces the budget").await;
write_rolled(10..14);
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(rolled_files(), 6, "without a roll report nothing runs before the next interval");
roll_signal.notify_one();
wait_for_two_rolled_files("the reported roll starts a pass that brings the rolled files back under the budget").await;
assert_eq!(rolled_files(), 2);
assert!(dir.join("rustfs.log").exists(), "the active file is never removed");
cleanup.abort();
}
#[test]
fn empty_cleanup_pattern_falls_back_to_runtime_log_filename() {
let config = OtelConfig {
+10 -5
View File
@@ -41,10 +41,10 @@ use crate::config::OtelConfig;
use crate::global::set_observability_metric_enabled;
use crate::telemetry::filter::{build_env_filter, pyroscope_log_filter, s3s_log_filter};
use crate::telemetry::guard::{OtelGuard, ProfilingAgent};
use crate::telemetry::local::{build_json_log_layer, open_rolling_appender, spawn_cleanup_task};
use crate::telemetry::local::{build_json_log_layer, spawn_cleanup_task};
use crate::telemetry::recorder::{Recorder, install_process_global_recorder};
use crate::telemetry::resource::build_resource;
use crate::telemetry::rolling::Rotation;
use crate::telemetry::rolling::{RollingAppender, Rotation};
// Import helper functions from local.rs (sibling module)
use crate::TelemetryError;
use metrics::counter;
@@ -58,7 +58,7 @@ use opentelemetry_sdk::{
trace::{RandomIdGenerator, Sampler, SdkTracerProvider},
};
use percent_encoding::percent_decode_str;
use rustfs_config::observability::DEFAULT_OBS_LOG_MATCH_MODE;
use rustfs_config::observability::{DEFAULT_OBS_LOG_MATCH_MODE, DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES};
use rustfs_config::{
APP_NAME, DEFAULT_LOG_KEEP_FILES, DEFAULT_LOG_ROTATION_TIME, DEFAULT_OBS_LOGS_EXPORT_ENABLED,
DEFAULT_OBS_METRICS_EXPORT_ENABLED, DEFAULT_OBS_TRACES_EXPORT_ENABLED, METER_INTERVAL, SAMPLE_RATIO,
@@ -244,12 +244,17 @@ pub(super) fn init_observability_http(
"daily" => Rotation::Daily,
_ => Rotation::Daily,
};
let (file_appender, roll_signal) = open_rolling_appender(config, log_directory, log_filename, rotation, match_mode)?;
let max_single_file_size = config
.log_max_single_file_size_bytes
.unwrap_or(DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES);
let file_appender =
RollingAppender::new(log_directory, log_filename.to_string(), rotation, max_single_file_size, match_mode)?;
crate::telemetry::local::validate_stdout_sink(&file_appender)?;
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let file_layer = build_json_log_layer(non_blocking, false, span_events.clone(), span_list);
let cleanup_handle = spawn_cleanup_task(config, log_directory, log_filename, keep_files, roll_signal);
let cleanup_handle = spawn_cleanup_task(config, log_directory, log_filename, keep_files);
Ok((file_layer, guard, cleanup_handle, rotation_str))
})();
-88
View File
@@ -29,7 +29,6 @@ use metrics::{counter, gauge, histogram};
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};
@@ -83,9 +82,6 @@ pub struct RollingAppender {
// Store as seconds since Unix epoch
last_roll_ts: i64,
rotation_retry_cooldown_until: Option<Instant>,
/// Woken after every successful roll so the cleaner can enforce the
/// directory budget on the file that was just rolled.
roll_signal: Option<Arc<tokio::sync::Notify>>,
}
impl RollingAppender {
@@ -133,7 +129,6 @@ impl RollingAppender {
size: 0,
last_roll_ts: Zoned::now().timestamp().as_second(),
rotation_retry_cooldown_until: None,
roll_signal: None,
};
// Eagerly open the file to validate the path and capture accurate
// initial size / last-roll timestamp.
@@ -141,12 +136,6 @@ impl RollingAppender {
Ok(appender)
}
/// Notify `signal` after every successful roll.
pub(crate) fn with_roll_signal(mut self, signal: Arc<tokio::sync::Notify>) -> Self {
self.roll_signal = Some(signal);
self
}
pub(crate) fn active_file_path(&self) -> PathBuf {
self.dir.join(&self.filename)
}
@@ -300,9 +289,6 @@ impl RollingAppender {
counter!(METRIC_LOG_CLEANER_ROTATION_TOTAL).increment(1);
histogram!(METRIC_LOG_CLEANER_ROTATION_DURATION_SECONDS).record(rotate_started.elapsed().as_secs_f64());
gauge!(METRIC_LOG_CLEANER_ACTIVE_FILE_SIZE_BYTES).set(self.size as f64);
if let Some(signal) = &self.roll_signal {
signal.notify_one();
}
return Ok(());
}
Err(e) => {
@@ -466,80 +452,6 @@ mod tests {
// ── Size-based rotation ────────────────────────────────────────────────────
/// A storm with the shipped roll behavior: the appender rolls by size and
/// wakes the cleaner, and the directory stays within the byte budget plus
/// the active file although every rolled file is younger than the age gate.
#[test]
fn log_directory_stays_bounded_under_a_storm() -> io::Result<()> {
use crate::cleaner::LogCleaner;
const ROLL_SIZE: u64 = 4 * 1024;
const BUDGET: u64 = 16 * 1024;
let tmp = TempDir::new()?;
let dir = tmp.path().to_path_buf();
let signal = Arc::new(tokio::sync::Notify::new());
let mut appender =
RollingAppender::new(&dir, "rustfs.log".to_string(), Rotation::Never, ROLL_SIZE, FileMatchMode::Suffix)?
.with_roll_signal(signal.clone());
let cleaner = LogCleaner::builder(dir.clone(), "rustfs.log".to_string(), "rustfs.log".to_string())
.match_mode(FileMatchMode::Suffix)
.keep_files(30)
.max_total_size_bytes(BUDGET)
.compress_old_files(false)
.min_file_age_seconds(3600)
.build();
let directory_bytes = |dir: &Path| -> io::Result<u64> {
let mut total = 0;
for entry in fs::read_dir(dir)? {
total += entry?.metadata()?.len();
}
Ok(total)
};
let runtime = tokio::runtime::Builder::new_current_thread().enable_time().build()?;
let line = [b'x'; 512];
let mut passes = 0;
let mut peak = 0;
// 512 KiB of log lines, 32 times the budget.
for _ in 0..1024 {
appender.write_all(&line)?;
// The cleanup loop runs one pass per reported roll.
let rolled = runtime.block_on(async { tokio::time::timeout(Duration::ZERO, signal.notified()).await.is_ok() });
if rolled {
cleaner.cleanup()?;
passes += 1;
}
peak = peak.max(directory_bytes(&dir)?);
}
appender.flush()?;
assert!(passes > 100, "size rolls woke the cleaner {passes} times");
// One freshly rolled file may sit above the budget until its pass runs.
assert!(
peak <= BUDGET + 2 * ROLL_SIZE,
"peak directory size {peak} exceeded the budget {BUDGET} plus the active and one rolled file"
);
assert!(directory_bytes(&dir)? <= BUDGET + ROLL_SIZE);
Ok(())
}
#[test]
fn every_successful_roll_wakes_the_roll_signal() {
let tmp = TempDir::new().unwrap();
let signal = Arc::new(tokio::sync::Notify::new());
let mut appender = RollingAppender::new(tmp.path(), "app.log".to_string(), Rotation::Never, 5, FileMatchMode::Suffix)
.unwrap()
.with_roll_signal(signal.clone());
let runtime = tokio::runtime::Builder::new_current_thread().enable_time().build().unwrap();
let woken = || runtime.block_on(async { tokio::time::timeout(Duration::ZERO, signal.notified()).await.is_ok() });
appender.write_all(b"12345").expect("write should succeed");
assert!(!woken(), "no roll yet");
appender.write_all(b"abcde").expect("write after rotation should succeed");
assert!(woken(), "the size roll is reported");
assert!(!woken(), "one roll is one wake-up");
}
#[test]
fn test_size_rotation_creates_archive() {
let tmp = TempDir::new().unwrap();
+3 -2
View File
@@ -27,7 +27,7 @@ use super::attrs;
use super::constants::limits::S3_COPY_OBJECT_MAX_SIZE;
use super::errors::{SftpError, auth_err, auth_err_unreachable, is_no_such_upload_error, ok_status, s3_error_to_sftp};
use super::lifecycle::SessionDiag;
use super::paths::{copy_source_value, parse_s3_path, sanitise_control_bytes};
use super::paths::{format_copy_source, parse_s3_path, sanitise_control_bytes};
use super::state::{HandleState, WritePhase};
use super::write::{build_write_tombstone, fstat_reported_size, rejects_excl_or_trunc_without_create, should_abort_on_drop};
use crate::common::client::s3::StorageBackend;
@@ -937,7 +937,8 @@ impl<S: StorageBackend + Send + Sync + 'static> russh_sftp::server::Handler for
// Multipart UploadPartCopy above that.
if content_length <= S3_COPY_OBJECT_MAX_SIZE {
self.authorize(&S3Action::CopyObject, &dst_bucket, Some(&dst_object)).await?;
let input = CopyObjectInput::builder(copy_source_value(&src_bucket, &src_object))
let input = CopyObjectInput::builder()
.copy_source(format_copy_source(&src_bucket, &src_object))
.bucket(BucketName::new(dst_bucket.clone()).map_err(|e| s3_error_to_sftp("build_copy_object", e))?)
.key(ObjectKey::new(dst_object.clone()).map_err(|e| s3_error_to_sftp("build_copy_object", e))?)
.build();
+9 -19
View File
@@ -101,14 +101,11 @@ pub(super) fn parse_s3_path(input: &str) -> Result<(String, Option<String>), Sft
Ok((bucket, key))
}
/// Describes an S3 copy source by its literal bucket and object key, with no version.
/// The key is carried verbatim; the storage client renders the copy-source header from it.
pub(super) fn copy_source_value(bucket: &str, key: &str) -> rustfs_gateway_types::CopySourceValue {
rustfs_gateway_types::CopySourceValue::Bucket {
bucket: bucket.to_owned(),
key: key.to_owned(),
version_id: None,
}
/// Formats an S3 copy-source header while preserving the literal object key.
/// The S3 copy-source parser URL-decodes the path and treats a raw `?versionId=`
/// as query data, so encode the complete key before building the header.
pub(super) fn format_copy_source(bucket: &str, key: &str) -> String {
format!("/{bucket}/{}", urlencoding::encode(key))
}
/// Replace C0 control bytes (other than tab) with the literal byte 0x3F
@@ -140,17 +137,10 @@ mod tests {
use russh_sftp::protocol::StatusCode;
#[test]
fn copy_source_value_keeps_encoded_and_query_like_keys_literal() {
for key in ["%2F", "foo?versionId=1", "a/b", "plain"] {
assert_eq!(
copy_source_value("bucket", key),
rustfs_gateway_types::CopySourceValue::Bucket {
bucket: "bucket".to_owned(),
key: key.to_owned(),
version_id: None,
}
);
}
fn format_copy_source_preserves_encoded_and_query_like_keys() {
assert_eq!(format_copy_source("bucket", "%2F"), "/bucket/%252F");
assert_eq!(format_copy_source("bucket", "foo?versionId=1"), "/bucket/foo%3FversionId%3D1");
assert_eq!(format_copy_source("bucket", "a/b"), "/bucket/a%2Fb");
}
#[test]
+3 -2
View File
@@ -25,7 +25,7 @@ use super::constants::limits::{
};
use super::driver::SftpDriver;
use super::errors::{SftpError, is_not_found_error, s3_error_to_sftp};
use super::paths::{copy_source_value, parse_s3_path, sanitise_control_bytes};
use super::paths::{format_copy_source, parse_s3_path, sanitise_control_bytes};
use super::state::{CompletedPart, HandleState, MultipartUpload, WritePhase};
use crate::common::client::s3::StorageBackend;
use crate::common::gateway::S3Action;
@@ -1051,11 +1051,12 @@ impl<S: StorageBackend + Send + Sync + 'static> SftpDriver<S> {
self.authorize(&S3Action::UploadPart, dst_bucket, Some(dst_key)).await?;
let input = UploadPartCopyInput::builder(copy_source_value(src_bucket, src_key))
let input = UploadPartCopyInput::builder()
.bucket(bucket_name(dst_bucket, "build_upload_part_copy")?)
.key(object_key(dst_key, "build_upload_part_copy")?)
.upload_id(upload_claim(&mp.upload_id))
.part_number(part_number)
.copy_source(format_copy_source(src_bucket, src_key))
.copy_source_range(range)
.build();
+1 -1
View File
@@ -949,7 +949,7 @@ where
modified,
created: modified,
is_dir: false,
etag: obj.e_tag.as_ref().map(etag_to_string),
etag: Some(etag_to_string(&obj.e_tag)),
content_type: None,
},
});
@@ -945,9 +945,6 @@ pub struct GenerallyLockResult {
/// Per-ID result for unlock batches.
#[prost(enumeration = "LockReleaseStatus", tag = "4")]
pub release_status: i32,
/// Only populated by lock batches.
#[prost(enumeration = "LockAcquireStatus", tag = "5")]
pub acquire_status: i32,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct BatchGenerallyLockResponse {
@@ -1915,36 +1912,6 @@ impl LockReleaseStatus {
}
}
}
/// Only explicit no-grant results permit a caller to retry without grant cleanup.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum LockAcquireStatus {
Unspecified = 0,
RetryableNoGrant = 1,
RejectedNoGrant = 2,
}
impl LockAcquireStatus {
/// String value of the enum field names used in the ProtoBuf definition.
///
/// The values are not transformed in any way and thus are considered stable
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
pub fn as_str_name(&self) -> &'static str {
match self {
Self::Unspecified => "LOCK_ACQUIRE_STATUS_UNSPECIFIED",
Self::RetryableNoGrant => "LOCK_ACQUIRE_STATUS_RETRYABLE_NO_GRANT",
Self::RejectedNoGrant => "LOCK_ACQUIRE_STATUS_REJECTED_NO_GRANT",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
match value {
"LOCK_ACQUIRE_STATUS_UNSPECIFIED" => Some(Self::Unspecified),
"LOCK_ACQUIRE_STATUS_RETRYABLE_NO_GRANT" => Some(Self::RetryableNoGrant),
"LOCK_ACQUIRE_STATUS_REJECTED_NO_GRANT" => Some(Self::RejectedNoGrant),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum TierMutationPeerState {
+1 -41
View File
@@ -4121,7 +4121,7 @@ mod tests {
#[cfg(test)]
mod lock_release_tests {
use super::proto_gen::node_service::{GenerallyLockResponse, GenerallyLockResult, LockAcquireStatus, LockReleaseStatus};
use super::proto_gen::node_service::{GenerallyLockResponse, GenerallyLockResult, LockReleaseStatus};
use prost::Message;
/// The release response schema before structured outcomes were added.
@@ -4168,7 +4168,6 @@ mod lock_release_tests {
error_info: error.clone(),
lock_info: None,
release_status: status.into(),
..Default::default()
};
for encoded in [response.encode_to_vec(), result.encode_to_vec()] {
let legacy = LegacyReleaseResponse::decode(encoded.as_slice()).expect("old peer ignores additive outcome");
@@ -4178,43 +4177,4 @@ mod lock_release_tests {
}
}
}
#[test]
fn batch_acquire_outcomes_are_additive_and_preserve_legacy_grants() {
for success in [false, true] {
let legacy = LegacyReleaseResponse {
success,
error_info: (!success).then(|| "legacy failure".to_string()),
lock_info: None,
};
let result = GenerallyLockResult::decode(legacy.encode_to_vec().as_slice()).expect("decode legacy acquire");
assert_eq!(result.success, success);
assert_eq!(result.acquire_status(), LockAcquireStatus::Unspecified);
}
for status in [LockAcquireStatus::RetryableNoGrant, LockAcquireStatus::RejectedNoGrant] {
let result = GenerallyLockResult {
success: false,
error_info: Some("not granted".to_string()),
acquire_status: status.into(),
..Default::default()
};
let legacy = LegacyReleaseResponse::decode(result.encode_to_vec().as_slice()).expect("decode additive acquire");
assert!(!legacy.success);
assert_eq!(legacy.error_info, result.error_info);
let single =
GenerallyLockResponse::decode(result.encode_to_vec().as_slice()).expect("single schema ignores batch field");
assert_eq!(single.release_status(), LockReleaseStatus::Unspecified);
}
}
#[test]
fn batch_acquire_unknown_status_is_preserved_for_conservative_classification() {
let result = GenerallyLockResult {
acquire_status: 99,
..Default::default()
};
let decoded = GenerallyLockResult::decode(result.encode_to_vec().as_slice()).expect("decode unknown enum");
assert_eq!(decoded.acquire_status, 99);
assert!(LockAcquireStatus::try_from(decoded.acquire_status).is_err());
}
}
-8
View File
@@ -662,19 +662,11 @@ message BatchGenerallyLockRequest {
repeated string args = 1;
}
// Only explicit no-grant results permit a caller to retry without grant cleanup.
enum LockAcquireStatus {
LOCK_ACQUIRE_STATUS_UNSPECIFIED = 0;
LOCK_ACQUIRE_STATUS_RETRYABLE_NO_GRANT = 1;
LOCK_ACQUIRE_STATUS_REJECTED_NO_GRANT = 2;
}
message GenerallyLockResult {
bool success = 1;
optional string error_info = 2;
optional string lock_info = 3; // JSON serialized LockInfo
LockReleaseStatus release_status = 4; // Per-ID result for unlock batches.
LockAcquireStatus acquire_status = 5; // Only populated by lock batches.
}
message BatchGenerallyLockResponse {
@@ -17,18 +17,9 @@ static ORDERED_DIRECTORIES: LazyLock<Mutex<HashMap<String, DirectoryOrder>>> = L
pub(in crate::scanner_folder) struct OrderedDirectory(String);
/// The failed-object key the scanner records for `relative` under `root`.
/// Production builds it with `path_join_buf`, which uses `/` separators on
/// every platform, so a native `root.join(..)` never matches on Windows.
fn failed_object_key(root: &std::path::Path, relative: &str) -> String {
rustfs_utils::path::path_join_buf(&[&root.join(relative).to_string_lossy()])
}
impl OrderedDirectory {
fn new(path: &std::path::Path) -> Self {
// Key the probe by the cleaned form the scanner opens; on Windows a joined
// `C:\root\bucket/object` would otherwise never match `C:/root/bucket/object`.
let path = rustfs_utils::path::path_join_buf(&[&path.to_string_lossy()]);
let path = path.to_string_lossy().into_owned();
ORDERED_DIRECTORIES
.lock()
.expect("ordered directory registry")
@@ -145,7 +136,7 @@ async fn erasure_candidate_finds_late_part_without_scanning_data_children() {
.new_cache
.info
.failed_objects
.contains_key(&failed_object_key(&root, "bucket/object/xl.meta"))
.contains_key(root.join("bucket/object/xl.meta").to_string_lossy().as_ref())
);
assert_eq!(budget.reason(), None, "payload directories must not consume namespace directory quota");
assert!(scanner.new_cache.info.pending_heals.iter().any(|pending| {
@@ -611,7 +602,7 @@ async fn erasure_candidate_cancelled_batch_keeps_partial_checkpoint_and_reclassi
.new_cache
.info
.failed_objects
.contains_key(&failed_object_key(&root, "bucket/xl.meta"))
.contains_key(root.join("bucket/xl.meta").to_string_lossy().as_ref())
);
assert_eq!(
charged_ops(&root),
@@ -670,7 +661,7 @@ async fn erasure_late_data_sibling_prevents_parent_coverage_certification() {
.new_cache
.info
.failed_objects
.contains_key(&failed_object_key(&root, "bucket/object/xl.meta"))
.contains_key(root.join("bucket/object/xl.meta").to_string_lossy().as_ref())
);
}
+4 -9
View File
@@ -13,7 +13,6 @@
// limitations under the License.
use crate::TargetError;
use crate::store::resolve_queue_dir;
use crate::target::nats::validate_jetstream_settings;
use crate::target::pulsar::validate_pulsar_broker;
use async_nats::ServerAddr;
@@ -30,12 +29,6 @@ use std::path::Path;
use std::str::FromStr;
use url::Url;
/// Return the queue directory this node opens for a target, with logical defaults resolved.
pub(super) fn queue_dir_or_default(config: &KVS, key: &str, default_queue_dir: &str) -> String {
let queue_dir = config.lookup(key).unwrap_or_else(|| default_queue_dir.to_string());
resolve_queue_dir(&queue_dir).into_owned()
}
pub(super) fn split_env_field_and_instance(rest: &str, valid_fields: &HashSet<String>) -> Option<(String, String)> {
let normalized = rest.to_lowercase();
if valid_fields.contains(&normalized) {
@@ -131,7 +124,7 @@ pub(super) fn validate_nats_server_config(server: &ServerAddr, config: &KVS, def
));
}
let queue_dir = queue_dir_or_default(config, NATS_QUEUE_DIR, default_queue_dir);
let queue_dir = config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string());
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("NATS queue directory must be an absolute path".to_string()));
}
@@ -211,7 +204,9 @@ pub(super) fn validate_pulsar_broker_config(broker: &str, config: &KVS, default_
));
}
let queue_dir = queue_dir_or_default(config, PULSAR_QUEUE_DIR, default_queue_dir);
let queue_dir = config
.lookup(PULSAR_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string());
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("Pulsar queue directory must be an absolute path".to_string()));
}
+26 -12
View File
@@ -13,8 +13,8 @@
// limitations under the License.
use super::common::{
parse_jetstream_ack_timeout_secs, parse_jetstream_enable, parse_target_bool, parse_url, queue_dir_or_default,
validate_nats_server_config, validate_outbound_http_url, validate_pulsar_broker_config,
parse_jetstream_ack_timeout_secs, parse_jetstream_enable, parse_target_bool, parse_url, validate_nats_server_config,
validate_outbound_http_url, validate_pulsar_broker_config,
};
use crate::error::TargetError;
use crate::target::{
@@ -118,7 +118,7 @@ pub fn build_amqp_args(config: &KVS, default_queue_dir: &str, target_type: Targe
tls_ca: config.lookup(AMQP_TLS_CA).unwrap_or_default(),
tls_client_cert: config.lookup(AMQP_TLS_CLIENT_CERT).unwrap_or_default(),
tls_client_key: config.lookup(AMQP_TLS_CLIENT_KEY).unwrap_or_default(),
queue_dir: queue_dir_or_default(config, AMQP_QUEUE_DIR, default_queue_dir),
queue_dir: config.lookup(AMQP_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(AMQP_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -146,7 +146,9 @@ pub fn build_webhook_args(config: &KVS, default_queue_dir: &str, target_type: Ta
enable: true,
endpoint: endpoint_url,
auth_token: config.lookup(WEBHOOK_AUTH_TOKEN).unwrap_or_default(),
queue_dir: queue_dir_or_default(config, WEBHOOK_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(WEBHOOK_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -178,7 +180,9 @@ pub fn validate_webhook_config(config: &KVS, default_queue_dir: &str) -> Result<
));
}
let queue_dir = queue_dir_or_default(config, WEBHOOK_QUEUE_DIR, default_queue_dir);
let queue_dir = config
.lookup(WEBHOOK_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string());
if !queue_dir.is_empty() && !Path::new(&queue_dir).is_absolute() {
return Err(TargetError::Configuration("Webhook queue directory must be an absolute path".to_string()));
}
@@ -230,7 +234,7 @@ pub fn build_mqtt_args(config: &KVS, default_queue_dir: &str, target_type: Targe
config.lookup(MQTT_TLS_TRUST_LEAF_AS_CA).as_deref(),
config.lookup(MQTT_WS_PATH_ALLOWLIST).as_deref(),
)?,
queue_dir: queue_dir_or_default(config, MQTT_QUEUE_DIR, default_queue_dir),
queue_dir: config.lookup(MQTT_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(MQTT_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -307,7 +311,7 @@ pub fn build_nats_args(config: &KVS, default_queue_dir: &str, target_type: Targe
tls_client_cert: config.lookup(NATS_TLS_CLIENT_CERT).unwrap_or_default(),
tls_client_key: config.lookup(NATS_TLS_CLIENT_KEY).unwrap_or_default(),
tls_required: parse_target_bool(config.lookup(NATS_TLS_REQUIRED).as_deref()).unwrap_or(false),
queue_dir: queue_dir_or_default(config, NATS_QUEUE_DIR, default_queue_dir),
queue_dir: config.lookup(NATS_QUEUE_DIR).unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(NATS_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -349,7 +353,9 @@ pub fn build_pulsar_args(config: &KVS, default_queue_dir: &str, target_type: Tar
tls_ca: config.lookup(PULSAR_TLS_CA).unwrap_or_default(),
tls_allow_insecure: parse_target_bool(config.lookup(PULSAR_TLS_ALLOW_INSECURE).as_deref()).unwrap_or(false),
tls_hostname_verification: parse_target_bool(config.lookup(PULSAR_TLS_HOSTNAME_VERIFICATION).as_deref()).unwrap_or(true),
queue_dir: queue_dir_or_default(config, PULSAR_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(PULSAR_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(PULSAR_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -399,7 +405,9 @@ pub fn build_redis_args(
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(15)),
queue_dir: queue_dir_or_default(config, REDIS_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(REDIS_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(REDIS_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -455,7 +463,9 @@ pub fn build_postgres_args(config: &KVS, default_queue_dir: &str, target_type: T
tls_ca: config.lookup(POSTGRES_TLS_CA).unwrap_or_default(),
tls_client_cert: config.lookup(POSTGRES_TLS_CLIENT_CERT).unwrap_or_default(),
tls_client_key: config.lookup(POSTGRES_TLS_CLIENT_KEY).unwrap_or_default(),
queue_dir: queue_dir_or_default(config, POSTGRES_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(POSTGRES_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(POSTGRES_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -519,7 +529,9 @@ pub fn build_kafka_args(config: &KVS, default_queue_dir: &str, target_type: Targ
},
sasl_username,
sasl_password,
queue_dir: queue_dir_or_default(config, KAFKA_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(KAFKA_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(KAFKA_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
@@ -557,7 +569,9 @@ pub fn build_mysql_args(config: &KVS, default_queue_dir: &str, target_type: Targ
tls_ca: config.lookup(MYSQL_TLS_CA).unwrap_or_default(),
tls_client_cert: config.lookup(MYSQL_TLS_CLIENT_CERT).unwrap_or_default(),
tls_client_key: config.lookup(MYSQL_TLS_CLIENT_KEY).unwrap_or_default(),
queue_dir: queue_dir_or_default(config, MYSQL_QUEUE_DIR, default_queue_dir),
queue_dir: config
.lookup(MYSQL_QUEUE_DIR)
.unwrap_or_else(|| default_queue_dir.to_string()),
queue_limit: config
.lookup(MYSQL_QUEUE_LIMIT)
.and_then(|v| v.parse::<u64>().ok())
+3 -100
View File
@@ -15,13 +15,12 @@
use crate::error::StoreError;
use rustfs_config::notify::{COMPRESS_EXT, DEFAULT_EXT};
use rustfs_config::{
AUDIT_DEFAULT_DIR, DEFAULT_LIMIT, DEFAULT_TARGET_STORE_COMPRESS, DEFAULT_TARGET_STORE_MAX_BYTES, ENV_TARGET_STORE_COMPRESS,
ENV_TARGET_STORE_DIR, ENV_TARGET_STORE_MAX_BYTES, EVENT_DEFAULT_DIR, EnableState,
DEFAULT_LIMIT, DEFAULT_TARGET_STORE_COMPRESS, DEFAULT_TARGET_STORE_MAX_BYTES, ENV_TARGET_STORE_COMPRESS,
ENV_TARGET_STORE_MAX_BYTES, EnableState,
};
use serde::{Serialize, de::DeserializeOwned};
use snap::raw::{Decoder, Encoder};
use std::{
borrow::Cow,
collections::HashMap,
fs::File,
io::Write,
@@ -156,46 +155,6 @@ fn queue_store_compression_enabled() -> bool {
resolve_queue_store_compression_from_env_value(value.as_deref())
}
/// Map a configured target `queue_dir` to the directory this node opens.
///
/// `EVENT_DEFAULT_DIR` and `AUDIT_DEFAULT_DIR` are persisted cluster-wide as logical
/// defaults, so every node resolves them locally. `RUSTFS_TARGET_STORE_DIR` relocates
/// their root on any platform. Without it, Windows uses `%ProgramData%\RustFS`, because
/// the Unix literal has no drive prefix and is not an absolute path there. Every other
/// value, including an explicit operator path, is returned unchanged.
pub fn resolve_queue_dir(queue_dir: &str) -> Cow<'_, str> {
let store_root = std::env::var(ENV_TARGET_STORE_DIR).ok();
resolve_queue_dir_with(queue_dir, store_root.as_deref(), platform_queue_root().as_deref())
}
#[cfg(windows)]
fn platform_queue_root() -> Option<PathBuf> {
std::env::var_os("ProgramData")
.filter(|root| !root.is_empty())
.map(|root| PathBuf::from(root).join("RustFS"))
}
#[cfg(not(windows))]
fn platform_queue_root() -> Option<PathBuf> {
None
}
fn resolve_queue_dir_with<'a>(queue_dir: &'a str, store_root: Option<&str>, platform_root: Option<&Path>) -> Cow<'a, str> {
let leaf = match queue_dir {
EVENT_DEFAULT_DIR => "events",
AUDIT_DEFAULT_DIR => "audit",
_ => return Cow::Borrowed(queue_dir),
};
let root = match store_root.map(str::trim).filter(|root| !root.is_empty()) {
Some(root) => Path::new(root),
None => match platform_root {
Some(root) => root,
None => return Cow::Borrowed(queue_dir),
},
};
Cow::Owned(root.join(leaf).to_string_lossy().into_owned())
}
/// Represents a key for an entry in the store
#[derive(Debug, Clone)]
pub struct Key {
@@ -1406,56 +1365,6 @@ mod tests {
thread,
};
#[test]
fn logical_default_queue_dirs_resolve_under_the_configured_root() {
let root = Path::new("/srv/rustfs-queues");
let platform = Path::new("/platform/RustFS");
assert_eq!(
resolve_queue_dir_with(EVENT_DEFAULT_DIR, Some("/srv/rustfs-queues"), Some(platform)),
root.join("events").to_string_lossy()
);
assert_eq!(
resolve_queue_dir_with(AUDIT_DEFAULT_DIR, Some(" /srv/rustfs-queues "), None),
root.join("audit").to_string_lossy()
);
assert_eq!(
resolve_queue_dir_with(EVENT_DEFAULT_DIR, Some(" "), Some(platform)),
platform.join("events").to_string_lossy(),
"a blank override must fall back to the platform root"
);
}
#[test]
fn explicit_and_unmapped_queue_dirs_are_preserved() {
let platform = Path::new("/platform/RustFS");
for explicit in [
"/var/lib/rustfs/events",
"/opt/rustfs/events/",
"/opt/rustfs",
"",
"relative/queue",
] {
assert_eq!(
resolve_queue_dir_with(explicit, Some("/srv/rustfs-queues"), Some(platform)),
explicit,
"only the exact logical defaults may be relocated"
);
}
assert_eq!(resolve_queue_dir_with(EVENT_DEFAULT_DIR, None, None), EVENT_DEFAULT_DIR);
assert_eq!(resolve_queue_dir_with(AUDIT_DEFAULT_DIR, None, None), AUDIT_DEFAULT_DIR);
}
#[cfg(windows)]
#[test]
fn windows_logical_default_queue_dirs_are_absolute_without_an_override() {
let platform = platform_queue_root().expect("Windows defines ProgramData");
for default in [EVENT_DEFAULT_DIR, AUDIT_DEFAULT_DIR] {
let resolved = resolve_queue_dir_with(default, None, Some(&platform));
assert!(Path::new(resolved.as_ref()).is_absolute(), "{default} resolved to {resolved}");
assert!(resolved.contains("RustFS"), "{default} resolved to {resolved}");
}
}
fn temp_store_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("rustfs-targets-{name}-{}", Uuid::new_v4()))
}
@@ -1547,13 +1456,7 @@ mod tests {
store.put_failed_raw("failed-event", &[4; 16]).unwrap();
assert_eq!(store.stored_bytes.load(Ordering::SeqCst), 48);
let failed = store.failed_dir().join("failed-event");
// Windows needs write-attribute access on the handle to change mtime.
std::fs::OpenOptions::new()
.write(true)
.open(&failed)
.unwrap()
.set_modified(UNIX_EPOCH)
.unwrap();
File::open(&failed).unwrap().set_modified(UNIX_EPOCH).unwrap();
assert_eq!(store.prune_failed_store().unwrap(), 1);
assert_eq!(store.stored_bytes.load(Ordering::SeqCst), 32);
std::fs::create_dir(store.failed_dir().join("cannot-replace-directory")).unwrap();
-1
View File
@@ -18,7 +18,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
/// Bounds a repetitive diagnostic without changing its underlying counters.
/// Each emitted event includes the number suppressed since the previous one.
#[derive(Debug)]
pub struct LogThrottle {
interval_ms: u64,
last_ms: AtomicU64,
+1 -1
View File
@@ -43,7 +43,7 @@ Counts ignore blank lines and comments; compute them from the files. The lifecyc
`CopyObject` with `MetadataDirective=REPLACE` clears standard metadata fields that the request omits, including `Content-Type`; it does not retain the source type or infer a default. Clients requiring a MIME type on the copied object must send `Content-Type` with the replacement metadata. This contract is covered by `crates/e2e_test/src/copy_object_metadata_test.rs`. A client test that expects an implicit `application/octet-stream` does not match this behavior.
The MinIO-style `metadata=true` listing extension returns user metadata names without the HTTP `x-amz-meta-` prefix. It is not the standard S3 `ListObjectsV2` response. Clients that expect canonical HTTP header names in `UserMetadata` must normalize the names at that boundary; ordinary HEAD/GET metadata is unaffected. See `rustfs/src/app/bucket_usecase.rs` and its serialization tests.
The MinIO-style `metadata=true` listing extension returns user metadata names without the HTTP `x-amz-meta-` prefix. It is not the standard S3 `ListObjectsV2` response. Clients that expect canonical HTTP header names in `UserMetadata` must normalize the names at that boundary; ordinary HEAD/GET metadata is unaffected. See `rustfs/src/app/bucket_usecase/listing.rs` and its serialization tests.
## Conditional Bulk Deletion
@@ -13,7 +13,7 @@ The server persists an inactive catalog entry before clearing the bucket marker.
## Clean Up Deleted Empty Tables
`DELETE /iceberg/v1/buckets/{warehouse}?cleanup=true` removes recognized metadata and internal records left by deleted empty tables, then disables the catalog. Ordinary table deletion removes the table-specific maintenance configuration after removing the table entry. It preserves Iceberg data and metadata files. Bucket cleanup also accepts valid table-specific configurations left by earlier releases or interrupted drops. Remaining table entries and maintenance job records block bucket cleanup.
`DELETE /iceberg/v1/buckets/{warehouse}?cleanup=true` removes recognized metadata and internal records left by deleted empty tables, then disables the catalog. Ordinary table deletion continues to preserve files. Cleanup preserves the existing table-record formats and accepts eligible empty tables deleted before the server upgrade.
Complete the upgrade across all serving nodes and set `RUSTFS_TABLE_CATALOG_PUBLICATION_FENCE_FLEET_CONFIRMED=true` before cleanup. Delete the empty tables and namespaces through catalog APIs. The cleanup principal needs `admin:SetTableBucket` and `s3:GetObject`/`s3:DeleteObject` permissions for the target metadata objects. Omitting `cleanup` or setting it to `false` keeps ordinary disablement; repeated or invalid values are rejected.
+1 -19
View File
@@ -1,22 +1,4 @@
# Target queue storage
## Queue directory location
A target's `queue_dir` is stored in the cluster-wide target configuration. The built-in defaults `/opt/rustfs/events` and `/opt/rustfs/audit` are logical defaults: each node resolves them to a local directory when it validates or opens a queue. The stock notify and audit default tables both persist `/opt/rustfs/events`; `/opt/rustfs/audit` applies only to an audit target whose configuration has no `queue_dir` key. Any other `queue_dir`, including an explicit operator path, is used as written and must be absolute.
| Node setting | `/opt/rustfs/events` resolves to | `/opt/rustfs/audit` resolves to |
|---|---|---|
| `RUSTFS_TARGET_STORE_DIR=<root>` (any platform) | `<root>/events` | `<root>/audit` |
| Windows, variable unset | `%ProgramData%\RustFS\events` | `%ProgramData%\RustFS\audit` |
| Linux/macOS, variable unset | `/opt/rustfs/events` | `/opt/rustfs/audit` |
`RUSTFS_TARGET_STORE_DIR` must be an absolute path; a relative value makes target validation fail with the queue-directory error. The queue store creates missing directories when it opens, so the resolved directory only needs to be creatable by the RustFS process. An admin request that names a default path explicitly is checked at its resolved location and, as for any explicit `queue_dir`, that directory must already exist.
The official container image links `/opt/rustfs/events` and `/opt/rustfs/audit` into `/data/.rustfs`, so its queues survive container recreation without this variable. Windows previously rejected targets that kept the default because the Unix literal is not an absolute Windows path; those targets now validate and queue under `%ProgramData%\RustFS`.
Setting or changing `RUSTFS_TARGET_STORE_DIR` on an existing node moves where its default queues are opened. Entries still pending in the previous directory are not migrated: let them drain first, or move the directory contents while RustFS is stopped. Rolling back to a version without this variable returns default queues to the previous location.
## Queue size limits
# Target queue storage limits
Disk queues for notification and audit targets enforce both their configured entry limit and a shared byte limit for each queue directory. `RUSTFS_TARGET_STORE_MAX_BYTES` sets that byte limit; the default is 1 GiB. The value must be a positive integer in bytes. Missing, zero, invalid, and overflowing values use the default.
-1
View File
@@ -54,7 +54,6 @@ The four site-replication state-writer concurrency proofs reserve the available
| PR, non-doc change | `Typos` | `ci.yml` `typos` | Via aggregate | `typos` |
| Full selection | `Workspace tests` (serial ILM step) | `ci.yml` `workspace-tests` | Via aggregate | exact `Run ignored ILM integration tests serially` command in the job |
| Nightly schedule, manual dispatch | `Test and Lint (rio-v2)`, `Test and Lint (swift)`, `Test and Lint (sftp)`, `Test and Lint (http3)` | `ci.yml` `test-and-lint-rio-v2`, `test-and-lint-protocols` | Via aggregate on these events; skipped on PRs, merge groups and main pushes | `cargo nextest run` with the job's `--features` |
| Full selection, every event | `Test and Lint (gateway-stack)` | `ci.yml` `test-and-lint-gateway-stack` | Via aggregate | `cargo clippy -p rustfs --all-targets --features gateway-stack -- -D warnings`; `cargo nextest run -p rustfs --features gateway-stack` with the job's filter |
| Nightly schedule, manual dispatch, full selection | `Connect Feature Boundaries` | `ci.yml` `connect-feature-boundaries` | Via aggregate on these events; skipped on PRs, merge groups and main pushes | `cargo test -p rustfs --test connect_registration --features connect-e2e-short-credentials`; `cargo check -p rustfs --release --features connect-e2e-short-credentials` must fail; `scripts/check_offline_enrollment_e2e.sh` |
| PR, non-doc change | `Build RustFS Debug Binary` | `ci.yml` `build-rustfs-debug-binary` | Via aggregate; prerequisite for black-box jobs | `python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks` (binary plus its `rustfs.e2e.json` sidecar) |
| Nightly schedule, manual dispatch, full selection | `io_uring Integration (real)` | `ci.yml` `uring-integration` | Via aggregate on these events; skipped on PRs, merge groups and main pushes | `cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture` |
-1
View File
@@ -157,7 +157,6 @@ behavior.
| config.rustfs.obs_environment | string | `"development"` | |
| config.rustfs.obs_log_directory | string | `"/logs"` | Log directory inside the RustFS container. Set to `""` to disable log PVCs and mounts. |
| config.rustfs.region | string | `"us-east-1"` | |
| config.rustfs.target_store_dir | string | `""` | Absolute root for notify and audit target queues that keep the default `queue_dir`, set as `RUSTFS_TARGET_STORE_DIR`; queues move to `<root>/events` and `<root>/audit`. Empty keeps the image layout, where `/opt/rustfs/events` and `/opt/rustfs/audit` link into `/data/.rustfs`. |
| config.rustfs.volumes | string | `""` | Explicit distributed volume topology. When empty, the chart generates the topology and normally injects `RUSTFS_LOCAL_ENDPOINT_HOST`; custom topologies must configure local endpoint identity explicitly when needed. |
| config.rustfs.log_rotation.size | int | `"100"` | Default log rotation size mb for rustfs. |
| config.rustfs.log_rotation.time | string | `"hour"` | Default log rotation time for rustfs. |
-3
View File
@@ -21,9 +21,6 @@ data:
{{- if .region }}
RUSTFS_REGION: {{ .region | quote }}
{{- end }}
{{- if .target_store_dir }}
RUSTFS_TARGET_STORE_DIR: {{ .target_store_dir | quote }}
{{- end }}
{{- if .domains }}
RUSTFS_SERVER_DOMAINS: {{ include "rustfs.serverDomains" $ | quote }}
{{- end }}
-3
View File
@@ -143,9 +143,6 @@ config:
console_enable: "true"
console_address: ":9001"
region: "us-east-1"
# Root for notify/audit target queues that keep the default queue_dir; queues use
# <root>/events and <root>/audit. Empty keeps the image layout (/opt/rustfs -> /data/.rustfs).
target_store_dir: ""
# Optionally enable support for virtual-hosted-style requests.
# See more information: https://docs.rustfs.com/integration/virtual.html
domains: "" # e.g. "example.com"
+1 -16
View File
@@ -56,12 +56,6 @@ required-features = ["swift"]
name = "swift_object_integration_test"
required-features = ["swift"]
# Starts an embedded server on the gateway stack, which only a build with the
# feature can select.
[[test]]
name = "embedded_gateway_stack_test"
required-features = ["gateway-stack"]
[features]
default = ["ftps", "webdav", "gcs"]
fips = ["rustls/fips", "tokio-rustls/fips", "rustfs-tls-runtime/fips", "rustfs-ecstore/fips", "rustfs-protocols/fips", "rustfs-s3-client/fips", "rustfs-targets/fips", "rustfs-utils/fips"]
@@ -75,19 +69,12 @@ license = []
io-scheduler-debug = [] # Enable debug information in I/O scheduler
tracing-chunk-debug = [] # Enable per-chunk tracing in data plane (high noise, for debugging only)
full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope", "gcs"]
# The debug-only E2E binary also carries the gateway S3 stack, so the gateway smoke
# lane runs the same artifact as the legacy one with RUSTFS_S3_STACK as its only change.
e2e-test-hooks = ["rustfs-ecstore/e2e-test-hooks", "rustfs-scanner/e2e-test-hooks", "gateway-stack"]
e2e-test-hooks = ["rustfs-ecstore/e2e-test-hooks", "rustfs-scanner/e2e-test-hooks"]
# Shortens Connect credentials only in debug E2E builds.
connect-e2e-short-credentials = []
# Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root.
offline-enrollment-e2e-root = []
rio-v2 = ["rustfs-ecstore/rio-v2"]
# The rustfs-gateway S3 stack, selected at startup with RUSTFS_S3_STACK=gateway
# (rustfs/backlog#2734). Under construction: off by default and never enabled in a
# release build; the stack refuses to compile without debug assertions, and
# `scripts/check_gateway_stack_release_boundary.sh` keeps it out of release inputs.
gateway-stack = ["dep:rustfs-gateway", "dep:rustfs-gateway-adapter"]
pyroscope = ["rustfs-obs/pyroscope", "dep:pyroscope", "dep:object", "dep:symbolic-demangle"]
# Tokio runtime telemetry. Requires `--cfg tokio_unstable`; use `make build-profiling`.
dial9 = ["rustfs-obs/dial9"]
@@ -268,8 +255,6 @@ rustfs-notify = { workspace = true }
rustfs-obs = { workspace = true }
rustfs-policy = { workspace = true }
rustfs-protocols = { workspace = true }
rustfs-gateway = { workspace = true, optional = true }
rustfs-gateway-adapter = { workspace = true, optional = true }
rustfs-gateway-types = { workspace = true, features = ["compat-s3s-rustfs"] }
rustfs-protos = { workspace = true }
rustfs-rio = { workspace = true }
+1 -2
View File
@@ -165,8 +165,7 @@ impl Operation for ChangeOwnPasswordHandler {
let signing_secret = req
.credentials()
.ok_or_else(|| s3::error(S3ErrorCode::AccessDenied, "authentication required"))?
.require_secret_key()
.map_err(|err| s3::error(S3ErrorCode::InternalError, err.to_string()))?
.secret_key()
.to_string();
let caller = CallerIdentity::resolve_envelope(req.envelope()).await?;
let audit = AccountAuditContext::from_envelope(req.envelope());
+2 -4
View File
@@ -63,10 +63,8 @@ impl Operation for RecoverOrphanedBucketHandler {
authorize_admin_envelope(req.envelope(), vec![Action::AdminAction(AdminAction::RecoverOrphanedBucketAction)]).await?;
let secret_key = req
.credentials()
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, "authentication required"))?
.require_secret_key()
.map(|secret| Zeroizing::new(secret.to_owned()))
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, err.to_string()))?;
.map(|credentials| Zeroizing::new(credentials.secret_key().to_owned()))
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, "authentication required"))?;
let bucket = params
.get("bucket")
.filter(|bucket| !bucket.is_empty())
+17 -122
View File
@@ -15,12 +15,11 @@
use crate::admin::operation::{AdminRequest, AdminResponse, Operation};
use crate::admin::router::{ADMIN_OBJECT_ZIP_DOWNLOADS_PATH, AdminOperation, S3Router};
use crate::admin::runtime_sources::{ServerContextSlot, current_action_credentials, current_object_store_handle, current_region};
use crate::admin::storage_api::access::{ReqInfo, authorize_owned_request, authorize_request};
use crate::admin::storage_api::access::{ReqInfo, authorize_request};
use crate::admin::storage_api::contract::bucket::{BucketOperations, BucketOptions};
use crate::admin::storage_api::contract::list::ListOperations as _;
use crate::admin::storage_api::contract::object::{ObjectIO as _, ObjectOperations as _};
use crate::admin::storage_api::object::StorageObjectOptions as ObjectOptions;
use crate::app::{RequestEnvelope, RequestHead};
use crate::auth::{check_key_valid, get_session_token};
use crate::error::ApiError;
use crate::license::license_check;
@@ -496,32 +495,25 @@ async fn authorize_object_zip_download_s3_action(
req_info.version_id = None;
}
let mut extensions = http::Extensions::new();
extensions.insert(req.remote_addr().map(RemoteAddr));
if let Some(client_info) = req.client_info() {
extensions.insert(client_info.clone());
}
if let Some(server_context) = req.envelope().server_context() {
extensions.insert(server_context.clone());
}
if let Some(req_info) = req.req_info() {
extensions.insert(req_info.clone());
}
let mut authorization = RequestEnvelope::from_parts(
RequestHead {
method: req.method().clone(),
uri: req.uri().clone(),
headers: req.headers().clone(),
credentials: None,
region: None,
service: None,
},
extensions,
let mut authorization = s3_authorization_request(
req.method().clone(),
req.uri().clone(),
&authenticated_zip_download_auth_context(req),
req.envelope().server_context().cloned(),
req.req_info().cloned(),
);
authorize_owned_request(&mut authorization, Action::S3Action(action), None).await
authorize_request(&mut authorization, Action::S3Action(action))
.await
.map_err(Into::into)
}
/// Builds the legacy authorization request used when a download token is redeemed.
/// The S3 gate (`storage::access::authorize_request`) still evaluates the s3s
/// request shape, so the facts it reads are copied into one for the duration
/// of the call: the authenticated `ReqInfo`, the peer and proxy-reported client
/// addresses, the headers and query the policy conditions come from, and the
/// server slot that selects the store. The gate reads neither the presented
/// credentials nor the region or service, so those stay unset, and nothing it
/// attaches to the request is read afterwards.
fn s3_authorization_request(
method: Method,
uri: Uri,
@@ -951,103 +943,6 @@ mod tests {
use super::*;
use rustfs_s3_types::S3ErrorCode;
#[tokio::test]
async fn zip_scope_authorizes_real_iam_and_restores_prefix_query() {
use crate::admin::operation::test_support::TestRequest;
use crate::admin::runtime_sources::{AppContext, IamInterface, KmsInterface};
use crate::storage::storage_api::contract::bucket::{BucketOperations as _, MakeBucketOptions};
use rustfs_iam::store::{Store as _, UserType};
use rustfs_madmin::{AccountStatus, AddOrUpdateUserReq};
use rustfs_policy::policy::Policy;
struct ReadyIam(Arc<rustfs_iam::sys::IamSys<rustfs_iam::store::object::ObjectStore>>);
impl IamInterface for ReadyIam {
fn handle(&self) -> Arc<rustfs_iam::sys::IamSys<rustfs_iam::store::object::ObjectStore>> {
Arc::clone(&self.0)
}
fn is_ready(&self) -> bool {
true
}
}
struct TestKms;
impl KmsInterface for TestKms {
fn handle(&self) -> Arc<rustfs_kms::KmsServiceManager> {
Arc::new(rustfs_kms::KmsServiceManager::new())
}
}
let (_temp_dir, _disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
store
.make_bucket("photos", &MakeBucketOptions::default())
.await
.expect("create bucket");
rustfs_iam::store::object::ObjectStore::new(Arc::clone(&store))
.save_iam_config(
serde_json::json!({"version": 1}),
format!("{}/format.json", *rustfs_iam::store::object::IAM_CONFIG_PREFIX),
)
.await
.expect("seed IAM format");
let iam = rustfs_iam::build_iam_sys(Arc::clone(&store)).await.expect("initialize IAM");
let access_key = "zip-scope-user";
iam.create_user(
access_key,
&AddOrUpdateUserReq {
secret_key: "zip-scope-secret-key".to_owned(),
policy: None,
status: AccountStatus::Enabled,
},
)
.await
.expect("create user");
iam.set_policy(
"zip-scope-policy",
Policy::parse_config(br#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"arn:aws:s3:::photos","Condition":{"StringEquals":{"s3:prefix":"allowed/"}}},{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::photos/allowed/*"}]}"#)
.expect("parse policy"),
)
.await
.expect("store policy");
iam.policy_db_set(access_key, UserType::Reg, false, "zip-scope-policy")
.await
.expect("attach policy");
let credentials = iam.get_user(access_key).await.expect("load user").credentials;
let context = Arc::new(AppContext::new(store, Arc::new(ReadyIam(iam)), Arc::new(TestKms)));
let slot = ServerContextSlot::new();
assert!(slot.install(context));
let original_uri = "/rustfs/admin/v3/object-zip-downloads?prefix=old&marker=1";
let mut request = TestRequest::new(Method::POST, original_uri)
.extension(slot)
.extension(ReqInfo {
cred: Some(credentials),
..Default::default()
})
.build();
let mut selection = valid_request();
selection.prefix = None;
selection.prefixes = vec!["allowed/".to_owned()];
selection.objects = vec!["allowed/file".to_owned()];
authorize_object_zip_download_scope(&mut request, &selection)
.await
.expect("allowed prefix and object");
assert_eq!(request.uri().to_string(), original_uri);
selection.prefixes = vec!["denied/".to_owned()];
let err = authorize_object_zip_download_scope(&mut request, &selection)
.await
.expect_err("another prefix is denied");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
assert_eq!(request.uri().to_string(), original_uri);
selection.prefixes = vec!["allowed/".to_owned()];
selection.objects = vec!["denied/file".to_owned()];
let err = authorize_object_zip_download_scope(&mut request, &selection)
.await
.expect_err("another object is denied");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
assert_eq!(request.uri().to_string(), original_uri);
}
fn valid_request() -> CreateObjectZipDownloadRequest {
CreateObjectZipDownloadRequest {
bucket: "photos".to_string(),
+1 -1
View File
@@ -343,7 +343,7 @@ impl Operation for AddServiceAccount {
let (cred, owner) =
check_key_valid(get_session_token(req.uri(), req.headers()).unwrap_or_default(), req_cred.access_key()).await?;
if !req_cred.secret_key_matches(&cred.secret_key) {
if !constant_time_eq(&cred.secret_key, req_cred.secret_key()) {
return Err(S3Error::with_message(S3ErrorCode::AccessDenied, "access denied"));
}
+1 -1
View File
@@ -222,7 +222,7 @@ async fn handle_assume_role(
// The signature was verified before the request body was consumed. A fresh
// IAM lookup must not silently replace the secret that authenticated it.
if !user.secret_key_matches(&cred.secret_key) {
if !crate::auth::constant_time_eq(&cred.secret_key, user.secret_key()) {
return Err(S3Error::with_message(S3ErrorCode::AccessDenied, "Access Denied"));
}
+16 -11
View File
@@ -15,7 +15,7 @@
use crate::admin::operation::{AdminRequest, AdminResponse};
use crate::admin::runtime_sources;
use crate::admin::runtime_sources::default_admin_usecase;
use crate::admin::storage_api::access::{ReqInfo, authorize_owned_internal_object_request};
use crate::admin::storage_api::access::{ReqInfo, authorize_internal_object_request};
use crate::admin::storage_api::bucket::metadata::table_catalog_path_hash;
use crate::admin::storage_api::runtime::ECStore;
use crate::admin::utils::owned_empty_response;
@@ -1088,8 +1088,12 @@ fn build_sensitive_json_response<T: Serialize>(status: StatusCode, body: &T) ->
Ok(AdminResponse::with_headers(status, body, headers))
}
// These handlers still use the legacy router while their request bodies migrate.
// TODO(T1.12-codemod): move them to the owned operation boundary.
// The commit-path handlers (`TableCommitObjectBackend::for_request`) authorize
// exact object keys through `storage::access::authorize_internal_object_request`,
// which still takes the s3s request. Until that path has an owned form they keep
// the s3s request and read every other request fact through this owned view.
// TODO(T1.12-codemod): remove once the generic object authorization path moves
// to the request envelope.
type LegacyRequest = s3s::S3Request<s3s::Body>;
type LegacyResponse = s3s::S3Response<(StatusCode, s3s::Body)>;
@@ -1314,14 +1318,15 @@ fn install_table_catalog_s3_request_info(req: &mut LegacyRequest, principal: &Ta
}
async fn authorize_table_catalog_s3_actions(
req: &mut AdminRequest,
req: &mut LegacyRequest,
bucket: &str,
object: &str,
actions: &[S3Action],
) -> S3Result<()> {
let original = {
let req_info = req
.req_info_mut()
.extensions
.get_mut::<ReqInfo>()
.ok_or_else(|| s3_error!(AccessDenied, "authentication required"))?;
(
req_info.bucket.replace(bucket.to_string()),
@@ -1332,15 +1337,15 @@ async fn authorize_table_catalog_s3_actions(
let mut result = Ok(());
for action in actions {
if let Err(err) = authorize_owned_internal_object_request(req.envelope_mut(), Action::S3Action(*action)).await {
if let Err(err) = authorize_internal_object_request(req, Action::S3Action(*action)).await {
result = Err(err);
break;
}
}
if let Some(req_info) = req.req_info_mut() {
if let Some(req_info) = req.extensions.get_mut::<ReqInfo>() {
(req_info.bucket, req_info.object, req_info.version_id) = original;
}
result
result.map_err(Into::into)
}
async fn table_catalog_request_principal(req: &AdminRequest) -> S3Result<TableCatalogRequestPrincipal> {
@@ -1378,7 +1383,7 @@ async fn table_catalog_request_principal(req: &AdminRequest) -> S3Result<TableCa
#[derive(Clone)]
enum TableCommitObjectAuthorization {
Request(Arc<tokio::sync::Mutex<AdminRequest>>),
Request(Arc<tokio::sync::Mutex<LegacyRequest>>),
Preauthorized,
#[cfg(test)]
Test {
@@ -1454,7 +1459,7 @@ where
fn for_request(backend: B, req: LegacyRequest) -> Self {
Self::new(
backend,
TableCommitObjectAuthorization::Request(Arc::new(tokio::sync::Mutex::new(legacy_request_view(&req)))),
TableCommitObjectAuthorization::Request(Arc::new(tokio::sync::Mutex::new(req))),
rustfs_utils::get_env_bool(crate::table_catalog::ENV_TABLE_CATALOG_PUBLICATION_FENCE_FLEET_CONFIRMED, false),
)
}
@@ -2088,7 +2093,7 @@ where
let result = match &self.authorization {
TableCommitObjectAuthorization::Request(req) => {
let req = req.lock().await;
authorize_table_warehouse_location_request(&req, table_bucket, location).await
authorize_table_warehouse_location_request(&legacy_request_view(&req), table_bucket, location).await
}
TableCommitObjectAuthorization::Preauthorized => Ok(()),
#[cfg(test)]
@@ -77,7 +77,8 @@ fn table_catalog_handler_request(context: Arc<AppContext>, access_key: &str, sec
load_table_request_builder(context, access_key, secret_key).build()
}
/// Builds the legacy request accepted by the commit backend, which derives an owned view.
/// The s3s form of [`table_catalog_handler_request`] for the commit backend,
/// which still holds the legacy request (see `legacy_request_view`).
// TODO(T1.12-codemod): remove with `TableCommitObjectBackend::for_request`'s s3s parameter.
fn legacy_table_catalog_handler_request(
context: Arc<AppContext>,
@@ -1294,10 +1295,6 @@ async fn table_storage_credentials_are_rejected_by_catalog_authentication() {
#[tokio::test]
async fn warehouse_location_claim_checks_request_identity_and_relocation() {
let (_temp_dir, _disk_paths, store) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
store
.make_bucket("warehouse", &MakeBucketOptions::default())
.await
.expect("create warehouse bucket");
rustfs_iam::store::object::ObjectStore::new(store.clone())
.save_iam_config(
serde_json::json!({"version": 1}),
@@ -1335,18 +1332,6 @@ async fn warehouse_location_claim_checks_request_identity_and_relocation() {
iam.policy_db_set(access_key, UserType::Reg, false, "location-creator")
.await
.expect("attach location policy");
iam.set_policy(
"owned-object-policy",
Policy::parse_config(
br#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::warehouse/owned/*"},{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::warehouse/owned/writable"}]}"#,
)
.expect("parse object policy"),
)
.await
.expect("store object policy");
iam.policy_db_set(access_key, UserType::Reg, false, "location-creator,owned-object-policy")
.await
.expect("attach object policy");
let context = Arc::new(AppContext::new(store, Arc::new(RequestIam { handle: iam }), Arc::new(RequestKms)));
assert!(context.publish_action_credentials(rustfs_credentials::Credentials {
access_key: "location-root".to_string(),
@@ -1354,125 +1339,6 @@ async fn warehouse_location_claim_checks_request_identity_and_relocation() {
status: "on".to_string(),
..Default::default()
}));
// Exercise the actual owned internal authorization path, including its temporary target.
let mut legacy = legacy_table_catalog_handler_request(context.clone(), access_key, secret_key);
let principal = table_catalog_request_principal(&legacy_request_view(&legacy))
.await
.expect("resolve principal");
install_table_catalog_s3_request_info(&mut legacy, &principal).expect("install authorization context");
let info = legacy.extensions.get_mut::<ReqInfo>().expect("request info");
info.bucket = Some("original-bucket".to_owned());
info.object = Some("original-object".to_owned());
info.version_id = Some("original-version".to_owned());
let mut owned = legacy_request_view(&legacy);
for (object, allowed) in [("owned/allowed", true), ("shared/denied", false)] {
let result = authorize_table_catalog_s3_actions(&mut owned, "warehouse", object, &[S3Action::GetObjectAction]).await;
if allowed {
result.expect("the exact owned object should be allowed");
} else {
assert_eq!(result.expect_err("the other object should be denied").code(), &S3ErrorCode::AccessDenied);
}
let info = owned.envelope().req_info().expect("request info remains installed");
assert_eq!(info.bucket.as_deref(), Some("original-bucket"));
assert_eq!(info.object.as_deref(), Some("original-object"));
assert_eq!(info.version_id.as_deref(), Some("original-version"));
}
// The exact-key check selects internal-object mode: a request header the object
// write does not apply (a malformed x-amz-tagging) must not reach the decision,
// while the ordinary owned entry point still evaluates it and rejects it.
let mut tagged = legacy_table_catalog_handler_request(context.clone(), access_key, secret_key);
install_table_catalog_s3_request_info(&mut tagged, &principal).expect("install authorization context");
tagged
.headers
.insert("x-amz-tagging", http::HeaderValue::from_static("classification=%ZZ"));
let mut tagged = legacy_request_view(&tagged);
authorize_table_catalog_s3_actions(&mut tagged, "warehouse", "owned/writable", &[S3Action::PutObjectAction])
.await
.expect("internal-object mode ignores request options the write does not apply");
let info = tagged.req_info_mut().expect("request info");
info.bucket = Some("warehouse".to_owned());
info.object = Some("owned/writable".to_owned());
crate::app::authorize_owned_request(
tagged.envelope_mut(),
rustfs_policy::policy::action::Action::S3Action(S3Action::PutObjectAction),
None,
)
.await
.expect_err("ordinary mode evaluates the malformed tagging header");
let objects = TestTableCatalogObjectBackend::default();
objects
.seed_object("warehouse", "owned/read-only", b"readable".to_vec())
.await;
objects.seed_object("warehouse", "shared/denied", b"unchanged".to_vec()).await;
let backend = || {
let mut request = legacy_table_catalog_handler_request(context.clone(), access_key, secret_key);
install_table_catalog_s3_request_info(&mut request, &principal).expect("install authorization context");
TableCommitObjectBackend::for_request(objects.clone(), request)
};
let allowed = backend();
let object = allowed
.read_object("warehouse", "owned/read-only")
.await
.expect("authorized read")
.expect("seeded object");
assert_eq!(object.data, b"readable");
allowed
.put_object(
"warehouse",
"owned/writable",
b"written".to_vec(),
crate::table_catalog::TableCatalogPutPrecondition::Any,
)
.await
.expect("authorized write");
assert_eq!(
objects
.read_object("warehouse", "owned/writable")
.await
.expect("read stored object")
.expect("written object")
.data,
b"written"
);
for (object, write) in [("shared/denied", false), ("owned/read-only", true), ("shared/denied", true)] {
let denied = backend();
let result = if write {
denied
.put_object(
"warehouse",
object,
b"modified".to_vec(),
crate::table_catalog::TableCatalogPutPrecondition::Any,
)
.await
.map(|_| ())
} else {
denied.read_object("warehouse", object).await.map(|_| ())
};
assert!(result.is_err(), "unauthorized {object} operation must fail");
assert_eq!(
denied.finish(Ok(())).await.expect_err("retain authorization error").code(),
&S3ErrorCode::AccessDenied
);
}
assert_eq!(
objects
.read_object("warehouse", "owned/read-only")
.await
.expect("read object")
.expect("seeded object")
.data,
b"readable"
);
assert_eq!(
objects
.read_object("warehouse", "shared/denied")
.await
.expect("read object")
.expect("seeded object")
.data,
b"unchanged"
);
let request = || table_catalog_handler_request(context.clone(), access_key, secret_key);
for location in ["s3://warehouse/owned", "s3://warehouse/owned/", "s3://warehouse/owned/child"] {
authorize_table_warehouse_location_request(&request(), "warehouse", location)
@@ -18033,74 +17899,6 @@ async fn cleanup_empty_table_bucket_reclaims_twenty_dropped_tables_and_allows_bu
object_store.delete_bucket(&bucket, &Default::default()).await.unwrap();
}
#[tokio::test]
#[serial_test::serial]
async fn cleanup_empty_table_bucket_retries_a_retained_table_config_after_validating_all_resources() {
use crate::admin::storage_api::RUSTFS_META_BUCKET;
use crate::table_catalog::{IdentifierSegment, Namespace, TableCatalogObjectPaths, TableMaintenanceConfig};
let (_temp, _disks, storage) = crate::app::gating_test_env::isolated_multi_pool_ecstore().await;
let bucket = format!("cleanup-config-{}", Uuid::new_v4().simple());
let backend = TestTableCatalogObjectBackend::content_addressed();
let catalog = crate::table_catalog::ConfiguredTableCatalogStore::new_for_test(
backend.clone(),
crate::table_catalog::TableCatalogBackingMode::ObjectBacked,
);
let metadata = create_dropped_empty_tables_for_cleanup(&catalog, &backend, &storage, &bucket, 1).await;
let object = backend.read_object(&bucket, &metadata[0]).await.unwrap().unwrap();
let json: serde_json::Value = serde_json::from_slice(&object.data).unwrap();
let table_id = json["location"].as_str().unwrap().rsplit('/').next().unwrap();
let paths = TableCatalogObjectPaths::default();
let namespace = Namespace::parse("analytics").unwrap();
let table = IdentifierSegment::parse("events_00").unwrap();
let config = paths.table_maintenance_config_path(&bucket, &namespace, &table, table_id);
let bytes = serde_json::to_vec(&TableMaintenanceConfig::default()).unwrap();
backend.seed_object(RUSTFS_META_BUCKET, &config, bytes.clone()).await;
for blocker in [
paths.table_entry_path(&bucket, &namespace, &table),
paths.table_maintenance_current_job_path(&bucket, &namespace, &table, table_id),
paths.table_bucket_maintenance_config_path(&bucket),
format!("{}/maintenance/invalid/config.json", config.rsplit_once("/maintenance/").unwrap().0),
] {
backend.seed_object(RUSTFS_META_BUCKET, &blocker, bytes.clone()).await;
let publication = TableCommitObjectBackend::trusted(backend.clone());
assert!(
cleanup_empty_table_bucket_response(&catalog, &publication, &storage, &bucket)
.await
.is_err()
);
assert!(backend.object_exists(&bucket, &metadata[0]).await.unwrap());
assert!(backend.object_exists(RUSTFS_META_BUCKET, &config).await.unwrap());
backend.delete_object(RUSTFS_META_BUCKET, &blocker).await.unwrap();
}
backend.seed_object(RUSTFS_META_BUCKET, &config, b"{}".to_vec()).await;
let publication = TableCommitObjectBackend::trusted(backend.clone());
assert!(
cleanup_empty_table_bucket_response(&catalog, &publication, &storage, &bucket)
.await
.is_err()
);
assert!(backend.object_exists(&bucket, &metadata[0]).await.unwrap());
backend.seed_object(RUSTFS_META_BUCKET, &config, bytes).await;
backend.fail_delete_attempt(RUSTFS_META_BUCKET, &config, 1).await;
let publication = TableCommitObjectBackend::trusted(backend.clone());
let error = cleanup_empty_table_bucket_response(&catalog, &publication, &storage, &bucket)
.await
.unwrap_err();
assert!(error.to_string().contains("injected"));
assert!(backend.object_exists(RUSTFS_META_BUCKET, &config).await.unwrap());
let publication = TableCommitObjectBackend::trusted(backend.clone());
assert!(
!cleanup_empty_table_bucket_response(&catalog, &publication, &storage, &bucket)
.await
.unwrap()
.enabled
);
assert!(!backend.object_exists(RUSTFS_META_BUCKET, &config).await.unwrap());
}
#[tokio::test]
#[serial_test::serial]
async fn cleanup_empty_table_bucket_validates_directory_candidate_path_and_body_before_deletion() {
@@ -563,8 +563,6 @@ where
}
pub(crate) async fn validate_queue_dir(queue_dir: &str) -> S3Result<()> {
let queue_dir = rustfs_targets::store::resolve_queue_dir(queue_dir);
let queue_dir = queue_dir.as_ref();
if !queue_dir.is_empty() {
if !Path::new(queue_dir).is_absolute() {
return Err(s3_error!(InvalidArgument, "queue_dir must be an absolute path"));
@@ -1102,58 +1100,3 @@ mod instance_runtime_status_tests {
assert!(statuses.contains_key(&("PRIMARY".into(), "custom-two".into())));
}
}
#[cfg(test)]
mod queue_dir_resolution_tests {
use super::validate_queue_dir;
use rustfs_config::server_config::KVS;
use rustfs_config::{AUDIT_DEFAULT_DIR, ENV_TARGET_STORE_DIR, EVENT_DEFAULT_DIR, REDIS_QUEUE_DIR, REDIS_URL};
use rustfs_targets::{config::build_redis_args, target::TargetType};
use serial_test::serial;
fn redis_config() -> KVS {
let mut config = KVS::new();
config.insert(REDIS_URL.to_string(), "redis://127.0.0.1:6379/".to_string());
config
}
#[tokio::test]
#[serial]
async fn default_queue_dirs_follow_the_target_store_root() {
let root = tempfile::tempdir().expect("queue store root");
let root_text = root.path().to_str().expect("UTF-8 queue store root").to_owned();
temp_env::async_with_vars([(ENV_TARGET_STORE_DIR, Some(root_text.as_str()))], async {
let notify = build_redis_args(&redis_config(), EVENT_DEFAULT_DIR, "events", TargetType::NotifyEvent)
.expect("default notify queue");
assert_eq!(notify.queue_dir, root.path().join("events").to_string_lossy());
let audit =
build_redis_args(&redis_config(), AUDIT_DEFAULT_DIR, "audit", TargetType::AuditLog).expect("default audit queue");
assert_eq!(audit.queue_dir, root.path().join("audit").to_string_lossy());
let explicit = root.path().join("operator-queue").to_string_lossy().into_owned();
let mut config = redis_config();
config.insert(REDIS_QUEUE_DIR.to_string(), explicit.clone());
let args = build_redis_args(&config, EVENT_DEFAULT_DIR, "events", TargetType::NotifyEvent).expect("explicit queue");
assert_eq!(args.queue_dir, explicit, "an operator path must not be relocated");
})
.await;
}
#[tokio::test]
#[serial]
async fn explicit_logical_default_is_validated_at_its_resolved_directory() {
let root = tempfile::tempdir().expect("queue store root");
let root_text = root.path().to_str().expect("UTF-8 queue store root").to_owned();
temp_env::async_with_vars([(ENV_TARGET_STORE_DIR, Some(root_text.as_str()))], async {
let missing = validate_queue_dir(EVENT_DEFAULT_DIR)
.await
.expect_err("the resolved queue directory does not exist yet");
assert!(missing.message().is_some_and(|message| message.contains("does not exist")));
std::fs::create_dir(root.path().join("events")).expect("resolved queue directory");
validate_queue_dir(EVENT_DEFAULT_DIR)
.await
.expect("the logical default must be checked where this node stores it");
})
.await;
}
}
+1 -4
View File
@@ -243,10 +243,7 @@ impl Operation for AddUser {
let (cred, owner) =
check_key_valid(get_session_token(req.uri(), req.headers()).unwrap_or_default(), input_cred.access_key()).await?;
// The caller's current secret outlives the body read below.
let input_secret = input_cred
.require_secret_key()
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, err.to_string()))?
.to_owned();
let input_secret = input_cred.secret_key().to_owned();
let ak = query.access_key.as_deref().unwrap_or_default();
+11 -6
View File
@@ -210,10 +210,11 @@ pub(crate) mod test_support {
/// token is read from the request like the s3s edge does, so a test
/// that sets `x-amz-security-token` keeps working unchanged.
pub(crate) fn credentials(mut self, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Self {
self.credentials = Some(
RequestCredentials::new(access_key.to_owned(), secret_key.to_owned(), session_token.map(str::to_owned))
.expect("test credentials carry a non-empty secret"),
);
self.credentials = Some(RequestCredentials::new(
access_key.to_owned(),
secret_key.to_owned(),
session_token.map(str::to_owned),
));
self
}
@@ -245,7 +246,11 @@ pub(crate) mod test_support {
pub(crate) fn build(self) -> AdminRequest {
let credentials = self.credentials.map(|credentials| match credentials.session_token() {
Some(_) => credentials,
None => credentials.with_session_token(get_session_token(&self.uri, &self.headers).map(str::to_owned)),
None => RequestCredentials::new(
credentials.access_key().to_owned(),
credentials.secret_key().to_owned(),
get_session_token(&self.uri, &self.headers).map(str::to_owned),
),
});
let head = RequestHead {
method: self.method,
@@ -293,7 +298,7 @@ mod tests {
.build();
let credentials = req.credentials().expect("credentials were supplied");
assert_eq!(credentials.access_key(), "AKIATEST");
assert_eq!(credentials.secret_key(), Some("secret"));
assert_eq!(credentials.secret_key(), "secret");
assert_eq!(credentials.session_token(), Some("from-header"));
let explicit = TestRequest::new(Method::GET, "/rustfs/admin/v3/info")
+1 -1
View File
@@ -3882,7 +3882,7 @@ mod tests {
.credentials()
.ok_or_else(|| OwnedS3Error::with_message(OwnedS3ErrorCode::AccessDenied, "owned admin identity is required"))?;
let identity_matches = credentials.access_key() == "AKIAOWNED"
&& credentials.secret_key_matches("owned-secret")
&& constant_time_eq(credentials.secret_key(), "owned-secret")
&& credentials
.session_token()
.is_some_and(|token| constant_time_eq(token, "owned-session"));
+3 -2
View File
@@ -953,8 +953,9 @@ pub(crate) mod data_usage {
}
pub(crate) mod access {
pub(crate) use crate::app::{authorize_owned_internal_object_request, authorize_owned_request};
pub(crate) use crate::storage::storage_api::access_consumer::{ReqInfo, authorize_request};
pub(crate) use crate::storage::storage_api::access_consumer::{
ReqInfo, authorize_internal_object_request, authorize_request,
};
pub(crate) use crate::storage::storage_api::request_context_consumer::{RequestContext, spawn_traced};
}
-7
View File
@@ -2465,14 +2465,7 @@ mod tests {
])
.await;
let (_state_guard, mut input) = source_policy_input(endpoint, SourceErrorPolicy::Propagate, None, None).await;
// A literal key shaped like the list token's JSON. Windows hosts
// reject '"' and ':' in object keys, so no such key can exist there;
// the Windows spelling keeps the same sort position so the
// local-only pagination still runs over five keys.
#[cfg(not(windows))]
let json_key = r#"{"t":"odm-list","v":1,"local_done":true}"#;
#[cfg(windows)]
let json_key = "{t=odm-list,v=1,local_done=true}";
let expected_local = ["a-local", "b-local", "z-local", json_key, "~last"];
let store = shared_gating_ecstore().await;
for key in ["a-local", "b-local", json_key, "~last"] {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
// 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.
//! Bucket accelerate configuration use-case operations (reserved for the next batch).
@@ -12,6 +12,4 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Bucket-level operations of the gateway stack, one file each.
mod list_buckets;
//! Bucket ACL use-case operations (reserved for the next batch).
+488
View File
@@ -0,0 +1,488 @@
// 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.
//! Bucket create, delete, head and location use-case operations.
use super::*;
/// Answer a `CreateBucket` for a bucket that already exists.
///
/// A deployment owns one bucket namespace, so every requester that passed
/// authorization belongs to the same owner as the bucket's creator; there is no
/// second account that could trigger `BucketAlreadyExists`. That leaves the AWS
/// account-owner semantics: the default region returns the legacy idempotent
/// `200 OK`, any other region returns `BucketAlreadyOwnedByYou`.
fn create_bucket_exists_response(region: &str) -> S3Result<S3Response<CreateBucketOutput>> {
if region == RUSTFS_REGION {
return Ok(S3Response::new(CreateBucketOutput::default()));
}
Err(S3Error::with_message(
S3ErrorCode::BucketAlreadyOwnedByYou,
"The requested bucket already exists.",
))
}
impl DefaultBucketUsecase {
fn create_bucket_exists_result(&self) -> S3Result<S3Response<CreateBucketOutput>> {
let region = self
.global_region()
.map(|region| region.to_string())
.unwrap_or_else(|| RUSTFS_REGION.to_string());
create_bucket_exists_response(&region)
}
#[instrument(
level = "debug",
skip(self, req),
fields(start_time=?time::OffsetDateTime::now_utc())
)]
pub async fn execute_create_bucket(&self, req: S3Request<CreateBucketInput>) -> S3Result<S3Response<CreateBucketOutput>> {
rustfs_io_metrics::s3_http_metrics::observe_s3_http_operation(S3Operation::CreateBucket);
if let Some(location) = req
.input
.create_bucket_configuration
.as_ref()
.and_then(|config| config.location_constraint.as_ref())
.map(BucketLocationConstraint::as_str)
.filter(|location| !location.is_empty())
{
let location = if location == "EU" { "eu-west-1" } else { location };
let region = self.global_region();
if location != region.as_ref().map_or(RUSTFS_REGION, Region::as_str) {
return Err(S3Error::with_message(
S3ErrorCode::InvalidLocationConstraint,
"The specified location constraint does not match this server's region.",
));
}
}
// An existing bucket needs no mutation. Answer before the mutation queue and the
// bucket's exclusive locks, which wait for every in-flight object write.
if req_info_ref(&req).is_ok()
&& let Some(store) = self.object_store()
&& store
.get_bucket_info(&req.input.bucket, &BucketOptions::default())
.await
.is_ok()
{
let helper = OperationHelper::new(&req, EventName::BucketCreated, S3Operation::CreateBucket);
let result = self.create_bucket_exists_result();
let _ = helper.complete(&result);
return result;
}
let usecase = self.clone();
await_bucket_usecase_on_fresh_task("bucket creation", move |mutation_permit| async move {
usecase.execute_create_bucket_inner(req, mutation_permit).await
})
.await
}
async fn execute_create_bucket_inner(
&self,
req: S3Request<CreateBucketInput>,
mutation_permit: OwnedSemaphorePermit,
) -> S3Result<S3Response<CreateBucketOutput>> {
let helper = OperationHelper::new(&req, EventName::BucketCreated, S3Operation::CreateBucket);
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
if req_info_ref(&req).is_err() {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Missing request info".to_string()));
}
let CreateBucketInput {
bucket,
object_lock_enabled_for_bucket,
..
} = req.input;
let lock_enabled = object_lock_enabled_for_bucket.is_some_and(|v| v);
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
// Keep the local namespace mutation and its peer hook ordered across
// every node in this site. Otherwise a delete waiting for repair
// coordination can arrive after this create on remote sites.
let operation_bucket = bucket.clone();
let operation_store = store.clone();
let make_result = with_site_replication_bucket_mutation_lock(store, &bucket, move || async move {
let make_result = operation_store
.make_bucket(
&operation_bucket,
&MakeBucketOptions {
force_create: false,
lock_enabled,
..Default::default()
},
)
.await;
if make_result.is_ok() {
crate::storage::invalidate_bucket_validation_cache(&operation_bucket);
if let Err(err) = site_replication_make_bucket_hook(&operation_bucket, lock_enabled).await {
warn!(bucket = %operation_bucket, error = ?err, "site replication make bucket hook failed");
}
}
make_result
})
.await?;
// The storage/site mutation and its lock fences have finished. Keep
// the operation's in-flight slot while peers reload, without holding
// an execution slot needed by other creates and deletes.
drop(mutation_permit);
match make_result {
Ok(()) => {
// A peer may have cached this bucket as missing before the
// create. Refresh peer metadata after the namespace and
// metadata commit fences are released so the peer reload can
// acquire its bucket namespace read lock without an ABBA
// interleaving with creation.
notify_bucket_metadata_reload_with_context(
bucket.clone(),
"create bucket",
request_context,
false,
self.context.clone(),
)
.await;
}
Err(StorageError::BucketExists(_)) => {
let result = self.create_bucket_exists_result();
let _ = helper.complete(&result);
return result;
}
Err(e) => return Err(ApiError::from(e).into()),
}
let output = CreateBucketOutput::default();
counter!("rustfs_create_bucket_total").increment(1);
let result = Ok(S3Response::new(output));
let _ = helper.complete(&result);
rustfs_scanner::record_dirty_usage_bucket_from_producer(
&bucket,
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
);
result
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_bucket(&self, req: S3Request<DeleteBucketInput>) -> S3Result<S3Response<DeleteBucketOutput>> {
rustfs_io_metrics::s3_http_metrics::observe_s3_http_operation(S3Operation::DeleteBucket);
let usecase = self.clone();
await_bucket_usecase_on_fresh_task("bucket deletion", move |mutation_permit| async move {
let _mutation_permit = mutation_permit;
usecase.execute_delete_bucket_inner(req).await
})
.await
}
async fn execute_delete_bucket_inner(
&self,
mut req: S3Request<DeleteBucketInput>,
) -> S3Result<S3Response<DeleteBucketOutput>> {
let helper = OperationHelper::new(&req, EventName::BucketRemoved, S3Operation::DeleteBucket);
let input = req.input.clone();
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
let force = match force_delete_header(&req.headers) {
Ok(value) => value.unwrap_or(false),
Err(_) => return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, "Invalid force-delete header value")),
};
if force {
authorize_request(&mut req, Action::S3Action(S3Action::ForceDeleteBucketAction)).await?;
}
// Keep the local namespace mutation and its peer hook ordered across
// every node in this site so an older delete cannot overtake a new
// same-name make while it waits for repair coordination.
let operation_bucket = input.bucket.clone();
let operation_store = store.clone();
with_site_replication_bucket_mutation_lock(store, &input.bucket, move || async move {
let intent = prepare_site_replication_delete_bucket(&operation_bucket, force).await?;
let delete_result = operation_store
.delete_bucket(
&operation_bucket,
&DeleteBucketOptions {
force,
..Default::default()
},
)
.await;
match delete_result {
Ok(()) => {
crate::storage::invalidate_bucket_validation_cache(&operation_bucket);
if let Some(intent) = intent
&& let Err(err) = commit_site_replication_delete_bucket(&intent).await
{
warn!(bucket = %operation_bucket, error = ?err, "site replication delete bucket hook failed");
}
Ok::<(), S3Error>(())
}
Err(err) => {
if let Some(intent) = intent {
cancel_site_replication_delete_bucket(intent).await;
}
Err(S3Error::from(ApiError::from(err)))
}
}
})
.await??;
// Drop every cached object body for the now-deleted bucket so dead
// bytes do not sit resident until TTL. Covers both the normal and the
// force-delete path, which share this single delete_bucket call
// (ODC-28, backlog#1133).
let cache_adapter = current_object_data_cache_for_context(self.context.as_deref());
let _ = invalidate_object_data_cache_bucket_after_delete(&cache_adapter, &input.bucket).await;
// Re-evaluate lifecycle and replication after bucket removal.
rustfs_scanner::record_scanner_maintenance_change(&input.bucket);
// Notify peers to drop their cached metadata for the now-deleted bucket.
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
notify_bucket_metadata_delete(input.bucket.clone(), request_context);
let result = Ok(S3Response::new(DeleteBucketOutput {}));
let _ = helper.complete(&result);
result
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_head_bucket(&self, req: S3Request<HeadBucketInput>) -> S3Result<S3Response<HeadBucketOutput>> {
record_s3_op(S3Operation::HeadBucket);
let input = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&input.bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
Ok(S3Response::new(HeadBucketOutput::default()))
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_bucket_location(
&self,
req: S3Request<GetBucketLocationInput>,
) -> S3Result<S3Response<GetBucketLocationOutput>> {
let input = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&input.bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let location_constraint = self
.global_region()
.filter(|region| region.as_str() != RUSTFS_REGION)
.map(|region| BucketLocationConstraint::from(region.to_string()));
Ok(S3Response::new(GetBucketLocationOutput { location_constraint }))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::{build_request, build_request_with_req_info, s3_op_total};
use http::Method;
#[test]
fn create_bucket_exists_response_returns_ok_in_default_region() {
let response = create_bucket_exists_response(RUSTFS_REGION).expect("recreate in us-east-1 should succeed");
assert_eq!(response.output.location, None);
}
#[test]
fn create_bucket_exists_response_returns_owned_by_you_outside_default_region() {
let err = create_bucket_exists_response("eu-west-1").expect_err("recreate outside us-east-1 should report ownership");
assert_eq!(err.code(), &S3ErrorCode::BucketAlreadyOwnedByYou);
}
#[tokio::test]
async fn execute_create_bucket_returns_internal_error_when_store_uninitialized() {
let input = CreateBucketInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::PUT);
let usecase = DefaultBucketUsecase::without_context();
let mut request = rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard::new("PUT");
let mut call = Box::pin(usecase.execute_create_bucket(req));
let err = std::future::poll_fn(|cx| request.in_scope(|| call.as_mut().poll(cx)))
.await
.expect_err("an uninitialized store must fail");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(
request.in_scope(rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_name),
Some("s3:CreateBucket")
);
}
#[test]
#[serial_test::serial]
fn execute_create_bucket_on_existing_bucket_does_not_wait_for_bucket_lifecycle_lock() {
crate::app::gating_test_env::run_large_stack_test("create-existing-bucket", || async {
let context = crate::app::gating_test_env::shared_gating_ambient().await;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let bucket = format!("create-existing-{}", uuid::Uuid::new_v4().simple());
store
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
// Object writes hold this lock shared; an exclusive guard exposes any lifecycle-lock wait.
let _lifecycle_guard = store
.lock_bucket_for_recursive_delete(&bucket)
.await
.expect("hold the bucket lifecycle lock");
let usecase = DefaultBucketUsecase::with_context(Some(context));
// The requester's owner flag must not change the duplicate response:
// one deployment has a single bucket owner.
for (requester_is_owner, location, expected_error) in [
(true, None, None),
(false, None, None),
(true, Some("ap-south-1"), Some(S3ErrorCode::InvalidLocationConstraint)),
] {
let input = CreateBucketInput {
bucket: bucket.clone(),
create_bucket_configuration: location.map(|location| s3s_crate::dto::CreateBucketConfiguration {
location_constraint: Some(BucketLocationConstraint::from(location.to_string())),
..Default::default()
}),
..Default::default()
};
let req = build_request_with_req_info(
input,
Method::PUT,
ReqInfo {
is_owner: requester_is_owner,
..Default::default()
},
);
let result = tokio::time::timeout(Duration::from_secs(2), usecase.execute_create_bucket(req))
.await
.expect("CreateBucket on an existing bucket must not wait for the bucket lifecycle lock");
match expected_error {
Some(code) => assert_eq!(result.expect_err("invalid request must fail").code(), &code),
None => {
result.expect("recreating a bucket in us-east-1 returns the idempotent success");
}
}
}
let req = build_request(
CreateBucketInput {
bucket,
..Default::default()
},
Method::PUT,
);
let err = tokio::time::timeout(Duration::from_secs(2), usecase.execute_create_bucket(req))
.await
.expect("missing request info must fail before storage mutation")
.expect_err("an existing bucket must not bypass request-info validation");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
});
}
#[test]
#[serial_test::serial]
fn execute_create_bucket_creates_missing_bucket() {
crate::app::gating_test_env::run_large_stack_test("create-missing-bucket", || async {
let context = crate::app::gating_test_env::shared_gating_ambient().await;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let bucket = format!("create-missing-{}", uuid::Uuid::new_v4().simple());
let input = CreateBucketInput::builder().bucket(bucket.clone()).build().unwrap();
let req = build_request_with_req_info(
input,
Method::PUT,
ReqInfo {
is_owner: true,
..Default::default()
},
);
let usecase = DefaultBucketUsecase::with_context(Some(context));
usecase.execute_create_bucket(req).await.expect("create a missing bucket");
let info = store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.expect("the missing-bucket lookup must fall through to real creation");
assert_eq!(info.name, bucket);
});
}
#[tokio::test]
async fn execute_delete_bucket_returns_internal_error_when_store_uninitialized() {
let input = DeleteBucketInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::DELETE);
let usecase = DefaultBucketUsecase::without_context();
let mut request = rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard::new("DELETE");
let mut call = Box::pin(usecase.execute_delete_bucket(req));
let err = std::future::poll_fn(|cx| request.in_scope(|| call.as_mut().poll(cx)))
.await
.expect_err("an uninitialized store must fail");
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(
request.in_scope(rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_name),
Some("s3:DeleteBucket")
);
}
#[tokio::test]
async fn execute_head_bucket_returns_internal_error_when_store_uninitialized() {
let input = HeadBucketInput::builder().bucket("test-bucket".to_string()).build().unwrap();
let req = build_request(input, Method::HEAD);
let usecase = DefaultBucketUsecase::without_context();
let before = s3_op_total(S3Operation::HeadBucket);
let err = usecase.execute_head_bucket(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(s3_op_total(S3Operation::HeadBucket), before + 1);
}
#[tokio::test]
async fn execute_get_bucket_location_returns_internal_error_when_store_uninitialized() {
let input = GetBucketLocationInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::GET);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_get_bucket_location(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
}
+178
View File
@@ -0,0 +1,178 @@
// 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.
//! Bucket CORS configuration use-case operations.
use super::*;
impl DefaultBucketUsecase {
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_bucket_cors(
&self,
req: S3Request<DeleteBucketCorsInput>,
) -> S3Result<S3Response<DeleteBucketCorsOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let DeleteBucketCorsInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
delete_bucket_config_for_incarnation(&bucket, BUCKET_CORS_CONFIG, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket cors", request_context, false).await;
let item = sr_bucket_meta_item(bucket.clone(), "cors-config");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket cors delete hook failed");
}
Ok(S3Response::new(DeleteBucketCorsOutput {}))
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_bucket_cors(&self, req: S3Request<GetBucketCorsInput>) -> S3Result<PersistedCorsConfiguration> {
let GetBucketCorsInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let cors_configuration = match metadata_sys::get_cors_config(&bucket).await {
Ok((config, _)) => config,
Err(err) => {
if err == StorageError::ConfigNotFound {
return Err(S3Error::with_message(
S3ErrorCode::NoSuchCORSConfiguration,
"The CORS configuration does not exist".to_string(),
));
}
warn!(
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_BUCKET,
event = "bucket_cors_config_load_failed",
bucket = %bucket,
error = ?err,
"Failed to load bucket CORS configuration"
);
return Err(ApiError::from(err).into());
}
};
Ok(cors_configuration)
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_bucket_cors(
&self,
req: S3Request<PutBucketCorsInput>,
prepared: S3Result<PersistedCorsConfiguration>,
) -> S3Result<S3Response<PutBucketCorsOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutBucketCorsInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let cors_configuration = prepared?;
let data = serialize_config(&cors_configuration)?;
update_bucket_config_for_incarnation(&bucket, BUCKET_CORS_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket cors", request_context, false).await;
let mut item = sr_bucket_meta_item(bucket.clone(), "cors-config");
item.cors =
Some(serialize_config(&cors_configuration).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket cors hook failed");
}
Ok(S3Response::new(PutBucketCorsOutput::default()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::build_request;
use http::Method;
#[tokio::test]
async fn execute_delete_bucket_cors_returns_internal_error_when_store_uninitialized() {
let input = DeleteBucketCorsInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::DELETE);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_delete_bucket_cors(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn execute_get_bucket_cors_returns_internal_error_when_store_uninitialized() {
let input = GetBucketCorsInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::GET);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_get_bucket_cors(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn execute_put_bucket_cors_returns_internal_error_when_store_uninitialized() {
let input = PutBucketCorsInput::builder()
.bucket("test-bucket".to_string())
.cors_configuration(CORSConfiguration::default())
.build()
.unwrap();
let req = build_request(input, Method::PUT);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase
.execute_put_bucket_cors(req, Ok(PersistedCorsConfiguration::default()))
.await
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
}
+292
View File
@@ -0,0 +1,292 @@
// 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.
//! Bucket default-encryption use-case operations.
use super::*;
/// Refuse a default-encryption configuration the write path could not honour
/// as written. `bucket_default_write_sse` falls back to AES256 for any
/// algorithm it does not know, so storing one would make GetBucketEncryption
/// advertise a scheme no object is encrypted under. s3s parses `SSEAlgorithm`
/// as an open string, so the schema check has to happen here.
fn validate_bucket_encryption_configuration(config: &PersistedBucketEncryptionConfiguration) -> S3Result<()> {
if config.rules.is_empty() {
return Err(S3Error::with_message(
S3ErrorCode::MalformedXML,
"ServerSideEncryptionConfiguration must contain at least one Rule".to_string(),
));
}
for rule in &config.rules {
let Some(by_default) = rule.apply_server_side_encryption_by_default.as_ref() else {
return Err(S3Error::with_message(
S3ErrorCode::MalformedXML,
"Rule must contain ApplyServerSideEncryptionByDefault".to_string(),
));
};
let names_kms_key = by_default.kms_master_key_id.as_deref().is_some_and(|id| !id.is_empty());
match by_default.sse_algorithm.as_str() {
ServerSideEncryption::AWS_KMS => {}
ServerSideEncryption::AES256 if names_kms_key => {
return Err(S3Error::with_message(
S3ErrorCode::InvalidArgument,
"KMSMasterKeyID can only be specified when SSEAlgorithm is aws:kms".to_string(),
));
}
ServerSideEncryption::AES256 => {}
other => {
return Err(S3Error::with_message(
S3ErrorCode::MalformedXML,
format!("SSEAlgorithm {other} is not supported; expected AES256 or aws:kms"),
));
}
}
}
Ok(())
}
impl DefaultBucketUsecase {
pub async fn execute_delete_bucket_encryption(
&self,
req: S3Request<DeleteBucketEncryptionInput>,
) -> S3Result<S3Response<DeleteBucketEncryptionOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let DeleteBucketEncryptionInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
delete_bucket_config_for_incarnation(&bucket, BUCKET_SSECONFIG, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket encryption", request_context, false).await;
let item = sr_bucket_meta_item(bucket.clone(), "sse-config");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket encryption delete hook failed");
}
Ok(S3Response::with_status(DeleteBucketEncryptionOutput::default(), StatusCode::NO_CONTENT))
}
pub async fn execute_get_bucket_encryption(
&self,
req: S3Request<GetBucketEncryptionInput>,
) -> S3Result<Option<PersistedBucketEncryptionConfiguration>> {
let GetBucketEncryptionInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let server_side_encryption_configuration = match metadata_sys::get_sse_config(&bucket).await {
Ok((cfg, _)) => Some(cfg),
Err(err) => {
if err == StorageError::ConfigNotFound {
return Err(s3_error!(ServerSideEncryptionConfigurationNotFoundError));
}
warn!(
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_BUCKET,
event = "bucket_sse_config_load_failed",
bucket = %bucket,
error = ?err,
"Failed to load bucket SSE configuration"
);
return Err(ApiError::from(err).into());
}
};
Ok(server_side_encryption_configuration)
}
pub async fn execute_put_bucket_encryption(
&self,
req: S3Request<PutBucketEncryptionInput>,
prepared: S3Result<PersistedBucketEncryptionConfiguration>,
) -> S3Result<S3Response<PutBucketEncryptionOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutBucketEncryptionInput { bucket, .. } = req.input;
let mut server_side_encryption_configuration = prepared?;
validate_bucket_encryption_configuration(&server_side_encryption_configuration)?;
// When SSE-KMS is set without a specific key ID, populate the default
// KMS key so that GetBucketEncryption responses include it. Clients like
// mc rely on the presence of KMSMasterKeyID to distinguish SSE-KMS from
// SSE-S3 in their display logic.
if let Some(rule) = server_side_encryption_configuration.rules.first_mut()
&& let Some(ref mut by_default) = rule.apply_server_side_encryption_by_default
&& by_default.sse_algorithm.as_str() == ServerSideEncryption::AWS_KMS
&& by_default.kms_master_key_id.as_deref().is_none_or(str::is_empty)
{
let service = current_encryption_service()
.await
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "KMS service not initialized".to_string()))?;
let default_key = service
.get_default_key_id()
.filter(|key| !key.is_empty())
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "KMS default key not configured".to_string()))?;
by_default.kms_master_key_id = Some(default_key.clone());
}
info!("sse_config {:?}", &server_side_encryption_configuration);
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let data = serialize_config(&server_side_encryption_configuration)?;
update_bucket_config_for_incarnation(&bucket, BUCKET_SSECONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket encryption", request_context, false).await;
let mut item = sr_bucket_meta_item(bucket.clone(), "sse-config");
item.sse_config = Some(
serialize_config(&server_side_encryption_configuration)
.and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?,
);
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket encryption hook failed");
}
Ok(S3Response::new(PutBucketEncryptionOutput::default()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::build_request;
use http::Method;
use rustfs_gateway_types::persistence::{
PersistedBucketEncryptionConfiguration as ServerSideEncryptionConfiguration,
PersistedBucketEncryptionRule as ServerSideEncryptionRule, PersistedEncryptionByDefault as ServerSideEncryptionByDefault,
};
fn sse_config(rules: Vec<ServerSideEncryptionRule>) -> ServerSideEncryptionConfiguration {
ServerSideEncryptionConfiguration { rules }
}
fn sse_rule(algorithm: &str, kms_key_id: Option<&str>) -> ServerSideEncryptionRule {
ServerSideEncryptionRule {
apply_server_side_encryption_by_default: Some(ServerSideEncryptionByDefault {
sse_algorithm: algorithm.to_string(),
kms_master_key_id: kms_key_id.map(|id| id.to_string()),
}),
blocked_encryption_types: None,
bucket_key_enabled: None,
}
}
/// The stored configuration must be one the write path honours as written:
/// only AES256 and aws:kms exist, and a key id belongs to aws:kms alone.
#[test]
fn put_bucket_encryption_refuses_configurations_the_write_path_cannot_honour() {
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES256", None)])).expect("AES256 is valid");
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("aws:kms", Some("bucket-key"))]))
.expect("aws:kms with a key is valid");
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("aws:kms", None)]))
.expect("aws:kms without a key is valid (the default key is filled in)");
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES256", Some(""))]))
.expect("an empty key id on AES256 is how some clients spell 'none'");
let unknown = validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES128", None)]))
.expect_err("AES128 is not an algorithm this server encrypts with");
assert_eq!(*unknown.code(), S3ErrorCode::MalformedXML);
let misplaced = validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES256", Some("bucket-key"))]))
.expect_err("a key id only makes sense for aws:kms");
assert_eq!(*misplaced.code(), S3ErrorCode::InvalidArgument);
let empty = validate_bucket_encryption_configuration(&sse_config(Vec::new())).expect_err("no rule, no default");
assert_eq!(*empty.code(), S3ErrorCode::MalformedXML);
let bare_rule = validate_bucket_encryption_configuration(&sse_config(vec![ServerSideEncryptionRule {
apply_server_side_encryption_by_default: None,
blocked_encryption_types: None,
bucket_key_enabled: None,
}]))
.expect_err("a rule without ApplyServerSideEncryptionByDefault configures nothing");
assert_eq!(*bare_rule.code(), S3ErrorCode::MalformedXML);
// A second rule is checked too, so a malformed one cannot hide behind a valid first rule.
let second_bad =
validate_bucket_encryption_configuration(&sse_config(vec![sse_rule("AES256", None), sse_rule("garbage", None)]))
.expect_err("every rule is validated");
assert_eq!(*second_bad.code(), S3ErrorCode::MalformedXML);
}
#[tokio::test]
async fn execute_get_bucket_encryption_returns_internal_error_when_store_uninitialized() {
let input = GetBucketEncryptionInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::GET);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_get_bucket_encryption(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn execute_put_bucket_encryption_returns_internal_error_when_store_uninitialized() {
// A well-formed rule, so the request reaches the store lookup instead
// of being refused by configuration validation first.
let input = PutBucketEncryptionInput::builder()
.bucket("test-bucket".to_string())
.server_side_encryption_configuration(s3s_crate::dto::ServerSideEncryptionConfiguration {
rules: vec![s3s_crate::dto::ServerSideEncryptionRule {
apply_server_side_encryption_by_default: Some(s3s_crate::dto::ServerSideEncryptionByDefault {
sse_algorithm: ServerSideEncryption::from_static(ServerSideEncryption::AES256),
kms_master_key_id: None,
}),
blocked_encryption_types: None,
bucket_key_enabled: None,
}],
})
.build()
.unwrap();
let req = build_request(input, Method::PUT);
let prepared = crate::storage::persisted_from_s3s(&req.input.server_side_encryption_configuration);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_put_bucket_encryption(req, prepared).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
}
+120
View File
@@ -0,0 +1,120 @@
// 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.
//! Source-text guards over the bucket use-case sources.
/// Bucket usecase source: this file plus `bucket_usecase/`, where a split moves its methods.
fn bucket_usecase_sources() -> crate::guard_sources::SourceSet {
crate::guard_sources::SourceSet::read("src/app/bucket_usecase.rs", "src/app/bucket_usecase")
}
/// Text after each exact `{marker}(` or `{marker}<` definition, in set order.
fn method_definitions<'a>(source: &'a crate::guard_sources::SourceSet, marker: &str) -> Vec<&'a str> {
source
.files()
.iter()
.flat_map(|file| {
file.text
.match_indices(marker)
.map(move |(start, _)| &file.text[start + marker.len()..])
.filter(|rest| rest.starts_with(['(', '<']))
})
.collect()
}
/// The body of the one definition of `method` across the bucket usecase set, preferring the
/// public form; it ends at the next method of the same indentation or at the end of its file.
fn usecase_method_source<'a>(source: &'a crate::guard_sources::SourceSet, method: &str) -> &'a str {
let public = method_definitions(source, &format!("pub async fn {method}"));
let definitions = if public.is_empty() {
method_definitions(source, &format!("async fn {method}"))
} else {
public
};
assert_eq!(definitions.len(), 1, "{method} must be defined exactly once across {}", source.describe());
let rest = definitions[0];
let end = [rest.find("\n pub async fn "), rest.find("\n async fn ")]
.into_iter()
.flatten()
.min()
.unwrap_or(rest.len());
&rest[..end]
}
#[test]
fn bucket_create_and_delete_use_admitted_fresh_tasks() {
let sources = bucket_usecase_sources();
let source = &sources;
for (method, operation, inner_call) in [
(
"execute_create_bucket",
"bucket creation",
"execute_create_bucket_inner(req, mutation_permit).await",
),
("execute_delete_bucket", "bucket deletion", "execute_delete_bucket_inner(req).await"),
] {
let body = usecase_method_source(source, method);
assert!(
body.contains(&format!("await_bucket_usecase_on_fresh_task(\"{operation}\"")),
"{method} must use bounded detached-task admission"
);
assert!(body.contains(inner_call), "{method} must run its mutation inside the admitted task");
}
let create_body = usecase_method_source(source, "execute_create_bucket_inner");
assert!(
create_body.contains("notify_bucket_metadata_reload_with_context("),
"a successful bucket creation must refresh peer metadata caches"
);
}
#[test]
fn bucket_metadata_config_changes_notify_peer_metadata_reload() {
let sources = bucket_usecase_sources();
let source = &sources;
for (method, operation, scanner_maintenance_change) in [
("execute_delete_bucket_policy", "delete bucket policy", false),
("execute_put_bucket_policy", "put bucket policy", false),
("execute_delete_public_access_block", "delete public access block", false),
("execute_put_public_access_block", "put public access block", false),
("execute_delete_bucket_lifecycle", "delete bucket lifecycle", true),
("execute_put_bucket_lifecycle_configuration", "put bucket lifecycle", true),
("execute_put_bucket_versioning", "put bucket versioning", false),
("execute_delete_bucket_tagging", "delete bucket tagging", false),
("execute_put_bucket_tagging", "put bucket tagging", false),
("execute_delete_bucket_replication", "delete bucket replication", true),
("execute_put_bucket_replication", "put bucket replication", true),
("execute_delete_bucket_cors", "delete bucket cors", false),
("execute_put_bucket_cors", "put bucket cors", false),
("execute_delete_bucket_encryption", "delete bucket encryption", false),
("execute_put_bucket_encryption", "put bucket encryption", false),
("execute_put_bucket_notification_configuration", "put bucket notification", false),
] {
let body = usecase_method_source(source, method);
assert!(
body.contains("notify_bucket_metadata_reload("),
"{method} should notify peers to reload cached bucket metadata"
);
assert!(
body.contains(operation),
"{method} should identify the bucket metadata operation in reload logs"
);
let expected_reload = format!(
"notify_bucket_metadata_reload(bucket.clone(), \"{operation}\", request_context, {scanner_maintenance_change}).await;"
);
assert!(
body.contains(&expected_reload),
"{method} should propagate scanner_maintenance_change={scanner_maintenance_change}"
);
}
}
+588
View File
@@ -0,0 +1,588 @@
// 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.
//! Bucket lifecycle configuration use-case operations.
use super::*;
const ERR_LIFECYCLE_RULE_STATUS: &str = "Rule status must be either Enabled or Disabled";
fn assign_lifecycle_rule_ids(rules: &mut [LifecycleRule]) {
let mut rule_ids: HashSet<String> = HashSet::new();
for rule in rules.iter() {
if let Some(id) = rule.id.as_ref() {
rule_ids.insert(id.to_string());
}
}
for (idx, rule) in rules.iter_mut().enumerate() {
if rule.id.is_none() {
let mut suffix = 0usize;
let mut generated_id = format!("rule-{}", idx);
while rule_ids.contains(&generated_id) {
suffix += 1;
generated_id = format!("rule-{idx}-{suffix}");
}
rule_ids.insert(generated_id.clone());
rule.id = Some(generated_id);
}
}
}
fn validate_lifecycle_rule_status(rules: &[LifecycleRule]) -> std::result::Result<(), &'static str> {
for rule in rules {
if rule.status != ExpirationStatus::from_static(ExpirationStatus::ENABLED)
&& rule.status != ExpirationStatus::from_static(ExpirationStatus::DISABLED)
{
return Err(ERR_LIFECYCLE_RULE_STATUS);
}
}
Ok(())
}
/// Map a lifecycle validation failure onto the S3 error the client should see.
///
/// The validator reports a schema-shape violation (a `Filter` with more than
/// one predicate, a one-member `And`) with
/// [`LIFECYCLE_MALFORMED_XML_ERROR_KIND`]; AWS answers those with
/// `MalformedXML`. Everything else is a value the schema allows but S3 refuses,
/// which stays `InvalidArgument` — the code this path has always returned
/// (backlog#2201).
fn lifecycle_validation_error(err: &std::io::Error) -> S3Error {
if err.kind() == LIFECYCLE_MALFORMED_XML_ERROR_KIND {
return S3Error::with_message(S3ErrorCode::MalformedXML, format!("Malformed XML: {err}"));
}
s3_error!(InvalidArgument, "{err}")
}
fn lifecycle_has_transition_rules(config: &PersistedLifecycleConfiguration) -> bool {
config.rules.iter().any(|rule| {
rule.status == "Enabled"
&& (rule.transitions.as_ref().is_some_and(|transitions| {
transitions.iter().any(|transition| {
transition
.storage_class
.as_ref()
.is_some_and(|storage_class| !storage_class.as_str().is_empty())
})
}) || rule.noncurrent_version_transitions.as_ref().is_some_and(|transitions| {
transitions.iter().any(|transition| {
transition
.storage_class
.as_ref()
.is_some_and(|storage_class| !storage_class.as_str().is_empty())
})
}))
})
}
fn lifecycle_has_expiry_rules(config: &PersistedLifecycleConfiguration) -> bool {
config.rules.iter().any(|rule| {
rule.status == "Enabled"
&& (rule.expiration.is_some() || rule.del_marker_expiration.is_some() || rule.noncurrent_version_expiration.is_some())
})
}
/// Status-independent presence of the expiry subset that site replication
/// propagates (`replicateILMExpiry`): expiration / noncurrent-version
/// expiration only. Distinct from [`lifecycle_has_expiry_rules`], which
/// filters on ENABLED for scanner scheduling — editing a Disabled expiry rule
/// must still advance the replication axis. Del-marker expiration and
/// abort-multipart are site-local and never travel.
fn lifecycle_rules_have_expiry(config: &PersistedLifecycleConfiguration) -> bool {
config
.rules
.iter()
.any(|rule| rule.expiration.is_some() || rule.noncurrent_version_expiration.is_some())
}
fn lifecycle_has_abort_multipart_rules(config: &PersistedLifecycleConfiguration) -> bool {
config
.rules
.iter()
.any(|rule| rule.status == "Enabled" && rule.abort_incomplete_multipart_upload.is_some())
}
impl DefaultBucketUsecase {
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_bucket_lifecycle(
&self,
req: S3Request<DeleteBucketLifecycleInput>,
) -> S3Result<S3Response<DeleteBucketLifecycleOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let DeleteBucketLifecycleInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
delete_bucket_config_for_incarnation(&bucket, BUCKET_LIFECYCLE_CONFIG, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket lifecycle", request_context, true).await;
let item = sr_bucket_meta_item(bucket.clone(), "lc-config");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket lifecycle delete hook failed");
}
Ok(S3Response::new(DeleteBucketLifecycleOutput::default()))
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_bucket_lifecycle_configuration(
&self,
req: S3Request<GetBucketLifecycleConfigurationInput>,
) -> S3Result<PersistedLifecycleConfiguration> {
let GetBucketLifecycleConfigurationInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let config = match metadata_sys::get_lifecycle_config(&bucket).await {
Ok((cfg, _)) => cfg,
Err(StorageError::ConfigNotFound) => {
return Err(s3_error!(NoSuchLifecycleConfiguration));
}
// An unreadable stored configuration is not an absent one.
Err(err) => return Err(ApiError::from(err).into()),
};
Ok(config)
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_bucket_lifecycle_configuration(
&self,
req: S3Request<PutBucketLifecycleConfigurationInput>,
prepared_configuration: std::io::Result<Option<PersistedLifecycleConfiguration>>,
) -> S3Result<S3Response<PutBucketLifecycleConfigurationOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutBucketLifecycleConfigurationInput {
bucket,
lifecycle_configuration,
..
} = req.input;
let Some(mut wire_cfg) = lifecycle_configuration else { return Err(s3_error!(InvalidArgument)) };
assign_lifecycle_rule_ids(&mut wire_cfg.rules);
if let Err(err) = validate_lifecycle_rule_status(&wire_cfg.rules) {
return Err(S3Error::with_message(S3ErrorCode::MalformedXML, format!("Malformed XML: {err}")));
}
let rcfg = match metadata_sys::get_object_lock_config(&bucket).await {
Ok((cfg, _)) => cfg,
Err(StorageError::ConfigNotFound) => ObjectLockConfiguration::default(),
Err(err) => {
warn!(
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_BUCKET,
event = "bucket_object_lock_config_load_failed",
bucket = %bucket,
error = ?err,
"Failed to load bucket object lock configuration"
);
return Err(ApiError::from(err).into());
}
};
// Keep conversion failures behind the request-boundary status and Object Lock checks.
let Some(mut input_cfg) = prepared_configuration.map_err(to_internal_error)? else {
return Err(rustfs_s3_types::s3_error!(InvalidArgument).into());
};
if input_cfg.rules.len() != wire_cfg.rules.len() {
return Err(S3Error::with_message(
S3ErrorCode::InternalError,
"lifecycle conversion changed the rule count",
));
}
for (rule, wire_rule) in input_cfg.rules.iter_mut().zip(&wire_cfg.rules) {
rule.id.clone_from(&wire_rule.id);
}
if let Err(err) = validate_lifecycle_config(&input_cfg, &rcfg).await {
return Err(lifecycle_validation_error(&err));
}
if let Err(err) = validate_transition_tier(&input_cfg).await {
return Err(s3_error!(InvalidArgument, "{err}"));
}
// Stamp the expiry axis only when the expiry subset can have changed
// (MinIO: HasExpiry() || expiryRuleRemoved). Site-replication peers
// judge lc-config staleness on this axis; a transition-only edit that
// advanced it would let this site's stale expiry subset shadow — and
// roll back — a newer peer expiry edit fleet-wide.
let previous_expiry_updated_at = match metadata_sys::get_lifecycle_config(&bucket).await {
Ok((previous, _)) => {
if lifecycle_rules_have_expiry(&input_cfg) || lifecycle_rules_have_expiry(&previous) {
Some(
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.map_err(to_internal_error)?,
)
} else {
previous.expiry_updated_at
}
}
// No previous config (or unreadable): stamping is the
// conservative pre-existing behavior.
Err(_) => {
if lifecycle_rules_have_expiry(&input_cfg) {
Some(
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.map_err(to_internal_error)?,
)
} else {
None
}
}
};
input_cfg.expiry_updated_at = previous_expiry_updated_at;
let data = serialize_config(&input_cfg)?;
update_bucket_config_for_incarnation(&bucket, BUCKET_LIFECYCLE_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket lifecycle", request_context, true).await;
let mut item = sr_bucket_meta_item(bucket.clone(), "lc-config");
item.expiry_lc_config =
Some(serialize_config(&input_cfg).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
// The item travels with the expiry axis, not the wall clock: a site
// whose expiry knowledge is old (or absent — UNIX_EPOCH) must not
// out-rank newer peer expiry state at the receivers.
item.expiry_updated_at = input_cfg
.expiry_updated_at
.as_deref()
.map(|value| time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339))
.transpose()
.map_err(to_internal_error)?
.or(Some(time::OffsetDateTime::UNIX_EPOCH));
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket lifecycle hook failed");
}
if lifecycle_has_transition_rules(&input_cfg)
&& let Some(store) = self.object_store()
{
let bucket_name = bucket.clone();
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
spawn_background_with_context(request_context, async move {
if let Err(err) = enqueue_transition_for_existing_objects(store, &bucket_name).await {
warn!(bucket = %bucket_name, error = ?err, "failed to enqueue transition for existing objects");
}
});
}
if lifecycle_has_expiry_rules(&input_cfg)
&& let Some(store) = self.object_store()
{
let bucket_name = bucket.clone();
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
spawn_background_with_context(request_context, async move {
if let Err(err) = enqueue_expiry_for_existing_objects(store, &bucket_name).await {
warn!(bucket = %bucket_name, error = ?err, "failed to enqueue expiry for existing objects");
}
});
}
if lifecycle_has_abort_multipart_rules(&input_cfg)
&& let Some(store) = self.object_store()
{
let bucket_name = bucket.clone();
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
spawn_background_with_context(request_context, async move {
let deleted = run_stale_multipart_upload_cleanup_once(store).await;
debug!(bucket = %bucket_name, deleted, "completed lifecycle abort multipart cleanup trigger");
});
}
Ok(S3Response::new(PutBucketLifecycleConfigurationOutput::default()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::build_request;
use http::Method;
#[test]
fn normalize_lifecycle_rules_generate_rule_ids_for_missing_values() {
let mut rules = vec![
LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(30),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: None,
id: None,
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
},
LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(60),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: None,
id: Some("rule-1".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
},
LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: Some(LifecycleExpiration {
days: Some(90),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: None,
id: None,
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
},
];
assign_lifecycle_rule_ids(&mut rules);
assert_eq!(rules[0].id.as_deref(), Some("rule-0"));
assert_eq!(rules[1].id.as_deref(), Some("rule-1"));
assert_eq!(rules[2].id.as_deref(), Some("rule-2"));
}
#[tokio::test]
async fn put_bucket_lifecycle_validation_errors_keep_their_s3_code() {
// The PUT path answers a schema-shape violation with MalformedXML and a
// rejected value with InvalidArgument. Both categories are produced by
// the real validator here, so the mapping cannot drift from it
// (backlog#2201).
let malformed = validate_lifecycle_config(
&rustfs_gateway_types::persistence::PersistedLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![rustfs_gateway_types::persistence::PersistedLifecycleRule {
status: "Enabled".to_owned(),
expiration: Some(rustfs_gateway_types::persistence::PersistedLifecycleExpiration {
days: Some(1),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: Some(rustfs_gateway_types::persistence::PersistedLifecycleFilter {
and: Some(rustfs_gateway_types::persistence::PersistedLifecycleAnd {
prefix: Some("logs/".to_string()),
..Default::default()
}),
..Default::default()
}),
id: Some("one-member-and".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
},
&ObjectLockConfiguration::default(),
)
.await
.expect_err("a Filter And with one predicate is a schema violation");
assert_eq!(*lifecycle_validation_error(&malformed).code(), S3ErrorCode::MalformedXML);
let invalid_value = validate_lifecycle_config(
&rustfs_gateway_types::persistence::PersistedLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![rustfs_gateway_types::persistence::PersistedLifecycleRule {
status: "Enabled".to_owned(),
expiration: None,
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: None,
id: Some("negative-count".to_string()),
noncurrent_version_expiration: Some(
rustfs_gateway_types::persistence::PersistedNoncurrentVersionExpiration {
noncurrent_days: Some(30),
newer_noncurrent_versions: Some(-1),
},
),
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
},
&ObjectLockConfiguration::default(),
)
.await
.expect_err("a negative retention count is rejected");
assert_eq!(*lifecycle_validation_error(&invalid_value).code(), S3ErrorCode::InvalidArgument);
}
#[test]
fn validate_lifecycle_rule_status_rejects_invalid_status() {
let rules = vec![LifecycleRule {
status: ExpirationStatus::from_static("enabled"),
expiration: Some(LifecycleExpiration {
days: Some(30),
..Default::default()
}),
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: None,
id: None,
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}];
assert_eq!(validate_lifecycle_rule_status(&rules).unwrap_err(), ERR_LIFECYCLE_RULE_STATUS);
}
#[test]
fn lifecycle_has_transition_rules_ignores_disabled_rules() {
let config = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::DISABLED),
expiration: None,
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: None,
id: Some("disabled-transition".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: Some(vec![Transition {
days: Some(1),
date: None,
storage_class: Some(TransitionStorageClass::from_static("WARM")),
}]),
}],
};
assert!(!lifecycle_has_transition_rules(
&crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s(config).unwrap()
));
}
#[test]
fn lifecycle_has_transition_rules_accepts_enabled_noncurrent_transitions() {
let config = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: None,
abort_incomplete_multipart_upload: None,
del_marker_expiration: None,
filter: None,
id: Some("enabled-noncurrent-transition".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: Some(vec![NoncurrentVersionTransition {
noncurrent_days: Some(1),
newer_noncurrent_versions: None,
storage_class: Some(TransitionStorageClass::from_static("WARM")),
}]),
prefix: None,
transitions: None,
}],
};
assert!(lifecycle_has_transition_rules(
&crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s(config).unwrap()
));
}
#[test]
fn lifecycle_has_abort_multipart_rules_accepts_enabled_abort_rule() {
let config = BucketLifecycleConfiguration {
expiry_updated_at: None,
rules: vec![LifecycleRule {
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
expiration: None,
abort_incomplete_multipart_upload: Some(s3s_crate::dto::AbortIncompleteMultipartUpload {
days_after_initiation: Some(0),
}),
del_marker_expiration: None,
filter: None,
id: Some("enabled-abort".to_string()),
noncurrent_version_expiration: None,
noncurrent_version_transitions: None,
prefix: None,
transitions: None,
}],
};
assert!(lifecycle_has_abort_multipart_rules(
&crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s(config.clone()).unwrap()
));
assert!(!lifecycle_has_expiry_rules(
&crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s(config).unwrap()
));
}
#[tokio::test]
async fn execute_put_bucket_lifecycle_configuration_rejects_missing_configuration() {
let input = PutBucketLifecycleConfigurationInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::PUT);
let usecase = DefaultBucketUsecase::without_context();
let prepared = req
.input
.lifecycle_configuration
.clone()
.map(crate::storage::s3_compat::lifecycle::lifecycle_configuration_from_s3s)
.transpose();
let err = usecase
.execute_put_bucket_lifecycle_configuration(req, prepared)
.await
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
// 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.
//! Bucket logging configuration use-case operations (reserved for the next batch).
@@ -0,0 +1,493 @@
// 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.
//! Bucket notification configuration use-case operations.
use super::*;
fn is_valid_notification_filter_value(value: &str) -> bool {
if value.len() > 1024 || value.contains('\\') {
return false;
}
!value.split('/').any(|segment| segment == "." || segment == "..")
}
fn invalid_filter_value_message(cfg_scope: &str, value: &str) -> String {
format!("invalid notification filter value (len={}) ({cfg_scope})", value.len())
}
fn invalid_filter_name_message(cfg_scope: &str, name: &str) -> String {
format!(
"invalid notification filter name (len={}) (only 'prefix'/'suffix' are supported) ({cfg_scope})",
name.len()
)
}
fn validate_notification_filter_rules(
filter: Option<&NotificationConfigurationFilter>,
cfg_kind: &str,
cfg_id: Option<&str>,
) -> S3Result<()> {
let Some(filter) = filter else {
return Ok(());
};
let Some(s3key_filter) = filter.key.as_ref() else {
return Ok(());
};
let Some(rules) = s3key_filter.filter_rules.as_ref() else {
return Ok(());
};
let mut has_prefix = false;
let mut has_suffix = false;
let cfg_scope = cfg_id.map_or_else(|| cfg_kind.to_string(), |id| format!("{cfg_kind} id={id}"));
for rule in rules {
let Some(name) = rule.name.as_ref() else {
return Err(s3_error!(InvalidArgument, "invalid notification filter rule: missing Name ({cfg_scope})"));
};
let Some(value) = rule.value.as_ref() else {
return Err(s3_error!(
InvalidArgument,
"invalid notification filter rule: missing Value ({cfg_scope})"
));
};
if !is_valid_notification_filter_value(value) {
return Err(s3_error!(InvalidArgument, "{}", invalid_filter_value_message(&cfg_scope, value)));
}
if name.as_str().eq_ignore_ascii_case("prefix") {
if has_prefix {
return Err(s3_error!(InvalidArgument, "duplicate notification filter name 'prefix' ({cfg_scope})"));
}
has_prefix = true;
} else if name.as_str().eq_ignore_ascii_case("suffix") {
if has_suffix {
return Err(s3_error!(InvalidArgument, "duplicate notification filter name 'suffix' ({cfg_scope})"));
}
has_suffix = true;
} else {
return Err(s3_error!(InvalidArgument, "{}", invalid_filter_name_message(&cfg_scope, name.as_str())));
}
}
Ok(())
}
fn validate_notification_configuration_filters(notification_configuration: &NotificationConfiguration) -> S3Result<()> {
if let Some(queue_configs) = notification_configuration.queue_configurations.as_ref() {
for cfg in queue_configs {
validate_notification_filter_rules(cfg.filter.as_ref(), "QueueConfiguration", cfg.id.as_deref())?;
}
}
if let Some(topic_configs) = notification_configuration.topic_configurations.as_ref() {
for cfg in topic_configs {
validate_notification_filter_rules(cfg.filter.as_ref(), "TopicConfiguration", cfg.id.as_deref())?;
}
}
if let Some(lambda_configs) = notification_configuration.lambda_function_configurations.as_ref() {
for cfg in lambda_configs {
validate_notification_filter_rules(cfg.filter.as_ref(), "LambdaFunctionConfiguration", cfg.id.as_deref())?;
}
}
Ok(())
}
fn parse_notification_target_id(arn_str: &str) -> Result<TargetID, TargetIDError> {
ARN::parse(arn_str)
.map(|arn| arn.target_id)
.map_err(|e| TargetIDError::InvalidFormat(e.to_string()))
}
type NotificationEventRule = (Vec<EventName>, String, String, Vec<TargetID>);
/// Builds the notify runtime rules for a bucket notification configuration
/// without touching the store or the runtime rule state.
fn build_notification_event_rules(
notification_configuration: &NotificationConfiguration,
) -> S3Result<Vec<NotificationEventRule>> {
let mut event_rules = Vec::new();
let invalid_arn = |e: TargetIDError| {
S3Error::with_message(S3ErrorCode::InvalidArgument, format!("Invalid ARN in notification configuration: {e}"))
};
process_queue_configurations(
&mut event_rules,
notification_configuration.queue_configurations.clone(),
parse_notification_target_id,
)
.map_err(invalid_arn)?;
process_topic_configurations(
&mut event_rules,
notification_configuration.topic_configurations.clone(),
parse_notification_target_id,
)
.map_err(invalid_arn)?;
process_lambda_configurations(
&mut event_rules,
notification_configuration.lambda_function_configurations.clone(),
parse_notification_target_id,
)
.map_err(invalid_arn)?;
Ok(event_rules)
}
fn resolve_notification_region(global_region: Option<Region>, request_region: Option<Region>) -> String {
global_region
.or(request_region)
.map(|region| region.to_string())
.unwrap_or_else(|| RUSTFS_REGION.to_string())
}
impl DefaultBucketUsecase {
pub async fn execute_get_bucket_notification_configuration(
&self,
req: S3Request<GetBucketNotificationConfigurationInput>,
) -> S3Result<Option<PersistedNotificationConfiguration>> {
let GetBucketNotificationConfigurationInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let has_notification_config = match metadata_sys::get_notification_config(&bucket).await {
Ok(config) => config,
// An unreadable config is not "no notifications configured".
Err(err) if crate::storage_api::error::is_unreadable_config_error(&err) => {
return Err(ApiError::from(err).into());
}
Err(err) => {
warn!(
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_BUCKET,
event = "bucket_notification_config_load_failed",
bucket = %bucket,
error = ?err,
"Failed to load bucket notification configuration"
);
None
}
};
Ok(has_notification_config)
}
pub async fn execute_put_bucket_notification_configuration(
&self,
req: S3Request<PutBucketNotificationConfigurationInput>,
prepared: S3Result<PersistedNotificationConfiguration>,
) -> S3Result<S3Response<PutBucketNotificationConfigurationOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_region = req.region.clone();
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutBucketNotificationConfigurationInput {
bucket,
notification_configuration,
..
} = req.input;
validate_notification_configuration_filters(&notification_configuration)?;
let persisted_notification_configuration = prepared?;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let region = resolve_notification_region(self.global_region(), request_region);
let notify = current_notify_interface_for_context(self.context.as_deref());
let event_rules = build_notification_event_rules(&notification_configuration)?;
// Reject the request before the store write so a failure cannot leave a
// persisted configuration that the notify runtime refused to activate.
notify
.validate_event_specific_rules(&bucket, region.as_str(), &event_rules)
.await
.map_err(|e| s3_error!(InternalError, "Failed to add rules: {e}"))?;
let data = serialize_config(&persisted_notification_configuration)?;
update_bucket_config_for_incarnation(&bucket, BUCKET_NOTIFICATION_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket notification", request_context, false).await;
notify
.clear_bucket_notification_rules(&bucket)
.await
.map_err(|e| s3_error!(InternalError, "Failed to clear rules: {e}"))?;
warn!("notify event rules: {:?}", &event_rules);
notify
.add_event_specific_rules(&bucket, region.as_str(), &event_rules)
.await
.map_err(|e| s3_error!(InternalError, "Failed to add rules: {e}"))?;
Ok(S3Response::new(PutBucketNotificationConfigurationOutput {}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::build_request;
use http::Method;
#[test]
fn resolve_notification_region_prefers_global_region() {
let binding = resolve_notification_region(Some("us-east-1".parse().unwrap()), Some("ap-southeast-1".parse().unwrap()));
assert_eq!(binding, "us-east-1");
}
#[test]
fn resolve_notification_region_falls_back_to_request_region() {
let binding = resolve_notification_region(None, Some("ap-southeast-1".parse().unwrap()));
assert_eq!(binding, "ap-southeast-1");
}
#[test]
fn resolve_notification_region_defaults_value() {
let binding = resolve_notification_region(None, None);
assert_eq!(binding, RUSTFS_REGION);
}
#[test]
fn validate_notification_configuration_filters_rejects_invalid_filter_name() {
let raw_name = "unsupported".repeat(100);
let cfg = NotificationConfiguration {
queue_configurations: Some(vec![QueueConfiguration {
id: Some("q1".to_string()),
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
events: vec!["s3:ObjectCreated:*".to_string().into()],
filter: Some(NotificationConfigurationFilter {
key: Some(S3KeyFilter {
filter_rules: Some(vec![FilterRule {
name: Some(FilterRuleName::from(raw_name.clone())),
value: Some("uploads/".to_string()),
}]),
}),
}),
}]),
..Default::default()
};
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
let msg = err.message().unwrap_or_default();
assert!(msg.contains("len="), "error message should include summarized length");
assert!(!msg.contains(&raw_name), "error message should not echo full raw filter name");
}
#[test]
fn validate_notification_configuration_filters_accepts_case_insensitive_filter_names() {
let cfg = NotificationConfiguration {
queue_configurations: Some(vec![QueueConfiguration {
id: Some("q1".to_string()),
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
events: vec!["s3:ObjectCreated:*".to_string().into()],
filter: Some(NotificationConfigurationFilter {
key: Some(S3KeyFilter {
filter_rules: Some(vec![
FilterRule {
name: Some(FilterRuleName::from("Prefix".to_string())),
value: Some("uploads/".to_string()),
},
FilterRule {
name: Some(FilterRuleName::from("Suffix".to_string())),
value: Some(".csv".to_string()),
},
]),
}),
}),
}]),
..Default::default()
};
validate_notification_configuration_filters(&cfg).expect("capitalized filter names should be accepted");
}
#[test]
fn validate_notification_configuration_filters_rejects_duplicate_prefix_rules() {
let cfg = NotificationConfiguration {
queue_configurations: Some(vec![QueueConfiguration {
id: Some("q1".to_string()),
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
events: vec!["s3:ObjectCreated:*".to_string().into()],
filter: Some(NotificationConfigurationFilter {
key: Some(S3KeyFilter {
filter_rules: Some(vec![
FilterRule {
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
value: Some("uploads/".to_string()),
},
FilterRule {
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
value: Some("images/".to_string()),
},
]),
}),
}),
}]),
..Default::default()
};
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
#[test]
fn validate_notification_configuration_filters_rejects_invalid_filter_value() {
let cfg = NotificationConfiguration {
queue_configurations: Some(vec![QueueConfiguration {
id: Some("q1".to_string()),
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
events: vec!["s3:ObjectCreated:*".to_string().into()],
filter: Some(NotificationConfigurationFilter {
key: Some(S3KeyFilter {
filter_rules: Some(vec![FilterRule {
name: Some(FilterRuleName::from_static(FilterRuleName::SUFFIX)),
value: Some("../secret".to_string()),
}]),
}),
}),
}]),
..Default::default()
};
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
let msg = err.message().unwrap_or_default();
assert!(msg.contains("len="), "error message should include summarized length");
assert!(!msg.contains("../secret"), "error message should not echo full raw filter value");
}
#[test]
fn validate_notification_configuration_filters_rejects_missing_filter_name() {
let cfg = NotificationConfiguration {
queue_configurations: Some(vec![QueueConfiguration {
id: Some("q1".to_string()),
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
events: vec!["s3:ObjectCreated:*".to_string().into()],
filter: Some(NotificationConfigurationFilter {
key: Some(S3KeyFilter {
filter_rules: Some(vec![FilterRule {
name: None,
value: Some("uploads/".to_string()),
}]),
}),
}),
}]),
..Default::default()
};
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
#[test]
fn validate_notification_configuration_filters_rejects_missing_filter_value() {
let cfg = NotificationConfiguration {
queue_configurations: Some(vec![QueueConfiguration {
id: Some("q1".to_string()),
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
events: vec!["s3:ObjectCreated:*".to_string().into()],
filter: Some(NotificationConfigurationFilter {
key: Some(S3KeyFilter {
filter_rules: Some(vec![FilterRule {
name: Some(FilterRuleName::from_static(FilterRuleName::PREFIX)),
value: None,
}]),
}),
}),
}]),
..Default::default()
};
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
#[test]
fn validate_notification_configuration_filters_rejects_duplicate_suffix_rules() {
let cfg = NotificationConfiguration {
queue_configurations: Some(vec![QueueConfiguration {
id: Some("q1".to_string()),
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
events: vec!["s3:ObjectCreated:*".to_string().into()],
filter: Some(NotificationConfigurationFilter {
key: Some(S3KeyFilter {
filter_rules: Some(vec![
FilterRule {
name: Some(FilterRuleName::from_static(FilterRuleName::SUFFIX)),
value: Some(".csv".to_string()),
},
FilterRule {
name: Some(FilterRuleName::from_static(FilterRuleName::SUFFIX)),
value: Some(".log".to_string()),
},
]),
}),
}),
}]),
..Default::default()
};
let err = validate_notification_configuration_filters(&cfg).unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
#[tokio::test]
async fn execute_put_bucket_notification_configuration_rejects_invalid_filter_before_store_lookup() {
let input = PutBucketNotificationConfigurationInput::builder()
.bucket("test-bucket".to_string())
.notification_configuration(NotificationConfiguration {
queue_configurations: Some(vec![QueueConfiguration {
id: Some("q1".to_string()),
queue_arn: "arn:rustfs:sqs:us-east-1:1:webhook".to_string(),
events: vec!["s3:ObjectCreated:*".to_string().into()],
filter: Some(NotificationConfigurationFilter {
key: Some(S3KeyFilter {
filter_rules: Some(vec![FilterRule {
name: Some(FilterRuleName::from("unsupported".to_string())),
value: Some("uploads/".to_string()),
}]),
}),
}),
}]),
..Default::default()
})
.build()
.unwrap();
let req = build_request(input, Method::PUT);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase
.execute_put_bucket_notification_configuration(req, Ok(PersistedNotificationConfiguration::default()))
.await
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
}
@@ -0,0 +1,15 @@
// 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.
//! Bucket object-lock configuration use-case operations (reserved for the next batch).
+294
View File
@@ -0,0 +1,294 @@
// 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.
//! Bucket policy and policy status use-case operations.
use super::*;
use rustfs_policy::policy::Validator;
impl DefaultBucketUsecase {
pub async fn execute_delete_bucket_policy(
&self,
req: S3Request<DeleteBucketPolicyInput>,
) -> S3Result<S3Response<DeleteBucketPolicyOutput>> {
record_s3_op(S3Operation::DeleteBucketPolicy);
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let DeleteBucketPolicyInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
delete_bucket_config_for_incarnation(&bucket, BUCKET_POLICY_CONFIG, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket policy", request_context, false).await;
let item = sr_bucket_meta_item(bucket.clone(), "policy");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket policy delete hook failed");
}
Ok(S3Response::new(DeleteBucketPolicyOutput {}))
}
pub async fn execute_get_bucket_policy(
&self,
req: S3Request<GetBucketPolicyInput>,
) -> S3Result<S3Response<GetBucketPolicyOutput>> {
let GetBucketPolicyInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let (policy_str, _) = match metadata_sys::get_bucket_policy_raw(&bucket).await {
Ok(res) => res,
Err(err) => {
if StorageError::ConfigNotFound == err {
return Err(s3_error!(NoSuchBucketPolicy));
}
return Err(S3Error::with_message(S3ErrorCode::InternalError, err.to_string()));
}
};
Ok(S3Response::new(GetBucketPolicyOutput {
policy: Some(policy_str),
}))
}
pub async fn execute_get_bucket_policy_status(
&self,
req: S3Request<GetBucketPolicyStatusInput>,
) -> S3Result<S3Response<GetBucketPolicyStatusOutput>> {
let GetBucketPolicyStatusInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let remote_addr = req.extensions.get::<Option<RemoteAddr>>().and_then(|opt| opt.map(|a| a.0));
let client_info = req.extensions.get::<ClientInfo>();
let conditions = get_condition_values_with_client_info(
&req.headers,
&rustfs_credentials::Credentials::default(),
None,
None,
remote_addr,
client_info,
);
let read_allowed = PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: &bucket,
action: Action::S3Action(S3Action::ListBucketAction),
is_owner: false,
account: "",
groups: &None,
conditions: &conditions,
object: "",
})
.await
.map_err(ApiError::from)?;
let write_allowed = PolicySys::try_is_allowed(&BucketPolicyArgs {
bucket: &bucket,
action: Action::S3Action(S3Action::PutObjectAction),
is_owner: false,
account: "",
groups: &None,
conditions: &conditions,
object: "",
})
.await
.map_err(ApiError::from)?;
let mut is_public = read_allowed || write_allowed;
let ignore_public_acls = match metadata_sys::get_public_access_block_config(&bucket).await {
Ok((config, _)) => config.ignore_public_acls.unwrap_or(false),
Err(_) => false,
};
let _ = ignore_public_acls;
let policy_public = match metadata_sys::get_bucket_policy(&bucket).await {
Ok((cfg, _)) => cfg.statements.iter().any(|statement| {
matches!(statement.effect, Effect::Allow)
&& statement.principal.is_match("*")
&& statement.conditions.is_empty()
&& statement.actions.is_match(&Action::S3Action(S3Action::ListBucketAction))
}),
Err(err) => {
if err == StorageError::ConfigNotFound {
false
} else {
return Err(ApiError::from(err).into());
}
}
};
if policy_public {
is_public = true;
}
let output = GetBucketPolicyStatusOutput {
policy_status: Some(PolicyStatus {
is_public: Some(is_public),
}),
};
Ok(S3Response::new(output))
}
pub async fn execute_put_bucket_policy(
&self,
req: S3Request<PutBucketPolicyInput>,
) -> S3Result<S3Response<PutBucketPolicyOutput>> {
record_s3_op(S3Operation::PutBucketPolicy);
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutBucketPolicyInput { bucket, policy, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let cfg: BucketPolicy =
serde_json::from_str(&policy).map_err(|e| s3_error!(InvalidArgument, "parse policy failed {:?}", e))?;
if let Err(err) = cfg.is_valid() {
warn!("put_bucket_policy err input {:?}, {:?}", &policy, err);
return Err(s3_error!(MalformedPolicy));
}
let is_public_policy = cfg
.statements
.iter()
.any(|statement| matches!(statement.effect, Effect::Allow) && statement.principal.is_match("*"));
if is_public_policy {
match metadata_sys::get_public_access_block_config(&bucket).await {
Ok((config, _)) => {
if config.block_public_policy.unwrap_or(false) {
return Err(s3_error!(AccessDenied, "Access Denied"));
}
}
Err(err) => {
if err != StorageError::ConfigNotFound {
warn!(
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_BUCKET,
event = "bucket_public_access_block_load_failed",
bucket = %bucket,
error = ?err,
"Failed to load bucket public access block configuration"
);
return Err(ApiError::from(err).into());
}
}
}
}
let data = policy.as_bytes().to_vec();
update_bucket_config_for_incarnation(&bucket, BUCKET_POLICY_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket policy", request_context, false).await;
let mut item = sr_bucket_meta_item(bucket.clone(), "policy");
item.policy = Some(serde_json::from_str(&policy).map_err(|e| s3_error!(InvalidArgument, "parse policy failed {:?}", e))?);
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket policy hook failed");
}
Ok(S3Response::new(PutBucketPolicyOutput {}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::{build_request, s3_op_total};
use http::Method;
#[tokio::test]
async fn execute_delete_bucket_policy_records_s3_operation_before_store_lookup() {
let input = DeleteBucketPolicyInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::DELETE);
let usecase = DefaultBucketUsecase::without_context();
let before = s3_op_total(S3Operation::DeleteBucketPolicy);
let err = usecase.execute_delete_bucket_policy(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(s3_op_total(S3Operation::DeleteBucketPolicy), before + 1);
}
#[tokio::test]
async fn execute_get_bucket_policy_returns_internal_error_when_store_uninitialized() {
let input = GetBucketPolicyInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::GET);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_get_bucket_policy(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn execute_put_bucket_policy_returns_internal_error_when_store_uninitialized() {
let input = PutBucketPolicyInput::builder()
.bucket("test-bucket".to_string())
.policy("{}".to_string())
.build()
.unwrap();
let req = build_request(input, Method::PUT);
let usecase = DefaultBucketUsecase::without_context();
let before = s3_op_total(S3Operation::PutBucketPolicy);
let err = usecase.execute_put_bucket_policy(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(s3_op_total(S3Operation::PutBucketPolicy), before + 1);
}
}
@@ -0,0 +1,161 @@
// 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.
//! Bucket public access block use-case operations.
use super::*;
impl DefaultBucketUsecase {
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_public_access_block(
&self,
req: S3Request<DeletePublicAccessBlockInput>,
) -> S3Result<S3Response<DeletePublicAccessBlockOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let DeletePublicAccessBlockInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
delete_bucket_config_for_incarnation(&bucket, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete public access block", request_context, false).await;
Ok(S3Response::with_status(DeletePublicAccessBlockOutput::default(), StatusCode::NO_CONTENT))
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_public_access_block(
&self,
req: S3Request<GetPublicAccessBlockInput>,
) -> S3Result<PersistedPublicAccessBlockConfiguration> {
let GetPublicAccessBlockInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let config = match metadata_sys::get_public_access_block_config(&bucket).await {
Ok((config, _)) => config,
Err(err) => {
if err == StorageError::ConfigNotFound {
return Err(S3Error::with_message(
S3ErrorCode::Custom("NoSuchPublicAccessBlockConfiguration".into()),
"Public access block configuration does not exist".to_string(),
));
}
return Err(ApiError::from(err).into());
}
};
Ok(config)
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_public_access_block(
&self,
req: S3Request<PutPublicAccessBlockInput>,
prepared: S3Result<PersistedPublicAccessBlockConfiguration>,
) -> S3Result<S3Response<PutPublicAccessBlockOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutPublicAccessBlockInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let public_access_block_configuration = prepared?;
let data = serialize_config(&public_access_block_configuration)?;
update_bucket_config_for_incarnation(&bucket, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put public access block", request_context, false).await;
Ok(S3Response::new(PutPublicAccessBlockOutput::default()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::build_request;
use http::Method;
#[tokio::test]
async fn execute_delete_public_access_block_returns_internal_error_when_store_uninitialized() {
let input = DeletePublicAccessBlockInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::DELETE);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_delete_public_access_block(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn execute_get_public_access_block_returns_internal_error_when_store_uninitialized() {
let input = GetPublicAccessBlockInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::GET);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_get_public_access_block(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn execute_put_public_access_block_returns_internal_error_when_store_uninitialized() {
let input = PutPublicAccessBlockInput::builder()
.bucket("test-bucket".to_string())
.public_access_block_configuration(PublicAccessBlockConfiguration::default())
.build()
.unwrap();
let req = build_request(input, Method::PUT);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase
.execute_put_public_access_block(req, Ok(PersistedPublicAccessBlockConfiguration::default()))
.await
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
}
@@ -0,0 +1,912 @@
// 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.
//! Bucket replication configuration use-case operations.
use super::*;
fn validate_replication_config_targets(targets: &BucketTargets, config: &PersistedReplicationConfiguration) -> S3Result<()> {
let configured_arns = targets
.targets
.iter()
.filter(|target| target.target_type == BucketTargetType::ReplicationService)
.map(|target| target.arn.as_str());
match validate_replication_config_target_arns(configured_arns, config) {
Ok(()) => Ok(()),
Err(err) => {
let message = match err {
ReplicationTargetValidationError::RoleWithMultipleDestinations => {
"replication config with Role cannot define multiple destination targets"
}
ReplicationTargetValidationError::StaleTarget => "replication config has a stale target",
};
Err(S3Error::with_message(S3ErrorCode::InvalidRequest, message))
}
}
}
fn validate_replication_config_capabilities(config: &PersistedReplicationConfiguration) -> S3Result<()> {
if let Err(err) = validate_replication_config_structure(config) {
return Err(S3Error::with_message(S3ErrorCode::InvalidRequest, err.message()));
}
if let Some(field) = invalid_replication_config_status_field(config) {
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("replication field {field} has an invalid status"),
));
}
if let Some(field) = unsupported_replication_config_field(config) {
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("replication field {field} is not supported by this RustFS version"),
));
}
Ok(())
}
async fn validate_bucket_replication_update(bucket: &str, config: &PersistedReplicationConfiguration) -> S3Result<()> {
if !BucketVersioningSys::enabled(bucket).await {
return Err(s3_error!(
InvalidRequest,
"bucket versioning must be enabled before replication can be configured"
));
}
let targets = metadata_sys::get_bucket_targets_config(bucket)
.await
.map_err(|err| match err {
StorageError::ConfigNotFound => {
S3Error::with_message(S3ErrorCode::InvalidRequest, "replication target configuration not found".to_string())
}
other => ApiError::from(other).into(),
})?;
validate_replication_config_targets(&targets, config)
}
/// Defense in depth for site-replication-managed buckets (issue #1948): an S3
/// PutBucketReplication replaces the operator-authored rules but must not wipe
/// the rules the reconciler derived for the current remote peers
/// (`site_peer_deployment_ids`) — until its next pass (600s period) every
/// peer link on this bucket would be silently dead. The same merge also drops
/// incoming impostors of those rules. An empty peer set (site replication
/// disabled) keeps the verbatim overwrite semantics: rule ids are not
/// reserved, so an operator's own `site-repl-*` rule is ordinary state there.
/// `contract` is what the peers were probed to support; the merged config is
/// what gets broadcast, so it is built the way every peer will merge it.
fn merge_user_replication_config_update(
incoming: PersistedReplicationConfiguration,
existing: Option<PersistedReplicationConfiguration>,
site_peer_deployment_ids: &HashSet<String>,
contract: OperatorRuleContract,
) -> PersistedReplicationConfiguration {
if site_peer_deployment_ids.is_empty() {
return incoming;
}
// `incoming` passed structure validation, so it holds at least one rule;
// `None` is only reachable when every incoming rule impersonates a
// reconciler rule, and then the stored reconciler rules are what remains.
merge_user_replication_config(Some(incoming.clone()), existing, site_peer_deployment_ids, contract).unwrap_or(incoming)
}
/// Split of an S3 DeleteBucketReplication on the stored config (issue #1948):
/// the operator-authored rules are removed, the rules the reconciler derived
/// for the current remote peers survive (`None` means nothing survives and
/// the config is deleted), and the returned ARNs are the ones whose bucket
/// targets may be garbage-collected — never an ARN a surviving reconciler
/// rule still points at.
fn split_replication_config_for_user_delete(
config: PersistedReplicationConfiguration,
site_peer_deployment_ids: &HashSet<String>,
contract: OperatorRuleContract,
) -> (Option<PersistedReplicationConfiguration>, HashSet<String>) {
let mut removable_arns = replication_target_arns(&config);
let remaining = merge_user_replication_config(None, Some(config), site_peer_deployment_ids, contract);
if let Some(remaining) = remaining.as_ref() {
for rule in &remaining.rules {
removable_arns.remove(rule.destination.bucket.trim());
}
}
(remaining, removable_arns)
}
async fn replication_targets_without_arns(
bucket: &str,
target_arns: &HashSet<String>,
) -> S3Result<Option<(BucketTargets, usize)>> {
if target_arns.is_empty() {
return Ok(None);
}
let mut targets = match metadata_sys::get_bucket_targets_config(bucket).await {
Ok(targets) => targets,
Err(StorageError::ConfigNotFound) => return Ok(None),
Err(err) => return Err(ApiError::from(err).into()),
};
let removed = remove_replication_targets_from_config_targets(&mut targets, target_arns);
if removed == 0 {
return Ok(None);
}
Ok(Some((targets, removed)))
}
fn remove_replication_targets_from_config_targets(targets: &mut BucketTargets, target_arns: &HashSet<String>) -> usize {
let original_len = targets.targets.len();
targets.targets.retain(|target| {
!should_remove_replication_target(
target.arn.as_str(),
target.target_type == BucketTargetType::ReplicationService,
target_arns,
)
});
original_len - targets.targets.len()
}
async fn write_replication_targets_after_config_delete(
bucket: &str,
targets: &BucketTargets,
removed: usize,
expected_incarnation_id: Option<uuid::Uuid>,
) -> S3Result<()> {
let json_targets = serde_json::to_vec(&targets).map_err(to_internal_error)?;
update_bucket_config_for_incarnation(bucket, BUCKET_TARGETS_FILE, json_targets, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
info!(bucket = %bucket, removed, "removed replication remote targets referenced by deleted bucket replication config");
Ok(())
}
async fn restore_replication_config_after_target_cleanup_failure(
bucket: &str,
config: &PersistedReplicationConfiguration,
cleanup_err: S3Error,
expected_incarnation_id: Option<uuid::Uuid>,
) -> S3Error {
match serialize(config) {
Ok(data) => {
if let Err(restore_err) =
update_bucket_config_for_incarnation(bucket, BUCKET_REPLICATION_CONFIG, data, expected_incarnation_id).await
{
error!(
bucket = %bucket,
error = ?restore_err,
cleanup_error = ?cleanup_err,
"failed to restore bucket replication config after target cleanup failure"
);
}
}
Err(restore_err) => {
error!(
bucket = %bucket,
error = ?restore_err,
cleanup_error = ?cleanup_err,
"failed to serialize bucket replication config for restore after target cleanup failure"
);
}
}
cleanup_err
}
impl DefaultBucketUsecase {
/// `site_peers` is the set of remote site-replication peer deployment ids
/// (empty when site replication is disabled). The interface layer reads it
/// from the persisted state and fails closed on a read error, so this
/// usecase stays a pure function of its inputs (layer rule: app never
/// imports interface).
pub async fn execute_delete_bucket_replication(
&self,
req: S3Request<DeleteBucketReplicationInput>,
site_peers: HashSet<String>,
contract: OperatorRuleContract,
) -> S3Result<S3Response<DeleteBucketReplicationOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let DeleteBucketReplicationInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let targets_guard = lock_bucket_targets_metadata(&bucket).await;
let replication_config = match metadata_sys::get_replication_config(&bucket).await {
Ok((config, _)) => Some(config),
Err(StorageError::ConfigNotFound) => None,
Err(err) => return Err(ApiError::from(err).into()),
};
let (remaining_config, updated_targets) = if let Some(config) = replication_config.as_ref() {
let (remaining, removable_arns) = split_replication_config_for_user_delete(config.clone(), &site_peers, contract);
let targets = replication_targets_without_arns(&bucket, &removable_arns).await?;
(remaining, targets)
} else {
(None, None)
};
match remaining_config {
// Site-replication rules and the targets backing them survive the
// S3 delete (issue #1948); only the operator-authored rules go.
Some(remaining) => {
let data = serialize_config(&remaining)?;
update_bucket_config_for_incarnation(&bucket, BUCKET_REPLICATION_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
}
None => {
delete_bucket_config_for_incarnation(&bucket, BUCKET_REPLICATION_CONFIG, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
}
}
if let Some((targets, removed)) = updated_targets
&& let Err(err) =
write_replication_targets_after_config_delete(&bucket, &targets, removed, expected_incarnation_id).await
{
if let Some(config) = replication_config.as_ref() {
return Err(restore_replication_config_after_target_cleanup_failure(
&bucket,
config,
err,
expected_incarnation_id,
)
.await);
}
return Err(err);
}
drop(targets_guard);
notify_bucket_metadata_reload(bucket.clone(), "delete bucket replication", request_context, true).await;
let item = sr_bucket_meta_item(bucket.clone(), "replication-config");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket replication-config delete hook failed");
}
info!(bucket = %bucket, "deleted bucket replication config");
Ok(S3Response::new(DeleteBucketReplicationOutput::default()))
}
/// Answers the stored configuration in the engine's shape; the s3s edge
/// (`storage::ecfs`) converts it into the `GetBucketReplication` output.
pub async fn execute_get_bucket_replication(
&self,
req: S3Request<GetBucketReplicationInput>,
) -> S3Result<PersistedReplicationConfiguration> {
let GetBucketReplicationInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let replication_configuration = match metadata_sys::get_replication_config(&bucket).await {
Ok((cfg, _created)) => cfg,
Err(StorageError::ConfigNotFound) => {
return Err(S3Error::with_message(
S3ErrorCode::ReplicationConfigurationNotFoundError,
"replication not found".to_string(),
));
}
Err(err) => {
warn!(
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_BUCKET,
event = "bucket_replication_config_load_failed",
bucket = %bucket,
error = ?err,
"Failed to load bucket replication configuration"
);
return Err(ApiError::from(err).into());
}
};
Ok(replication_configuration)
}
/// See [`Self::execute_delete_bucket_replication`] for `site_peers` and
/// `contract`. `replication_configuration` is the request body in the
/// engine's shape: the s3s edge (`storage::ecfs`) converts it, and this use
/// case never reads `req.input.replication_configuration`.
pub async fn execute_put_bucket_replication(
&self,
req: S3Request<PutBucketReplicationInput>,
replication_configuration: PersistedReplicationConfiguration,
site_peers: HashSet<String>,
contract: OperatorRuleContract,
) -> S3Result<S3Response<PutBucketReplicationOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutBucketReplicationInput { bucket, .. } = req.input;
info!(bucket = %bucket, "updating bucket replication config");
validate_replication_config_capabilities(&replication_configuration)?;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let targets_guard = lock_bucket_targets_metadata(&bucket).await;
validate_bucket_replication_update(&bucket, &replication_configuration).await?;
let existing_config = match metadata_sys::get_replication_config(&bucket).await {
Ok((config, _)) => Some(config),
Err(StorageError::ConfigNotFound) => None,
Err(err) => return Err(ApiError::from(err).into()),
};
let replication_configuration =
merge_user_replication_config_update(replication_configuration, existing_config, &site_peers, contract);
let data = serialize_config(&replication_configuration)?;
update_bucket_config_for_incarnation(&bucket, BUCKET_REPLICATION_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
drop(targets_guard);
notify_bucket_metadata_reload(bucket.clone(), "put bucket replication", request_context, true).await;
let mut item = sr_bucket_meta_item(bucket.clone(), "replication-config");
item.replication_config = Some(
serialize_config(&replication_configuration).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?,
);
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket replication-config hook failed");
}
Ok(S3Response::new(PutBucketReplicationOutput::default()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::build_request;
use http::Method;
use rustfs_gateway_types::dto::Status;
use rustfs_gateway_types::persistence::{
PersistedEncryptionConfiguration, PersistedReplicationDestination, PersistedReplicationRule,
};
fn replication_rule_for_target(arn: &str) -> PersistedReplicationRule {
PersistedReplicationRule {
delete_marker_replication: None,
delete_replication: None,
destination: PersistedReplicationDestination {
bucket: arn.to_string(),
..Default::default()
},
existing_object_replication: None,
filter: None,
id: Some("rule-1".to_string()),
prefix: None,
priority: Some(1),
source_selection_criteria: None,
status: Status::ENABLED.to_string(),
}
}
#[test]
fn replication_target_arns_use_role_when_present() {
let role = "arn:rustfs:replication:us-east-1:source:bucket";
let destination = "arn:rustfs:replication:us-east-1:target:bucket";
let config = PersistedReplicationConfiguration {
role: format!(" {role} "),
rules: vec![replication_rule_for_target(destination)],
};
let arns = replication_target_arns(&config);
assert!(arns.contains(role));
assert!(!arns.contains(destination));
}
#[test]
fn replication_target_arns_use_rule_destinations_without_role() {
let destination = "arn:rustfs:replication:us-east-1:target:bucket";
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule_for_target(destination)],
};
let arns = replication_target_arns(&config);
assert!(arns.contains(destination));
}
fn replication_rule_with_id(arn: &str, id: &str, priority: i32) -> PersistedReplicationRule {
let mut rule = replication_rule_for_target(arn);
rule.id = Some(id.to_string());
rule.priority = Some(priority);
rule
}
fn site_peers(deployment_ids: &[&str]) -> HashSet<String> {
deployment_ids.iter().map(|id| id.to_string()).collect()
}
#[test]
fn put_replication_merge_preserves_site_replication_rules() {
let existing = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![
replication_rule_with_id("arn:rustfs:replication::peer-dep:bucket", "site-repl-peer-dep", 1),
replication_rule_with_id("arn:rustfs:replication:us-east-1:old:bucket", "old-user-rule", 2),
],
};
let incoming = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![
replication_rule_with_id("arn:rustfs:replication:us-east-1:new:bucket", "new-user-rule", 1),
replication_rule_with_id("arn:rustfs:replication::forged-dep:bucket", "site-repl-peer-dep", 2),
replication_rule_with_id("arn:rustfs:replication::other-dep:bucket", "site-repl-other", 3),
],
};
let merged = merge_user_replication_config_update(
incoming,
Some(existing),
&site_peers(&["peer-dep"]),
OperatorRuleContract::Derived,
);
let rules: Vec<_> = merged
.rules
.iter()
.map(|rule| (rule.id.as_deref().unwrap_or_default(), rule.destination.bucket.as_str()))
.collect();
assert_eq!(
rules,
vec![
("new-user-rule", "arn:rustfs:replication:us-east-1:new:bucket"),
("site-repl-other", "arn:rustfs:replication::other-dep:bucket"),
("site-repl-peer-dep", "arn:rustfs:replication::peer-dep:bucket"),
],
"user rules replaced, the reconciler rule for the current peer kept over the incoming impostor, \
a site-repl-* id that names no current peer is ordinary operator state"
);
}
// Rule ids do not reserve `site-repl-*`: outside site replication an
// owner's `site-repl-user` rule is ordinary state, so PUT stores it
// verbatim and DELETE removes it and garbage-collects its target.
#[test]
fn put_then_delete_replication_without_site_replication_treats_site_repl_id_as_user_rule() {
let user_arn = "arn:minio:replication:us-east-1:2f1c-remote:bucket";
let incoming = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule_with_id(user_arn, "site-repl-user", 1)],
};
let stored = merge_user_replication_config_update(incoming.clone(), None, &HashSet::new(), OperatorRuleContract::Derived);
assert_eq!(stored, incoming, "PUT on a non-site-replication bucket is verbatim");
let (remaining, removable) =
split_replication_config_for_user_delete(stored, &HashSet::new(), OperatorRuleContract::Derived);
assert!(remaining.is_none(), "DELETE must remove the operator's site-repl-* rule");
assert_eq!(removable, HashSet::from([user_arn.to_string()]));
}
// Under site replication only a rule the reconciler would derive — id
// `site-repl-<peer>` for a current peer, destination ARN naming the same
// peer — is reconciler-owned. Everything else is operator state.
#[test]
fn delete_replication_split_keeps_only_reconciler_derived_rules() {
let peer_arn = "arn:rustfs:replication::peer-dep:bucket";
let user_arn = "arn:minio:replication:us-east-1:2f1c-remote:bucket";
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![
replication_rule_with_id(user_arn, "site-repl-user", 1),
replication_rule_with_id(user_arn, "site-repl-peer-dep", 2),
replication_rule_with_id("arn:rustfs:replication::gone-dep:bucket", "site-repl-gone-dep", 3),
replication_rule_with_id(peer_arn, "site-repl-peer-dep", 4),
],
};
let (remaining, removable) =
split_replication_config_for_user_delete(config, &site_peers(&["peer-dep"]), OperatorRuleContract::Derived);
let remaining = remaining.expect("the reconciler-derived rule must survive");
assert_eq!(remaining.rules.len(), 1);
assert_eq!(remaining.rules[0].destination.bucket, peer_arn);
assert_eq!(
removable,
HashSet::from([user_arn.to_string(), "arn:rustfs:replication::gone-dep:bucket".to_string()]),
"targets of operator rules and of a removed peer are garbage-collected"
);
}
#[test]
fn put_replication_merge_returns_incoming_verbatim_without_site_rules() {
let existing = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule_with_id(
"arn:rustfs:replication:us-east-1:old:bucket",
"old-user-rule",
7,
)],
};
let incoming = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule_with_id(
"arn:rustfs:replication:us-east-1:new:bucket",
"new-user-rule",
5,
)],
};
let merged = merge_user_replication_config_update(
incoming.clone(),
Some(existing),
&HashSet::new(),
OperatorRuleContract::Derived,
);
assert_eq!(merged.role, incoming.role);
assert_eq!(merged.rules, incoming.rules, "non-SR buckets keep the verbatim overwrite semantics");
}
#[test]
fn delete_replication_split_keeps_site_rules_and_their_targets() {
let sr_arn = "arn:rustfs:replication::peer-dep:bucket";
let user_arn = "arn:rustfs:replication:us-east-1:user:bucket";
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![
replication_rule_with_id(user_arn, "user-rule", 1),
replication_rule_with_id(sr_arn, "site-repl-peer-dep", 2),
],
};
let (remaining, removable) =
split_replication_config_for_user_delete(config, &site_peers(&["peer-dep"]), OperatorRuleContract::Derived);
let remaining = remaining.expect("site-replication rules must survive a user delete");
let ids: Vec<_> = remaining
.rules
.iter()
.map(|rule| rule.id.as_deref().unwrap_or_default())
.collect();
assert_eq!(ids, vec!["site-repl-peer-dep"]);
assert_eq!(removable, HashSet::from([user_arn.to_string()]));
}
#[test]
fn delete_replication_split_protects_targets_shared_with_site_rules() {
let sr_arn = "arn:rustfs:replication::peer-dep:bucket";
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![
replication_rule_with_id(sr_arn, "user-rule-on-sr-target", 1),
replication_rule_with_id(sr_arn, "site-repl-peer-dep", 2),
],
};
let (remaining, removable) =
split_replication_config_for_user_delete(config, &site_peers(&["peer-dep"]), OperatorRuleContract::Derived);
assert!(remaining.is_some());
assert!(
removable.is_empty(),
"a target still referenced by a surviving site-replication rule must not be removed"
);
}
#[test]
fn delete_replication_split_removes_everything_without_site_rules() {
let user_arn = "arn:rustfs:replication:us-east-1:user:bucket";
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule_with_id(user_arn, "user-rule", 1)],
};
let (remaining, removable) =
split_replication_config_for_user_delete(config, &site_peers(&["peer-dep"]), OperatorRuleContract::Derived);
assert!(remaining.is_none(), "without site-replication rules the whole config is deleted");
assert_eq!(removable, HashSet::from([user_arn.to_string()]));
}
fn replication_targets_with_arn(arns: &[&str]) -> BucketTargets {
BucketTargets {
targets: arns
.iter()
.map(|arn| BucketTarget {
arn: (*arn).to_string(),
target_type: BucketTargetType::ReplicationService,
..Default::default()
})
.collect(),
}
}
#[test]
fn validate_replication_config_targets_accepts_matching_destination_arns() {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let targets = replication_targets_with_arn(&[arn]);
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule_for_target(arn)],
};
validate_replication_config_targets(&targets, &config).expect("matching target should pass validation");
}
#[test]
fn validate_replication_config_targets_rejects_stale_destination_arns() {
let targets = replication_targets_with_arn(&["arn:rustfs:replication:us-east-1:target:bucket-a"]);
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule_for_target(
"arn:rustfs:replication:us-east-1:target:bucket-b",
)],
};
let err = validate_replication_config_targets(&targets, &config).expect_err("stale target should fail validation");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
}
#[test]
fn validate_replication_config_targets_accepts_matching_role_arn() {
let arn = "arn:rustfs:replication:us-east-1:role-target:bucket";
let targets = replication_targets_with_arn(&[arn]);
let config = PersistedReplicationConfiguration {
role: format!(" {arn} "),
rules: vec![replication_rule_for_target("arn:rustfs:replication:us-east-1:ignored:bucket")],
};
validate_replication_config_targets(&targets, &config).expect("matching role ARN should pass validation");
}
#[test]
fn validate_replication_config_targets_rejects_role_with_multiple_destinations() {
let role = "arn:rustfs:replication:us-east-1:role-target:bucket";
let targets = replication_targets_with_arn(&[role]);
let config = PersistedReplicationConfiguration {
role: role.to_string(),
rules: vec![
replication_rule_for_target("arn:rustfs:replication:us-east-1:target-a:bucket"),
replication_rule_for_target("arn:rustfs:replication:us-east-1:target-b:bucket"),
],
};
let err = validate_replication_config_targets(&targets, &config)
.expect_err("role plus multiple destinations should be rejected");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
}
#[test]
fn validate_replication_config_targets_trims_destination_arns() {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let targets = replication_targets_with_arn(&[arn]);
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![replication_rule_for_target(
" arn:rustfs:replication:us-east-1:target:bucket ",
)],
};
validate_replication_config_targets(&targets, &config).expect("trimmed destination ARN should match configured target");
}
#[test]
fn validate_replication_config_targets_ignores_disabled_rules() {
let targets = replication_targets_with_arn(&[]);
let mut rule = replication_rule_for_target("arn:rustfs:replication:us-east-1:stale:bucket");
rule.status = Status::DISABLED.to_string();
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
validate_replication_config_targets(&targets, &config).expect("disabled rules should not require live targets");
}
#[test]
fn validate_replication_config_capabilities_names_unsupported_field() {
let mut rule = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
let destination_key_id = "arn:aws:kms:us-east-1:123456789012:key/opaque-key-id";
rule.destination.encryption_configuration = Some(PersistedEncryptionConfiguration {
replica_kms_key_id: Some(destination_key_id.to_string()),
});
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
let err = validate_replication_config_capabilities(&config)
.expect_err("destination encryption must be rejected until the execution path supports it");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(
err.to_string()
.contains("Destination.EncryptionConfiguration is not supported")
);
assert!(!err.to_string().contains(destination_key_id));
}
#[test]
fn validate_replication_config_capabilities_rejects_structural_defects_before_write() {
let mut first = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
first.priority = Some(1);
let mut second = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
second.priority = Some(1);
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![first, second],
};
let err = validate_replication_config_capabilities(&config)
.expect_err("duplicate rule priorities must be rejected before persistence");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(err.to_string().contains("Priority must be unique"));
}
#[test]
fn validate_replication_config_capabilities_rejects_invalid_status_before_write() {
let mut rule = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
rule.status = "Invalid".to_owned();
let config = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
let err = validate_replication_config_capabilities(&config)
.expect_err("an invalid string-backed replication status must be rejected before persistence");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(err.to_string().contains("Rule.Status has an invalid status"));
}
#[test]
fn remove_replication_targets_from_config_targets_only_removes_referenced_replication_targets() {
let removed_arn = "arn:rustfs:replication:us-east-1:removed:bucket";
let kept_replication_arn = "arn:rustfs:replication:us-east-1:kept:bucket";
let kept_ilm_arn = "arn:rustfs:ilm:us-east-1:kept:bucket";
let mut targets = BucketTargets {
targets: vec![
BucketTarget {
arn: removed_arn.to_string(),
target_type: BucketTargetType::ReplicationService,
..Default::default()
},
BucketTarget {
arn: kept_replication_arn.to_string(),
target_type: BucketTargetType::ReplicationService,
..Default::default()
},
BucketTarget {
arn: kept_ilm_arn.to_string(),
target_type: BucketTargetType::IlmService,
..Default::default()
},
],
};
let target_arns = HashSet::from([removed_arn.to_string(), kept_ilm_arn.to_string()]);
let removed = remove_replication_targets_from_config_targets(&mut targets, &target_arns);
assert_eq!(removed, 1);
let remaining_arns = targets
.targets
.iter()
.map(|target| target.arn.as_str())
.collect::<HashSet<_>>();
assert!(!remaining_arns.contains(removed_arn));
assert!(remaining_arns.contains(kept_replication_arn));
assert!(remaining_arns.contains(kept_ilm_arn));
}
#[tokio::test]
async fn execute_delete_bucket_replication_returns_internal_error_when_store_uninitialized() {
let input = DeleteBucketReplicationInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::DELETE);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase
.execute_delete_bucket_replication(req, HashSet::new(), OperatorRuleContract::Derived)
.await
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn execute_get_bucket_replication_returns_internal_error_when_store_uninitialized() {
let input = GetBucketReplicationInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::GET);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_get_bucket_replication(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn execute_put_bucket_replication_returns_internal_error_when_store_uninitialized() {
// The config must clear the structural/capability validators so the
// request actually reaches the store lookup this test pins.
let configuration = PersistedReplicationConfiguration {
role: "arn:aws:iam::123456789012:role/test".to_string(),
rules: vec![replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket")],
};
let input = PutBucketReplicationInput::builder()
.bucket("test-bucket".to_string())
.replication_configuration(Default::default())
.build()
.unwrap();
let req = build_request(input, Method::PUT);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase
.execute_put_bucket_replication(req, configuration, HashSet::new(), OperatorRuleContract::Derived)
.await
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[tokio::test]
async fn execute_put_bucket_replication_rejects_unsupported_fields_before_store_or_metadata_write() {
let mut rule = replication_rule_for_target("arn:rustfs:replication:us-east-1:target:bucket");
rule.destination.account = Some("123456789012".to_string());
let configuration = PersistedReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
let input = PutBucketReplicationInput::builder()
.bucket("test-bucket".to_string())
.replication_configuration(Default::default())
.build()
.unwrap();
let err = DefaultBucketUsecase::without_context()
.execute_put_bucket_replication(
build_request(input, Method::PUT),
configuration,
HashSet::new(),
OperatorRuleContract::Derived,
)
.await
.expect_err("unsupported fields must be rejected before store access");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert_eq!(
err.message(),
Some("replication field Destination.Account is not supported by this RustFS version")
);
}
}
@@ -0,0 +1,15 @@
// 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.
//! Bucket request-payment configuration use-case operations (reserved for the next batch).
+247
View File
@@ -0,0 +1,247 @@
// 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.
//! Bucket tagging use-case operations.
use super::*;
impl DefaultBucketUsecase {
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_delete_bucket_tagging(
&self,
req: S3Request<DeleteBucketTaggingInput>,
) -> S3Result<S3Response<DeleteBucketTaggingOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let DeleteBucketTaggingInput { bucket, .. } = req.input;
metadata_sys::update_s3_tagging_config(&bucket, None, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "delete bucket tagging", request_context, false).await;
let item = sr_bucket_meta_item(bucket.clone(), "tags");
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket tagging delete hook failed");
}
rustfs_scanner::record_dirty_usage_bucket_from_producer(
&bucket,
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
);
Ok(S3Response::new(DeleteBucketTaggingOutput {}))
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_bucket_tagging(&self, req: S3Request<GetBucketTaggingInput>) -> S3Result<PersistedTagging> {
let GetBucketTaggingInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
let tagging = match metadata_sys::get_tagging_config(&bucket).await {
Ok((tags, _)) => tags,
Err(err) => {
if err == StorageError::ConfigNotFound {
return Err(S3Error::with_message(S3ErrorCode::NoSuchTagSet, "The TagSet does not exist".to_string()));
}
warn!(
component = LOG_COMPONENT_APP,
subsystem = LOG_SUBSYSTEM_BUCKET,
event = "bucket_tagging_config_load_failed",
bucket = %bucket,
error = ?err,
"Failed to load bucket tagging configuration"
);
return Err(ApiError::from(err).into());
}
};
Ok(tagging)
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_bucket_tagging(
&self,
req: S3Request<PutBucketTaggingInput>,
tagging: PersistedTagging,
) -> S3Result<S3Response<PutBucketTaggingOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutBucketTaggingInput { bucket, .. } = req.input;
let s3_tag_set: Vec<s3s_crate::dto::Tag> = tagging
.tag_set
.iter()
.map(|tag| s3s_crate::dto::Tag {
key: tag.key.clone(),
value: tag.value.clone(),
})
.collect();
crate::app::storage_api::bucket_usecase::s3_api::tagging::validate_bucket_tag_set(&s3_tag_set)?;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
metadata_sys::update_s3_tagging_config(&bucket, Some(tagging.clone()), expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket tagging", request_context, false).await;
let mut item = sr_bucket_meta_item(bucket.clone(), "tags");
item.tags = Some(serialize_config(&tagging).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?);
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket tagging hook failed");
}
rustfs_scanner::record_dirty_usage_bucket_from_producer(
&bucket,
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
);
Ok(S3Response::new(PutBucketTaggingOutput::default()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::build_request;
use http::Method;
#[tokio::test]
async fn execute_get_bucket_tagging_returns_internal_error_when_store_uninitialized() {
let input = GetBucketTaggingInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::GET);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_get_bucket_tagging(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
#[test]
#[serial_test::serial]
fn put_bucket_tagging_rejects_invalid_shapes_before_storage_mutation() {
crate::app::gating_test_env::run_large_stack_test("bucket-tag-validation", || async {
use crate::app::runtime_sources::{AppContext, ServerContextSlot};
use crate::app::storage_api::test::ReqInfo;
use crate::app::storage_api::test::ecfs::FS;
use s3s_crate::access::S3Access as _;
let ambient = crate::app::gating_test_env::shared_gating_ambient().await;
let store = crate::app::gating_test_env::shared_gating_ecstore().await;
let context = Arc::new(AppContext::new(store.clone(), ambient.iam(), ambient.kms()));
let slot = ServerContextSlot::new();
assert!(slot.install(context.clone()));
let fs = FS::with_server_ctx(slot.clone());
let usecase = DefaultBucketUsecase::with_context(Some(context));
let bucket = format!("tag-validation-{}", uuid::Uuid::new_v4().simple());
store.make_bucket(&bucket, &MakeBucketOptions::default()).await.unwrap();
store
.update_bucket_metadata_config(
&bucket,
BUCKET_POLICY_CONFIG,
serde_json::to_vec(
&serde_json::json!({"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},
"Action":"s3:PutBucketTagging","Resource":format!("arn:aws:s3:::{bucket}")}]}),
)
.unwrap(),
)
.await
.unwrap();
let tag = s3s_crate::dto::Tag {
key: Some("team".to_owned()),
value: Some("storage".to_owned()),
};
for (tag_set, valid) in [
(vec![tag.clone()], true),
(
vec![s3s_crate::dto::Tag {
key: Some("team".to_owned()),
value: None,
}],
false,
),
(vec![tag.clone(), tag], false),
] {
let persisted = PersistedTagging {
tag_set: tag_set
.iter()
.map(|tag| rustfs_gateway_types::cors_tagging::PersistedTag {
key: tag.key.clone(),
value: tag.value.clone(),
})
.collect(),
};
let input = PutBucketTaggingInput::builder()
.bucket(bucket.clone())
.tagging(s3s_crate::dto::Tagging { tag_set })
.build()
.unwrap();
let mut req = build_request(input, Method::PUT);
req.extensions.insert(ReqInfo::default());
req.extensions.insert(slot.clone());
fs.put_bucket_tagging(&mut req).await.expect("permission allows tag updates");
let result = usecase.execute_put_bucket_tagging(req, persisted).await;
if valid {
result.expect("valid tags are persisted");
} else {
assert_eq!(result.unwrap_err().code(), &S3ErrorCode::InvalidTag);
}
assert_eq!(
store.get_bucket_tags_for_policy(&bucket).await.unwrap(),
HashMap::from([("team".to_owned(), "storage".to_owned())])
);
}
});
}
#[tokio::test]
async fn execute_put_bucket_tagging_returns_internal_error_when_store_uninitialized() {
let input = PutBucketTaggingInput::builder()
.bucket("test-bucket".to_string())
.tagging(Tagging {
tag_set: vec![Tag {
key: Some("env".to_string()),
value: Some("prod".to_string()),
}],
})
.build()
.unwrap();
let req = build_request(input, Method::PUT);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase
.execute_put_bucket_tagging(req, PersistedTagging::default())
.await
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
}
@@ -0,0 +1,46 @@
// 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.
//! Request fixtures shared by the bucket use-case tests.
use super::*;
use http::{Extensions, HeaderMap, Method, Uri};
pub(super) fn s3_op_total(op: S3Operation) -> u64 {
rustfs_io_metrics::s3_op_metrics_snapshot()
.into_iter()
.find(|snapshot| snapshot.op == op.as_str())
.map(|snapshot| snapshot.total)
.unwrap_or_default()
}
pub(super) fn build_request<T>(input: T, method: Method) -> S3Request<T> {
S3Request {
input,
method,
uri: Uri::from_static("/"),
headers: HeaderMap::new(),
extensions: Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
}
}
pub(super) fn build_request_with_req_info<T>(input: T, method: Method, req_info: ReqInfo) -> S3Request<T> {
let mut req = build_request(input, method);
req.extensions.insert(req_info);
req
}
+193
View File
@@ -0,0 +1,193 @@
// 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.
//! Bucket versioning use-case operations.
use super::*;
use crate::app::storage_api::bucket_usecase::bucket::{ObjectLockConfigExt as _, VersioningConfigExt as _};
fn versioning_configuration_has_object_lock_incompatible_settings(config: &PersistedVersioningConfiguration) -> bool {
config.suspended()
|| config.exclude_folders.unwrap_or(false)
|| config
.excluded_prefixes
.as_ref()
.is_some_and(|excluded_prefixes| !excluded_prefixes.is_empty())
}
async fn validate_bucket_versioning_update(bucket: &str, config: &PersistedVersioningConfiguration) -> S3Result<()> {
if config
.status
.as_ref()
.is_some_and(|status| !matches!(status.as_str(), "Enabled" | "Suspended"))
{
return Err(S3Error::with_message(
S3ErrorCode::InvalidArgument,
"bucket versioning configuration has an invalid status",
));
}
match metadata_sys::get_object_lock_config(bucket).await {
Ok((object_lock_config, _)) => {
if object_lock_config.enabled() && versioning_configuration_has_object_lock_incompatible_settings(config) {
return Err(S3Error::with_message(
S3ErrorCode::InvalidBucketState,
"An Object Lock configuration is present on this bucket, versioning cannot be suspended.".to_string(),
));
}
}
Err(StorageError::ConfigNotFound) => {}
Err(err) => return Err(ApiError::from(err).into()),
}
// AWS S3 and MinIO both refuse to suspend versioning while a replication
// configuration exists: suspension would start minting null versions that
// the replication engine (versioned by contract) can never converge.
if config.suspended() {
match metadata_sys::get_replication_config(bucket).await {
Ok(_) => {
return Err(S3Error::with_message(
S3ErrorCode::InvalidBucketState,
"A replication configuration is present on this bucket, bucket wide versioning cannot be suspended."
.to_string(),
));
}
Err(StorageError::ConfigNotFound) => {}
Err(err) => return Err(ApiError::from(err).into()),
}
}
Ok(())
}
impl DefaultBucketUsecase {
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_get_bucket_versioning(
&self,
req: S3Request<GetBucketVersioningInput>,
) -> S3Result<PersistedVersioningConfiguration> {
let GetBucketVersioningInput { bucket, .. } = req.input;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
store
.get_bucket_info(&bucket, &BucketOptions::default())
.await
.map_err(ApiError::from)?;
BucketVersioningSys::get(&bucket)
.await
.map_err(|err| ApiError::from(err).into())
}
#[instrument(level = "debug", skip(self, req))]
pub async fn execute_put_bucket_versioning(
&self,
req: S3Request<PutBucketVersioningInput>,
prepared: S3Result<PersistedVersioningConfiguration>,
) -> S3Result<S3Response<PutBucketVersioningOutput>> {
let expected_incarnation_id = bucket_config_mutation_incarnation(&req, &req.input.bucket)?;
let request_context = req.extensions.get::<request_context::RequestContext>().cloned();
let PutBucketVersioningInput { bucket, .. } = req.input;
let versioning_configuration = prepared?;
validate_bucket_versioning_update(&bucket, &versioning_configuration).await?;
let data = serialize_config(&versioning_configuration)?;
update_bucket_config_for_incarnation(&bucket, BUCKET_VERSIONING_CONFIG, data, expected_incarnation_id)
.await
.map_err(ApiError::from)?;
notify_bucket_metadata_reload(bucket.clone(), "put bucket versioning", request_context, false).await;
let mut item = sr_bucket_meta_item(bucket.clone(), "version-config");
item.versioning = Some(
serialize_config(&versioning_configuration).and_then(|bytes| String::from_utf8(bytes).map_err(to_internal_error))?,
);
if let Err(err) = site_replication_bucket_meta_hook(item).await {
warn!(bucket = %bucket, error = ?err, "site replication bucket versioning hook failed");
}
rustfs_scanner::record_dirty_usage_bucket_from_producer(
&bucket,
rustfs_scanner::SegmentInvalidationProducerIdentity::BucketMetadata,
);
Ok(S3Response::new(PutBucketVersioningOutput {}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::bucket_usecase::test_support::build_request;
use http::Method;
#[tokio::test]
async fn validate_bucket_versioning_update_rejects_invalid_status_before_metadata_lookup() {
let config = PersistedVersioningConfiguration {
status: Some("Invalid".to_owned()),
..Default::default()
};
let err = validate_bucket_versioning_update("unregistered-test-bucket", &config)
.await
.expect_err("an invalid string-backed versioning status must be rejected before persistence");
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
}
#[test]
fn versioning_configuration_has_object_lock_incompatible_settings_rejects_suspended() {
let config = PersistedVersioningConfiguration {
status: Some("Suspended".to_owned()),
..Default::default()
};
assert!(versioning_configuration_has_object_lock_incompatible_settings(&config));
}
#[test]
fn versioning_configuration_has_object_lock_incompatible_settings_rejects_exclude_folders() {
let config = PersistedVersioningConfiguration {
exclude_folders: Some(true),
..Default::default()
};
assert!(versioning_configuration_has_object_lock_incompatible_settings(&config));
}
#[test]
fn versioning_configuration_has_object_lock_incompatible_settings_rejects_excluded_prefixes() {
let config = PersistedVersioningConfiguration {
excluded_prefixes: Some(vec![Some("archive/".to_string())]),
..Default::default()
};
assert!(versioning_configuration_has_object_lock_incompatible_settings(&config));
}
#[tokio::test]
async fn execute_get_bucket_versioning_returns_internal_error_when_store_uninitialized() {
let input = GetBucketVersioningInput::builder()
.bucket("test-bucket".to_string())
.build()
.unwrap();
let req = build_request(input, Method::GET);
let usecase = DefaultBucketUsecase::without_context();
let err = usecase.execute_get_bucket_versioning(req).await.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InternalError);
}
}
+15
View File
@@ -0,0 +1,15 @@
// 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.
//! Bucket website configuration use-case operations (reserved for the next batch).
-1
View File
@@ -26,7 +26,6 @@ mod startup;
pub(crate) use envelope::RequestHead;
pub use envelope::{RequestCredentials, RequestEnvelope};
pub(crate) use envelope::{authorize_owned_internal_object_request, authorize_owned_request};
pub use global::*;
pub use handles::*;
pub use interfaces::*;
+11 -422
View File
@@ -29,19 +29,13 @@ use super::ServerContextSlot;
use crate::app::metadata_route::WebsiteRead;
use crate::app::object::request_body::BodyReadControl;
use crate::app::storage_api::context::{
AuditRequestView, AuthorizationRequest, DenialAuditor, DenialScope, authorize_request_core,
};
use crate::app::storage_api::context::{
BucketConfigMutationSnapshot, BucketGenerationGuard, CopySourceBucketGenerationGuard, MultipartStagingOperation,
ObjectTagConditions, OdmReadGenerationGuard, PendingDeleteBucketGenerationGuard, PostObjectPolicyHeaders,
PostObjectRequestMarker, ReqInfo, RequestContext, RequestedCopySource, RequestedObjectLegalHold,
RequestedObjectLockRetainUntil, RequestedObjectLockRetention, RequestedObjectTags, StagedMultipartPartAuthorization,
BucketConfigMutationSnapshot, BucketGenerationGuard, CopySourceBucketGenerationGuard, ObjectTagConditions,
OdmReadGenerationGuard, PendingDeleteBucketGenerationGuard, PostObjectRequestMarker, ReqInfo, RequestContext,
TableDataPlaneListAccess, TableDataPlanePublicationGuards,
};
use crate::auth::{VerifiedPresignedRequest, VerifiedSigV4Request};
use crate::shared_types::{NativeTlsTransport, RemoteAddr};
use http::{Extensions, HeaderMap, Method, Uri};
use rustfs_policy::policy::action::{Action, S3Action};
use rustfs_trusted_proxies::ClientInfo;
use std::fmt;
use std::net::SocketAddr;
@@ -51,109 +45,42 @@ use zeroize::Zeroizing;
/// The identity a request was authenticated with.
///
/// The secret key and the session token are bearer material: the type has no
/// `PartialEq`, and its `Debug` output redacts both. An edge that keeps the
/// caller's secret (it verified the signature itself) builds the value with
/// [`Self::without_secret_key`]; the absence is then `None`, never a
/// substitute string a reader could compare, seal or derive with.
/// `PartialEq`, and its `Debug` output redacts both.
#[derive(Clone)]
pub struct RequestCredentials {
access_key: String,
secret_key: Option<Zeroizing<String>>,
secret_key: Zeroizing<String>,
session_token: Option<Zeroizing<String>>,
}
/// An edge handed over an empty secret key. Absence is expressed with
/// [`RequestCredentials::without_secret_key`], so an empty secret is refused
/// rather than read as "absent" or used as a key.
#[derive(Debug)]
pub(crate) struct EmptySecretKey;
impl fmt::Display for EmptySecretKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("handed-over secret key is empty")
}
}
impl std::error::Error for EmptySecretKey {}
impl RequestCredentials {
/// Credentials whose secret the edge handed over.
///
/// # Errors
///
/// [`EmptySecretKey`] when `secret_key` is empty.
pub(crate) fn new(access_key: String, secret_key: String, session_token: Option<String>) -> Result<Self, EmptySecretKey> {
if secret_key.is_empty() {
return Err(EmptySecretKey);
}
Ok(Self {
access_key,
secret_key: Some(Zeroizing::new(secret_key)),
session_token: session_token.map(Zeroizing::new),
})
}
/// Credentials whose secret the edge kept.
pub(crate) fn without_secret_key(access_key: String, session_token: Option<String>) -> Self {
pub(crate) fn new(access_key: String, secret_key: String, session_token: Option<String>) -> Self {
Self {
access_key,
secret_key: None,
secret_key: Zeroizing::new(secret_key),
session_token: session_token.map(Zeroizing::new),
}
}
/// The same identity with `session_token` as its token.
#[cfg(test)]
pub(crate) fn with_session_token(mut self, session_token: Option<String>) -> Self {
self.session_token = session_token.map(Zeroizing::new);
self
}
pub fn access_key(&self) -> &str {
&self.access_key
}
/// The caller's secret, or `None` when the edge did not hand it over.
/// A reader must fail closed on `None`. Never log this value or compare it
/// with `==`.
pub fn secret_key(&self) -> Option<&str> {
self.secret_key.as_deref().map(String::as_str)
/// Never log this value or compare it with `==`.
pub fn secret_key(&self) -> &str {
&self.secret_key
}
pub fn session_token(&self) -> Option<&str> {
self.session_token.as_deref().map(String::as_str)
}
/// The handed-over secret, or [`MissingSecretKey`] when the edge kept it.
pub(crate) fn require_secret_key(&self) -> Result<&str, MissingSecretKey> {
self.secret_key().ok_or(MissingSecretKey)
}
/// Whether the handed-over secret equals `stored`, compared in constant
/// time. An absent secret matches nothing, not even an empty `stored`.
pub(crate) fn secret_key_matches(&self, stored: &str) -> bool {
self.secret_key()
.is_some_and(|secret| crate::auth::constant_time_eq(secret, stored))
}
}
/// A reader needed the caller's secret, but the edge did not hand it over.
#[derive(Debug)]
pub(crate) struct MissingSecretKey;
impl fmt::Display for MissingSecretKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("the caller's secret key was not handed over for this request")
}
}
impl std::error::Error for MissingSecretKey {}
impl fmt::Debug for RequestCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RequestCredentials")
.field("access_key", &self.access_key)
.field("secret_key", &self.secret_key.as_ref().map(|_| "<redacted>"))
.field("secret_key", &"<redacted>")
.field("session_token", &self.session_token.as_ref().map(|_| "<redacted>"))
.finish()
}
@@ -201,7 +128,6 @@ pub struct RequestEnvelope {
verified_presigned: Option<VerifiedPresignedRequest>,
website_read: Option<WebsiteRead>,
post_object: Option<PostObjectRequestMarker>,
post_object_policy_headers: Option<PostObjectPolicyHeaders>,
// Attached by the authorization phase (`storage/access.rs`) for the use case.
req_info: Option<ReqInfo>,
bucket_generation: Option<BucketGenerationGuard>,
@@ -212,13 +138,6 @@ pub struct RequestEnvelope {
table_publication_guards: Option<TableDataPlanePublicationGuards>,
table_list_access: Option<TableDataPlaneListAccess>,
object_tag_conditions: Option<ObjectTagConditions>,
multipart_staging_operation: Option<MultipartStagingOperation>,
staged_multipart_part_authorization: Option<StagedMultipartPartAuthorization>,
requested_object_lock_retention: Option<RequestedObjectLockRetention>,
requested_object_lock_retain_until: Option<RequestedObjectLockRetainUntil>,
requested_object_legal_hold: Option<RequestedObjectLegalHold>,
requested_object_tags: Option<RequestedObjectTags>,
requested_copy_source: Option<RequestedCopySource>,
residual_extensions: Extensions,
}
@@ -248,7 +167,6 @@ impl RequestEnvelope {
verified_presigned: extensions.remove(),
website_read: extensions.remove(),
post_object: extensions.remove(),
post_object_policy_headers: extensions.remove(),
req_info: extensions.remove(),
bucket_generation: extensions.remove(),
pending_delete_bucket_generation: extensions.remove(),
@@ -258,13 +176,6 @@ impl RequestEnvelope {
table_publication_guards: extensions.remove(),
table_list_access: extensions.remove(),
object_tag_conditions: extensions.remove(),
multipart_staging_operation: extensions.remove(),
staged_multipart_part_authorization: extensions.remove(),
requested_object_lock_retention: extensions.remove(),
requested_object_lock_retain_until: extensions.remove(),
requested_object_legal_hold: extensions.remove(),
requested_object_tags: extensions.remove(),
requested_copy_source: extensions.remove(),
residual_extensions: extensions,
}
}
@@ -401,10 +312,6 @@ impl RequestEnvelope {
self.table_publication_guards.as_ref()
}
pub(crate) fn install_table_publication_guards(&mut self, guards: TableDataPlanePublicationGuards) {
self.table_publication_guards = Some(guards);
}
pub(crate) fn table_list_access(&self) -> Option<&TableDataPlaneListAccess> {
self.table_list_access.as_ref()
}
@@ -412,337 +319,19 @@ impl RequestEnvelope {
pub(crate) fn object_tag_conditions(&self) -> Option<&ObjectTagConditions> {
self.object_tag_conditions.as_ref()
}
pub(crate) fn install_object_tag_conditions(&mut self, conditions: ObjectTagConditions) {
self.object_tag_conditions = Some(conditions);
}
pub(crate) fn multipart_staging_operation(&self) -> Option<&MultipartStagingOperation> {
self.multipart_staging_operation.as_ref()
}
pub(crate) fn staged_multipart_part_authorization(&self) -> Option<&StagedMultipartPartAuthorization> {
self.staged_multipart_part_authorization.as_ref()
}
pub(crate) fn requested_object_lock_retention(&self) -> Option<&RequestedObjectLockRetention> {
self.requested_object_lock_retention.as_ref()
}
pub(crate) fn requested_object_lock_retain_until(&self) -> Option<&RequestedObjectLockRetainUntil> {
self.requested_object_lock_retain_until.as_ref()
}
pub(crate) fn post_object_policy_headers(&self) -> Option<&PostObjectPolicyHeaders> {
self.post_object_policy_headers.as_ref()
}
pub(crate) fn requested_object_legal_hold(&self) -> Option<&RequestedObjectLegalHold> {
self.requested_object_legal_hold.as_ref()
}
pub(crate) fn requested_object_tags(&self) -> Option<&RequestedObjectTags> {
self.requested_object_tags.as_ref()
}
pub(crate) fn requested_copy_source(&self) -> Option<&RequestedCopySource> {
self.requested_copy_source.as_ref()
}
}
/// The authorization core in `storage` reads requests only through
/// [`AuthorizationRequest`]; this impl hands it the envelope's named fields.
impl AuthorizationRequest for RequestEnvelope {
fn headers(&self) -> &HeaderMap {
self.headers()
}
fn uri(&self) -> &http::Uri {
self.uri()
}
fn remote_addr(&self) -> Option<std::net::SocketAddr> {
self.remote_addr()
}
fn client_info(&self) -> Option<&ClientInfo> {
self.client_info()
}
fn server_context(&self) -> Option<&Arc<ServerContextSlot>> {
self.server_context()
}
fn req_info(&self) -> Option<&ReqInfo> {
self.req_info()
}
fn internal_object_authorization(&self) -> bool {
false
}
fn multipart_staging(&self) -> bool {
self.multipart_staging_operation().is_some()
}
fn staged_multipart_part(&self) -> bool {
self.staged_multipart_part_authorization().is_some()
}
fn requested_retention(&self) -> Option<&RequestedObjectLockRetention> {
self.requested_object_lock_retention()
}
fn requested_retain_until(&self) -> Option<&RequestedObjectLockRetainUntil> {
self.requested_object_lock_retain_until()
}
fn requested_legal_hold(&self) -> Option<&RequestedObjectLegalHold> {
self.requested_object_legal_hold()
}
fn requested_object_tags(&self) -> Option<&RequestedObjectTags> {
self.requested_object_tags()
}
fn requested_copy_source(&self) -> Option<&RequestedCopySource> {
self.requested_copy_source()
}
fn policy_headers(&self) -> &HeaderMap {
self.post_object_policy_headers().map_or(self.headers(), |headers| &headers.0)
}
fn object_tag_conditions(&self) -> Option<&ObjectTagConditions> {
self.object_tag_conditions()
}
fn install_object_tag_conditions(&mut self, conditions: ObjectTagConditions) {
self.install_object_tag_conditions(conditions);
}
fn publication_guards(&self) -> Option<&TableDataPlanePublicationGuards> {
self.table_publication_guards()
}
fn install_publication_guards(&mut self, guards: TableDataPlanePublicationGuards) {
self.install_table_publication_guards(guards);
}
fn audit_denial(&self, auditor: &DenialAuditor, action: &Action, reason: &'static str) {
auditor.audit_owned(&self.audit_view(), action, reason, DenialScope::Request);
}
}
/// Authorizes an owned request through the same IAM, bucket and table policies as the legacy edge.
///
/// Like the legacy `authorize_request`, it first restores the signed `x-amz-meta-*`
/// query parameters into headers, so policy conditions and duplicate rejection see
/// the same request shape on both edges.
///
/// This is only the policy decision. It does **not** perform the composite checks
/// the legacy `S3Access` hooks run around their decision, such as the
/// `s3:BypassGovernanceRetention` authorization for a governance-bypass header or
/// the copy-source read authorization; a caller that needs them runs them itself.
///
/// # Errors
///
/// `InvalidArgument` when the signed query repeats a user metadata key;
/// `AccessDenied` when the decision for `action` depends on a request-body input
/// the envelope does not carry (see [`require_body_policy_inputs`]) or when the
/// policies deny the request.
pub(crate) async fn authorize_owned_request(
req: &mut RequestEnvelope,
action: Action,
location_constraint: Option<String>,
) -> rustfs_s3_types::S3Result<()> {
prepare_owned_authorization(req, action)?;
authorize_request_core(req, action, location_constraint, false)
.await
.map_err(Into::into)
}
/// Authorizes one exact object key after its enclosing table-catalog operation has been authorized.
///
/// It applies the same preparation and has the same limits as [`authorize_owned_request`].
pub(crate) async fn authorize_owned_internal_object_request(
req: &mut RequestEnvelope,
action: Action,
) -> rustfs_s3_types::S3Result<()> {
prepare_owned_authorization(req, action)?;
authorize_request_core(req, action, None, true).await.map_err(Into::into)
}
fn prepare_owned_authorization(req: &mut RequestEnvelope, action: Action) -> rustfs_s3_types::S3Result<()> {
crate::auth::hoist_signed_query_params(&mut req.headers, req.uri.query()).map_err(rustfs_s3_types::S3Error::from)?;
require_body_policy_inputs(req, action)
}
/// Fails closed when the decision for `action` depends on a value the request
/// body supplies but the envelope does not carry it. Policy conditions would
/// otherwise fall back to client headers, which the stored value need not match
/// (header `OFF` with body `ON` would pass a legal-hold Deny).
fn require_body_policy_inputs(req: &RequestEnvelope, action: Action) -> rustfs_s3_types::S3Result<()> {
let missing = match action {
Action::S3Action(S3Action::PutObjectLegalHoldAction) => req.requested_object_legal_hold.is_none(),
Action::S3Action(S3Action::PutObjectTaggingAction | S3Action::PutObjectVersionTaggingAction) => {
req.requested_object_tags.is_none()
}
Action::S3Action(S3Action::PutObjectRetentionAction) => req.requested_object_lock_retention.is_none(),
_ => false,
} || (req.post_object.is_some() && req.post_object_policy_headers.is_none())
|| (req.headers.contains_key(COPY_SOURCE_HEADER) && req.requested_copy_source.is_none());
if missing {
return Err(rustfs_s3_types::s3_error!(
AccessDenied,
"request body policy input is missing for this action"
));
}
Ok(())
}
const COPY_SOURCE_HEADER: &str = "x-amz-copy-source";
impl RequestEnvelope {
/// The request fields an audit entry for this request is built from.
pub(crate) fn audit_view(&self) -> AuditRequestView<'_> {
AuditRequestView::from_facts(
self.headers(),
self.uri(),
self.req_info(),
self.client_info(),
self.remote_addr(),
self.request_context(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn authorization_payloads_move_from_extensions_into_named_fields() {
let mut extensions = Extensions::new();
extensions.insert(MultipartStagingOperation);
extensions.insert(StagedMultipartPartAuthorization);
let mut retention = RequestedObjectLockRetention(Default::default());
retention.0.mode = Some("COMPLIANCE".to_owned().into());
extensions.insert(retention);
let retain_until = time::OffsetDateTime::from_unix_timestamp(2_000_000_000).expect("valid timestamp");
extensions.insert(RequestedObjectLockRetainUntil(retain_until));
let head = || RequestHead {
method: Method::PUT,
uri: "/bucket/key".parse().expect("valid URI"),
headers: HeaderMap::new(),
credentials: None,
region: None,
service: None,
};
let envelope = RequestEnvelope::from_parts(head(), extensions);
let empty = RequestEnvelope::from_parts(head(), Extensions::new());
assert_eq!(
(
envelope.multipart_staging_operation().is_some(),
envelope.staged_multipart_part_authorization().is_some(),
envelope
.requested_object_lock_retention()
.and_then(|value| value.0.mode.as_ref().map(|mode| mode.as_str())),
envelope.requested_object_lock_retain_until().map(|value| value.0),
envelope.residual_extensions().is_empty(),
empty.multipart_staging_operation().is_none(),
empty.staged_multipart_part_authorization().is_none(),
empty.requested_object_lock_retention().is_none(),
empty.requested_object_lock_retain_until().is_none(),
),
(true, true, Some("COMPLIANCE"), Some(retain_until), true, true, true, true, true),
);
}
#[test]
fn request_body_policy_payloads_move_from_extensions_into_named_fields() {
let mut form_headers = HeaderMap::new();
form_headers.insert("x-amz-tagging", http::HeaderValue::from_static("from=form"));
let mut legal_hold = RequestedObjectLegalHold(Default::default());
legal_hold.0.status = Some("ON".to_owned().into());
let mut tags = RequestedObjectTags(Default::default());
tags.0.tag_set.push(Default::default());
tags.0.tag_set[0].key = Some("body".to_owned());
let mut extensions = Extensions::new();
extensions.insert(PostObjectPolicyHeaders(form_headers));
extensions.insert(legal_hold);
extensions.insert(tags);
extensions.insert(RequestedCopySource("source-bucket/source-key".to_owned()));
let head = || RequestHead {
method: Method::PUT,
uri: "/bucket/key".parse().expect("valid URI"),
headers: HeaderMap::new(),
credentials: None,
region: None,
service: None,
};
let envelope = RequestEnvelope::from_parts(head(), extensions);
let empty = RequestEnvelope::from_parts(head(), Extensions::new());
assert_eq!(
(
envelope
.post_object_policy_headers()
.and_then(|headers| headers.0.get("x-amz-tagging"))
.and_then(|value| value.to_str().ok()),
envelope
.requested_object_legal_hold()
.and_then(|hold| hold.0.status.as_ref().map(|status| status.as_str())),
envelope
.requested_object_tags()
.and_then(|tags| tags.0.tag_set.first())
.and_then(|tag| tag.key.as_deref()),
envelope.requested_copy_source().map(|source| source.0.as_str()),
envelope.residual_extensions().is_empty(),
empty.post_object_policy_headers().is_none(),
empty.requested_object_legal_hold().is_none(),
empty.requested_object_tags().is_none(),
empty.requested_copy_source().is_none(),
),
(
Some("from=form"),
Some("ON"),
Some("body"),
Some("source-bucket/source-key"),
true,
true,
true,
true,
true
),
);
}
#[test]
fn an_empty_handed_over_secret_is_refused() {
assert!(RequestCredentials::new("AKIAENVELOPE".to_owned(), String::new(), None).is_err());
}
#[test]
fn an_absent_secret_matches_no_stored_secret() {
let kept = RequestCredentials::without_secret_key("AKIAENVELOPE".to_owned(), None);
assert_eq!(kept.secret_key(), None);
assert!(!kept.secret_key_matches(""), "absent must not equal an empty stored secret");
assert!(!kept.secret_key_matches("stored-secret"));
let handed = RequestCredentials::new("AKIAENVELOPE".to_owned(), "stored-secret".to_owned(), None).expect("secret");
assert!(!handed.secret_key_matches(""));
assert!(!handed.secret_key_matches("other-secret"));
assert!(handed.secret_key_matches("stored-secret"));
}
#[test]
fn a_reader_of_an_absent_secret_fails_closed() {
let kept = RequestCredentials::without_secret_key("AKIAENVELOPE".to_owned(), Some("token".to_owned()));
assert!(kept.require_secret_key().is_err());
let handed = RequestCredentials::new("AKIAENVELOPE".to_owned(), "s".to_owned(), None).expect("secret");
assert_eq!(handed.require_secret_key().ok(), Some("s"));
}
#[test]
fn kept_secret_debug_prints_neither_secret_nor_token() {
let kept = RequestCredentials::without_secret_key("AKIAENVELOPE".to_owned(), Some("envelope-session-token".to_owned()));
let debug = format!("{kept:?}");
assert!(debug.contains("AKIAENVELOPE"), "{debug}");
assert!(!debug.contains("envelope-session-token"), "{debug}");
}
#[test]
fn request_credentials_debug_redacts_secret_and_session_token() {
let credentials = RequestCredentials::new(
"AKIAENVELOPE".to_owned(),
"envelope-secret-value".to_owned(),
Some("envelope-session-token".to_owned()),
)
.expect("non-empty secret");
);
let debug = format!("{credentials:?}");
@@ -44,10 +44,6 @@ use uuid::Uuid;
const SEEDED_BUCKET_SIZE: u64 = 123_456;
const SEEDED_BUCKET_OBJECTS: u64 = 42;
/// The smallest snapshot-time step that every supported platform can
/// represent. Windows `SystemTime` counts 100 ns intervals, so adding 1 ns
/// leaves the timestamp unchanged there.
const SNAPSHOT_TIME_STEP: Duration = Duration::from_micros(1);
async fn next_snapshot_time(ecstore: Arc<ECStore>) -> SystemTime {
let now = SystemTime::now();
@@ -56,7 +52,7 @@ async fn next_snapshot_time(ecstore: Arc<ECStore>) -> SystemTime {
.ok()
.and_then(|info| info.last_update)
.unwrap_or(now);
latest.max(now) + SNAPSHOT_TIME_STEP
latest.max(now) + Duration::from_nanos(1)
}
fn seeded_data_usage_info(bucket: &str, last_update: SystemTime) -> DataUsageInfo {
@@ -298,7 +294,7 @@ async fn data_usage_endpoint_treats_legacy_partial_stats_as_unknown_until_covere
"uncovered snapshot handling must not restore request-time object listings"
);
let completed_at = persisted_at + SNAPSHOT_TIME_STEP;
let completed_at = persisted_at + Duration::from_nanos(1);
let mut completed = DataUsageInfo {
last_update: Some(completed_at),
usage_snapshot_complete: true,
@@ -383,11 +379,11 @@ async fn save_data_usage_invalidates_snapshot_cache() {
let ecstore = shared_gating_ecstore().await;
let bucket = format!("usage-invalidate-{}", Uuid::new_v4());
// Advance beyond the latest persisted timestamp by the smallest portable
// Advance beyond the latest persisted timestamp by the smallest possible
// increment so this save wins the monotonicity guard without masking a
// subsequent in-memory mutation as older than the snapshot.
let warm_at = next_snapshot_time(ecstore.clone()).await;
let changed_at = warm_at + SNAPSHOT_TIME_STEP;
let changed_at = warm_at + Duration::from_nanos(1);
const WARM_SIZE: u64 = 111_000;
const CHANGED_SIZE: u64 = 222_000;
@@ -431,7 +427,7 @@ async fn scanner_save_invalidates_snapshot_cache() {
let ecstore = shared_gating_ecstore().await;
let bucket = format!("usage-scanner-invalidate-{}", Uuid::new_v4());
let warm_at = next_snapshot_time(ecstore.clone()).await;
let changed_at = warm_at + SNAPSHOT_TIME_STEP;
let changed_at = warm_at + Duration::from_nanos(1);
const WARM_SIZE: u64 = 333_000;
const CHANGED_SIZE: u64 = 444_000;
-3
View File
@@ -36,7 +36,6 @@ pub(crate) mod trailer_adapter;
// from here rather than through `app::context`.
pub(crate) use context::RequestHead;
pub use context::{RequestCredentials, RequestEnvelope};
pub(crate) use context::{authorize_owned_internal_object_request, authorize_owned_request};
#[cfg(test)]
mod capacity_dirty_scope_test;
@@ -49,8 +48,6 @@ pub(crate) mod gating_test_env;
#[cfg(test)]
mod lifecycle_transition_api_test;
#[cfg(test)]
mod owned_authorization_test;
#[cfg(test)]
mod put_prelookup_gating_test;
#[cfg(test)]
pub(crate) mod test_seams;

Some files were not shown because too many files have changed in this diff Show More