mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-14 00:53:14 +00:00
Compare commits
131 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 778f1dfa21 | |||
| 7e9e4b67e5 | |||
| f5463f4aa8 | |||
| cb93ac5df1 | |||
| 96d24bc006 | |||
| 74c6c114b1 | |||
| b301588248 | |||
| 601c766fca | |||
| 41e262cdab | |||
| 23fef384ce | |||
| d7f1ba9ae7 | |||
| a4712fae81 | |||
| 8201a74f7f | |||
| ce7ca4cbb8 | |||
| 6633c80151 | |||
| a0a8eaa0f3 | |||
| 027456032f | |||
| 58c49672ca | |||
| aa4de7b9d6 | |||
| 4b2d79f5d5 | |||
| 96665f4de9 | |||
| 05a5be51ce | |||
| 5187f91997 | |||
| 9d996b82a8 | |||
| ab35681928 | |||
| ba5641237c | |||
| 3792fed827 | |||
| 7553715f62 | |||
| 766afe12fb | |||
| f5929a8305 | |||
| 2a44985037 | |||
| bd15dd5784 | |||
| a5c8052163 | |||
| 58d4bdc79f | |||
| 8d582a096c | |||
| f5bf1fc313 | |||
| 10abef4791 | |||
| b7b571dfa4 | |||
| dd2e0328fd | |||
| fe91b75d65 | |||
| 706a8b6061 | |||
| 83cdea1f18 | |||
| 77f2b948c2 | |||
| 5e7e25b7d1 | |||
| da82fd995e | |||
| 656a2f14bf | |||
| 87d32a6207 | |||
| 3bad829b9a | |||
| 434663f2aa | |||
| 5f6fb024cc | |||
| c1b8136f9a | |||
| aff3d4a39f | |||
| 8003912bb1 | |||
| 6303aa9a42 | |||
| 4855095446 | |||
| fc0de983d8 | |||
| e26b869259 | |||
| efd5481b35 | |||
| dbf51117a1 | |||
| 5e0fdaa247 | |||
| 923e35efa0 | |||
| 733c7b0f67 | |||
| ead419451a | |||
| 7211f29498 | |||
| 04722caa04 | |||
| 066e952df1 | |||
| ea8dbf49a2 | |||
| 5f3bc617fe | |||
| 6f10ca18a9 | |||
| 759ade4770 | |||
| db1daaece2 | |||
| f0c4fbd28f | |||
| 8550a8f9c3 | |||
| 018f27d1cd | |||
| 6617708faa | |||
| f73054f6ad | |||
| 8c9e884cf2 | |||
| 75d0c8d6b9 | |||
| d2e5346044 | |||
| 204068e07e | |||
| ec135f8c4c | |||
| 5bd28048d5 | |||
| 53a8e02a08 | |||
| 15b9c1f4e3 | |||
| 4042bc0a5e | |||
| 4576c2e470 | |||
| 327fdd5fc2 | |||
| 16c2928965 | |||
| 3dabac4a09 | |||
| 15c2bade5f | |||
| 624a4ab837 | |||
| 4f43c0ca7e | |||
| 3405b4e980 | |||
| 510b0350d6 | |||
| b14805af47 | |||
| f3eba31aee | |||
| 42af6e3b63 | |||
| a43267160d | |||
| 2039ba5f65 | |||
| 3a6f630ff1 | |||
| 1695873e55 | |||
| c26419e357 | |||
| d401c65719 | |||
| eb87bb1faf | |||
| 93fcd6b6b5 | |||
| c63dba7d3f | |||
| 31959b90db | |||
| 3c8bd5b929 | |||
| ec106548ba | |||
| cfce7bd9b1 | |||
| b71483b1c8 | |||
| cebc28f678 | |||
| d6e11cf018 | |||
| 71f2e7a209 | |||
| 1934cddd66 | |||
| e08cf474db | |||
| 48c8d85f3b | |||
| 99701e9f52 | |||
| e64ed14fb0 | |||
| cad0fd9b2f | |||
| de8cb5f26c | |||
| 98d3619613 | |||
| 5237a4465d | |||
| 62cc19e937 | |||
| d8d22599fe | |||
| ee55691f63 | |||
| 3ce17cd7dd | |||
| 4310850103 | |||
| acce8b2253 | |||
| e20892ace9 | |||
| accc906b33 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: rustfs-logging-governance
|
||||
description: Standardize and review RustFS logging with structured `tracing` events, lower noise on hot paths, preserve security-sensitive diagnostics, and extend guardrails to prevent legacy logging patterns from returning. Use when editing or reviewing RustFS logs, startup/config diagnostics, cloud metadata logs, request validation logs, or `scripts/check_logging_guardrails.sh`.
|
||||
description: Standardize and review RustFS logging with structured `tracing` events, lower noise on hot paths, preserve security-sensitive diagnostics, and extend guardrails to prevent legacy logging patterns from returning. Use whenever a change adds or edits any `tracing` macro call (`error!`/`warn!`/`info!`/`debug!`/`trace!`/`#[instrument]`) — including a single log line added in passing while fixing unrelated logic, which is how most new log sites enter the repo — and when reviewing RustFS logs, startup/config diagnostics, cloud metadata logs, request validation logs, or `scripts/check_logging_guardrails.sh`.
|
||||
---
|
||||
|
||||
# RustFS Logging Governance
|
||||
|
||||
@@ -60,6 +60,11 @@ body-cache-whitelist-check: ## Check the body-cache eligibility gate stays a fai
|
||||
@echo "🧱 Checking body-cache whitelist guard..."
|
||||
./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
.PHONY: s3s-footprint-check
|
||||
s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
|
||||
@echo "📦 Checking s3s footprint ratchet..."
|
||||
./scripts/check_s3s_footprint.sh
|
||||
|
||||
.PHONY: fips-wording-check
|
||||
fips-wording-check: ## Check outward docs do not make unsupported FIPS claims
|
||||
@echo "📣 Checking FIPS wording guard..."
|
||||
|
||||
@@ -19,13 +19,13 @@ 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 tokio-io-uring-check extension-schema-check body-cache-whitelist-check fips-wording-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 tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-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 tokio-io-uring-check extension-schema-check body-cache-whitelist-check fips-wording-check doc-paths-check planning-docs-check log-analyzer-rules-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 tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
@echo "✅ All pre-PR checks passed!"
|
||||
|
||||
.PHONY: dev-check
|
||||
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check fips-wording-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
|
||||
dev-check: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check doc-paths-check planning-docs-check quick-check ## Run fast local development checks
|
||||
@echo "✅ Fast development checks passed!"
|
||||
|
||||
+61
-9
@@ -29,6 +29,8 @@
|
||||
|
||||
[test-groups]
|
||||
ecstore-serial-flaky = { max-threads = 1 }
|
||||
embedded-test-ports = { max-threads = 1 }
|
||||
e2e-vault = { max-threads = 1 }
|
||||
|
||||
# Reliability / fault-injection e2e tests each spawn a single-node 4-disk RustFS
|
||||
# server and manipulate its disk directories at runtime (crates/e2e_test:
|
||||
@@ -54,12 +56,36 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# The production-handler relocation regression builds an isolated 8-disk,
|
||||
# 2-pool store and commits a 72 MiB multipart object. Keep that cross-disk IO
|
||||
# from overlapping the ecstore commit fixtures above.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Embedded integration-test binaries discover an ephemeral port and release
|
||||
# the probe listener before RustFS binds it. Serialize that cross-process
|
||||
# TOCTOU window; retries would only hide real startup failures.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs) & (binary(/^embedded.*_test$/) | binary(admin_diagnostic_capability_e2e))'
|
||||
test-group = 'embedded-test-ports'
|
||||
|
||||
# Serialize the durable manual-transition checkpoint test across nextest's
|
||||
# process boundary; it mutates bucket lifecycle metadata and is not quarantined.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the bucket-incarnation / lifecycle-fence tests. They drive
|
||||
# init_bucket_metadata_sys and bucket_metadata_sys_of, i.e. process-global
|
||||
# OnceLock state that serial_test's #[serial] cannot protect across nextest's
|
||||
# process boundary, and they delete+recreate buckets — the same shape that
|
||||
# raced into InsufficientWriteQuorum in backlog#937. Preventive only, no
|
||||
# retries. The matching ci-profile override is after [profile.ci].
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(bucket_delete_waits_for_config_mutation_fence) | test(stale_config_request_cannot_mutate_a_recreated_bucket) | test(disk_incarnation_read_detects_stale_cache_until_peer_reload) | test(lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata) | test(expiry_configs_are_resolved_from_the_owning_store))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the 4-disk reliability / degraded-read e2e tests (see the
|
||||
# e2e-reliability test-group note above). The matching ci-profile override is at
|
||||
# the end of the file, after [profile.ci] is declared.
|
||||
@@ -71,6 +97,12 @@ test-group = 'e2e-reliability'
|
||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
||||
# does not cross nextest process boundaries, so keep these tests in one group.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^kms::kms_vault_test::/)'
|
||||
test-group = 'e2e-vault'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -133,12 +165,28 @@ test-group = 'e2e-reliability'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(execute_get_object_resumes_from_relocated_pool_without_splicing_body)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Match the default-profile embedded test isolation without quarantining or
|
||||
# retrying failures in CI.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & (binary(/^embedded.*_test$/) | binary(admin_diagnostic_capability_e2e))'
|
||||
test-group = 'embedded-test-ports'
|
||||
|
||||
# Serialize the durable manual-transition checkpoint test under the ci profile
|
||||
# too. No retries: failures stay visible.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the bucket-incarnation / lifecycle-fence tests under the ci profile
|
||||
# too (see the matching default-profile override near the top). No retries.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(bucket_delete_waits_for_config_mutation_fence) | test(stale_config_request_cannot_mutate_a_recreated_bucket) | test(disk_incarnation_read_detects_stale_cache_until_peer_reload) | test(lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata) | test(expiry_configs_are_resolved_from_the_owning_store))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -170,7 +218,7 @@ test-group = 'ecstore-serial-flaky'
|
||||
# the nightly profile derives its set as "the replication module MINUS this
|
||||
# allowlist", so any new replication test lands in nightly by default (never
|
||||
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
||||
# regexes byte-identical. Count invariant: 20 here + 28 nightly = 48 total
|
||||
# regexes byte-identical. Count invariant: 20 here + 36 nightly = 56 total
|
||||
# (authority: `cargo nextest list`; docs/testing/e2e-suite-inventory.md).
|
||||
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
|
||||
# (#4724) because they set a loopback (127.0.0.1) replication target that the
|
||||
@@ -206,7 +254,7 @@ test-group = 'ecstore-serial-flaky'
|
||||
[profile.e2e-smoke]
|
||||
default-filter = """
|
||||
package(e2e_test) & (
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
|
||||
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
||||
| test(/^reliant::lifecycle::/)
|
||||
| test(/^reliant::tiering::/)
|
||||
@@ -232,10 +280,10 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
# tests that are unfit for the per-PR e2e-smoke gate:
|
||||
#
|
||||
# * 2 remote-target TLS validation tests.
|
||||
# * 12 bucket-replication data-plane/helper tests — they PUT/delete objects
|
||||
# and poll until source and target converge; two replicate over HTTPS, two
|
||||
# pin active SSE failure contracts, and one guards event/history observers.
|
||||
# The SSE-S3 contract remains ignored under backlog#1291.
|
||||
# * 13 bucket-replication data-plane/helper tests — they PUT/delete objects
|
||||
# and poll until source and target converge; two replicate over HTTPS,
|
||||
# four pin active SSE fail-closed contracts (SSE-C, SSE-S3, SSE-KMS, and
|
||||
# the SSE-S3 resync path), and one guards event/history observers.
|
||||
# * 12 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
||||
# servers and drives the cross-process site-replication control plane.
|
||||
# * 1 `_real_three_node` site-replication test.
|
||||
@@ -301,9 +349,9 @@ path = "junit.xml"
|
||||
#
|
||||
# Each e2e test spawns its own single-node rustfs server on a random port with
|
||||
# an isolated temp dir (crates/e2e_test/src/common.rs), so the set is
|
||||
# parallel-safe — the same property e2e-smoke relies on. The exception is the
|
||||
# 4-disk reliability / degraded-read fault-injection tests, serialized below
|
||||
# (identical to the ci profile) so several 4-disk servers never run at once.
|
||||
# parallel-safe — the same property e2e-smoke relies on. The exceptions are the
|
||||
# 4-disk reliability / degraded-read fault-injection tests and the fixed-port
|
||||
# Vault tests, both serialized below.
|
||||
# KNOWN-FAILURE EXCLUSIONS (characterization run 29381309848, 2026-07-15:
|
||||
# 341 ran / 32 failed on the suites' first automated run ever). Deterministic
|
||||
# product failures cannot be quarantined away with retries, so each family is
|
||||
@@ -339,3 +387,7 @@ test-group = 'e2e-reliability'
|
||||
[[profile.e2e-full.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
[[profile.e2e-full.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^kms::kms_vault_test::/)'
|
||||
test-group = 'e2e-vault'
|
||||
|
||||
@@ -170,6 +170,10 @@ Important behavior notes:
|
||||
|
||||
- Logs and metrics usually appear during startup, so seeing those two signals
|
||||
first is expected.
|
||||
- The OpenTelemetry bridge sends `tracing` fields as log attributes. Loki stores
|
||||
those attributes as structured metadata, and the Collector also mirrors the
|
||||
common troubleshooting fields into the log line so simple line filters can
|
||||
find them.
|
||||
- Visible trace data usually requires real HTTP/S3/gRPC request traffic after
|
||||
startup, because request-path spans are created on demand.
|
||||
- `RUSTFS_OBS_LOGGER_LEVEL=info` keeps the top-level request span but filters
|
||||
@@ -195,6 +199,17 @@ curl -I http://127.0.0.1:9000/health/ready
|
||||
# Jaeger: http://localhost:16686
|
||||
```
|
||||
|
||||
For a structured RustFS log such as an inter-node RPC authentication failure,
|
||||
the Loki line now includes fields such as `event`, `component`, `subsystem`,
|
||||
`failure_reason`, `rpc_service`, `rpc_method`, and `expected_audience`. Useful
|
||||
LogQL checks:
|
||||
|
||||
```logql
|
||||
{service_name="RustFS"} |= "RPC signature verification failed"
|
||||
{service_name="RustFS"} |= "failure_reason="
|
||||
{service_name="RustFS"} | failure_reason != ""
|
||||
```
|
||||
|
||||
If logs and metrics are present but traces are sparse, the most common cause is
|
||||
"no real request traffic yet" or "`info` level filtered nested spans", not an
|
||||
OTLP routing failure.
|
||||
|
||||
@@ -169,6 +169,7 @@ RustFS 会自动在该基础 URL 后补全:
|
||||
需要注意:
|
||||
|
||||
- 启动阶段通常会先看到日志和指标,因此“先有日志/指标、后有 trace”是正常现象。
|
||||
- OpenTelemetry bridge 会把 `tracing` 字段作为日志 attributes 发送。Loki 会将这些 attributes 存为 structured metadata,同时 Collector 会把常用排障字段镜像进日志行,方便用简单的行内容过滤直接查到。
|
||||
- 可见的 trace 数据通常依赖启动后的真实 HTTP/S3/gRPC 请求流量,因为请求路径上的 span 是按需创建的。
|
||||
- `RUSTFS_OBS_LOGGER_LEVEL=info` 会保留顶层请求 span,但会过滤掉很多 `debug` 级别的嵌套 span。
|
||||
如果 Tempo 或 Jaeger 中的 trace 看起来很稀疏,建议先改成 `RUSTFS_OBS_LOGGER_LEVEL=debug`,再判断是否是 collector 或 Tempo 问题。
|
||||
@@ -192,6 +193,14 @@ curl -I http://127.0.0.1:9000/health/ready
|
||||
# Jaeger: http://localhost:16686
|
||||
```
|
||||
|
||||
对于 RustFS 结构化日志,例如节点间 RPC 鉴权失败,Loki 日志行现在会包含 `event`、`component`、`subsystem`、`failure_reason`、`rpc_service`、`rpc_method`、`expected_audience` 等字段。常用 LogQL 检查:
|
||||
|
||||
```logql
|
||||
{service_name="RustFS"} |= "RPC signature verification failed"
|
||||
{service_name="RustFS"} |= "failure_reason="
|
||||
{service_name="RustFS"} | failure_reason != ""
|
||||
```
|
||||
|
||||
如果日志和指标已经正常,但 trace 仍然稀疏,最常见的原因通常是
|
||||
“还没有真实请求流量”或“`info` 级别过滤了嵌套 span”,而不是 OTLP 路由失败。
|
||||
|
||||
|
||||
@@ -29,11 +29,27 @@ processors:
|
||||
limit_mib: 1024
|
||||
spike_limit_mib: 256
|
||||
transform/logs:
|
||||
error_mode: ignore
|
||||
log_statements:
|
||||
- context: log
|
||||
statements:
|
||||
- set(attributes["message"], body.string)
|
||||
- set(attributes["log.body"], body.string)
|
||||
- set(attributes["message"], body.string) where IsString(body)
|
||||
- set(attributes["log.body"], body.string) where IsString(body)
|
||||
- set(body, Concat([body, " event=", attributes["event"]], "")) where IsString(body) and attributes["event"] != nil
|
||||
- set(body, Concat([body, " component=", attributes["component"]], "")) where IsString(body) and attributes["component"] != nil
|
||||
- set(body, Concat([body, " subsystem=", attributes["subsystem"]], "")) where IsString(body) and attributes["subsystem"] != nil
|
||||
- set(body, Concat([body, " state=", attributes["state"]], "")) where IsString(body) and attributes["state"] != nil
|
||||
- set(body, Concat([body, " result=", attributes["result"]], "")) where IsString(body) and attributes["result"] != nil
|
||||
- set(body, Concat([body, " reason=", attributes["reason"]], "")) where IsString(body) and attributes["reason"] != nil
|
||||
- set(body, Concat([body, " failure_reason=", attributes["failure_reason"]], "")) where IsString(body) and attributes["failure_reason"] != nil
|
||||
- set(body, Concat([body, " rpc_path=", attributes["rpc_path"]], "")) where IsString(body) and attributes["rpc_path"] != nil
|
||||
- set(body, Concat([body, " rpc_service=", attributes["rpc_service"]], "")) where IsString(body) and attributes["rpc_service"] != nil
|
||||
- set(body, Concat([body, " rpc_method=", attributes["rpc_method"]], "")) where IsString(body) and attributes["rpc_method"] != nil
|
||||
- set(body, Concat([body, " expected_audience=", attributes["expected_audience"]], "")) where IsString(body) and attributes["expected_audience"] != nil
|
||||
- set(body, Concat([body, " peer_addr=", attributes["peer_addr"]], "")) where IsString(body) and attributes["peer_addr"] != nil
|
||||
- set(body, Concat([body, " replay_scope_bootstrap_allowed=", attributes["replay_scope_bootstrap_allowed"]], "")) where IsString(body) and attributes["replay_scope_bootstrap_allowed"] != nil
|
||||
- set(body, Concat([body, " error=", attributes["error"]], "")) where IsString(body) and attributes["error"] != nil
|
||||
- set(body, Concat([body, " exception_message=", attributes["exception.message"]], "")) where IsString(body) and attributes["exception.message"] != nil
|
||||
|
||||
exporters:
|
||||
otlp/tempo:
|
||||
|
||||
@@ -24,6 +24,7 @@ on:
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
pull_request:
|
||||
@@ -36,6 +37,7 @@ on:
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
schedule:
|
||||
@@ -141,6 +143,9 @@ jobs:
|
||||
- name: Check preview release workflow policy
|
||||
run: ./scripts/security/check_preview_release_workflow.sh
|
||||
|
||||
- name: Check performance A/B workflow trust boundary
|
||||
run: ./scripts/security/check_performance_ab_workflow.sh
|
||||
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -102,6 +102,9 @@ jobs:
|
||||
- name: Check architecture migration rules
|
||||
run: ./scripts/check_architecture_migration_rules.sh
|
||||
|
||||
- name: Check logging guardrails
|
||||
run: ./scripts/check_logging_guardrails.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
@@ -111,6 +114,9 @@ jobs:
|
||||
- name: Check body-cache whitelist guard
|
||||
run: ./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
- name: Check s3s footprint ratchet
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
|
||||
@@ -137,6 +137,9 @@ jobs:
|
||||
- name: Check architecture migration rules
|
||||
run: ./scripts/check_architecture_migration_rules.sh
|
||||
|
||||
- name: Check logging guardrails
|
||||
run: ./scripts/check_logging_guardrails.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
@@ -146,6 +149,9 @@ jobs:
|
||||
- name: Check body-cache whitelist guard
|
||||
run: ./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
- name: Check s3s footprint ratchet
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
@@ -764,6 +770,32 @@ jobs:
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install awscurl
|
||||
run: |
|
||||
python3 -m pip install --user --upgrade pip "awscurl==0.44"
|
||||
echo "AWSCURL_PATH=$HOME/.local/bin/awscurl" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify awscurl
|
||||
run: test -x "$AWSCURL_PATH"
|
||||
|
||||
- name: Install Vault
|
||||
run: |
|
||||
VAULT_VERSION="1.17.6"
|
||||
VAULT_ARCHIVE="vault_${VAULT_VERSION}_linux_amd64.zip"
|
||||
curl -fsSLo "$RUNNER_TEMP/$VAULT_ARCHIVE" "https://releases.hashicorp.com/vault/${VAULT_VERSION}/${VAULT_ARCHIVE}"
|
||||
echo "0cddc1fbbb88583b5ba5b845f9f8fae47c6fb39a6d48cd543c6ba6fd3ac1a669 $RUNNER_TEMP/$VAULT_ARCHIVE" | sha256sum --check --status
|
||||
unzip -q "$RUNNER_TEMP/$VAULT_ARCHIVE" -d "$RUNNER_TEMP/vault-bin"
|
||||
echo "RUSTFS_TEST_VAULT_BIN=$RUNNER_TEMP/vault-bin/vault" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify Vault
|
||||
run: |
|
||||
"$RUSTFS_TEST_VAULT_BIN" version
|
||||
|
||||
# Download after the cache restore so the freshly built binary from the
|
||||
# build job always wins over anything restored into target/debug.
|
||||
- name: Download debug binary
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
# 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.
|
||||
|
||||
# Package Workflow - Build DEB/RPM packages
|
||||
#
|
||||
# This workflow builds DEB and RPM packages from pre-built Linux binaries
|
||||
# and uploads them to Cloudflare R2.
|
||||
#
|
||||
# Trigger:
|
||||
# - release published: automatically package when a GitHub release is published
|
||||
# - workflow_dispatch: manual trigger with optional tag/run_id
|
||||
#
|
||||
# Flow:
|
||||
# 1. Find the Build workflow run for the release tag
|
||||
# 2. Download Linux binaries (x86_64-gnu, aarch64-gnu) from build artifacts
|
||||
# 3. Build DEB packages for amd64 and arm64
|
||||
# 4. Build RPM packages for x86_64 and aarch64
|
||||
# 5. Upload all packages to Cloudflare R2
|
||||
|
||||
name: Package DEB/RPM
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to package (e.g. 1.0.0-beta.12). Leave empty for latest main build."
|
||||
required: false
|
||||
type: string
|
||||
build_run_id:
|
||||
description: "Build workflow run ID (overrides tag lookup)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Resolve which build run to use and extract version info
|
||||
resolve:
|
||||
name: Resolve Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
build_type: ${{ steps.resolve.outputs.build_type }}
|
||||
build_run_id: ${{ steps.resolve.outputs.build_run_id }}
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
steps:
|
||||
- name: Resolve build run
|
||||
id: resolve
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
INPUT_RUN_ID: ${{ github.event.inputs.build_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Determine tag
|
||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
elif [[ -n "$INPUT_TAG" ]]; then
|
||||
TAG="$INPUT_TAG"
|
||||
else
|
||||
TAG=""
|
||||
fi
|
||||
|
||||
echo "Tag: ${TAG:-<none>}"
|
||||
|
||||
# Determine build run ID
|
||||
BUILD_RUN_ID=""
|
||||
|
||||
if [[ -n "$INPUT_RUN_ID" ]]; then
|
||||
# Explicit run ID takes priority
|
||||
BUILD_RUN_ID="$INPUT_RUN_ID"
|
||||
echo "Using explicit build run ID: $BUILD_RUN_ID"
|
||||
|
||||
elif [[ -n "$TAG" ]]; then
|
||||
# Find the build run that produced this tag
|
||||
echo "Looking for build run for tag: $TAG"
|
||||
BUILD_RUN_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?branch=${TAG}&status=success&per_page=1" \
|
||||
--jq '.workflow_runs[0].id' 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
|
||||
# Tag might not be a branch; try event=push with head_branch matching
|
||||
BUILD_RUN_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?event=push&status=success&per_page=100" \
|
||||
--jq ".workflow_runs[] | select(.head_branch == \"$TAG\") | .id" 2>/dev/null | head -1 || echo "")
|
||||
fi
|
||||
|
||||
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
|
||||
echo "❌ No successful build run found for tag: $TAG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found build run: $BUILD_RUN_ID"
|
||||
|
||||
else
|
||||
# No tag — latest successful main build
|
||||
echo "No tag specified, looking for latest main build"
|
||||
BUILD_RUN_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?branch=main&status=success&per_page=1" \
|
||||
--jq '.workflow_runs[0].id' 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
|
||||
echo "❌ No successful main build found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Latest main build: $BUILD_RUN_ID"
|
||||
fi
|
||||
|
||||
# Determine version and build type
|
||||
if [[ -n "$TAG" ]]; then
|
||||
VERSION="$TAG"
|
||||
if [[ "$TAG" == *"-preview"* ]]; then
|
||||
BUILD_TYPE="preview"
|
||||
elif [[ "$TAG" == *"alpha"* || "$TAG" == *"beta"* || "$TAG" == *"rc"* ]]; then
|
||||
BUILD_TYPE="prerelease"
|
||||
else
|
||||
BUILD_TYPE="release"
|
||||
fi
|
||||
else
|
||||
SHORT_SHA=$(gh api "repos/${{ github.repository }}/actions/runs/${BUILD_RUN_ID}" \
|
||||
--jq '.head_sha' 2>/dev/null | head -c 7)
|
||||
VERSION="dev-${SHORT_SHA}"
|
||||
BUILD_TYPE="development"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "build_type=$BUILD_TYPE"
|
||||
echo "build_run_id=$BUILD_RUN_ID"
|
||||
echo "tag=${TAG}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "📊 Resolved:"
|
||||
echo " Version: $VERSION"
|
||||
echo " Build type: $BUILD_TYPE"
|
||||
echo " Build run ID: $BUILD_RUN_ID"
|
||||
|
||||
# Build DEB and RPM packages for each architecture
|
||||
package:
|
||||
name: Package (${{ matrix.arch }})
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
deb_arch: amd64
|
||||
rpm_arch: x86_64
|
||||
artifact_name: "rustfs-linux-x86_64-gnu"
|
||||
- arch: aarch64
|
||||
deb_arch: arm64
|
||||
rpm_arch: aarch64
|
||||
artifact_name: "rustfs-linux-aarch64-gnu"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download binary artifact from build run
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: ${{ matrix.artifact_name }}*
|
||||
path: ./binary-artifact
|
||||
run-id: ${{ needs.resolve.outputs.build_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
merge-multiple: true
|
||||
|
||||
- name: Extract binary
|
||||
id: binary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ZIP_FILE=$(find ./binary-artifact -name "*.zip" -type f | head -1)
|
||||
if [[ -z "$ZIP_FILE" ]]; then
|
||||
echo "❌ No binary artifact found"
|
||||
ls -la ./binary-artifact/ || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found artifact: $ZIP_FILE"
|
||||
|
||||
mkdir -p ./bin
|
||||
unzip -o "$ZIP_FILE" -d ./bin
|
||||
|
||||
if [[ ! -f ./bin/rustfs ]]; then
|
||||
echo "❌ rustfs binary not found in archive"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod +x ./bin/rustfs
|
||||
ls -lh ./bin/rustfs
|
||||
echo "✅ Binary extracted"
|
||||
|
||||
- name: Build DEB package
|
||||
id: deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${{ needs.resolve.outputs.version }}"
|
||||
DEB_ARCH="${{ matrix.deb_arch }}"
|
||||
# DEB version: replace - with ~ (1.0.0-beta.12 -> 1.0.0~beta.12)
|
||||
DEB_VERSION="${VERSION/-/~}"
|
||||
PKG_DIR="rustfs_${DEB_VERSION}_${DEB_ARCH}"
|
||||
|
||||
echo "Building DEB: ${PKG_DIR}.deb"
|
||||
|
||||
mkdir -p "${PKG_DIR}/DEBIAN"
|
||||
mkdir -p "${PKG_DIR}/usr/bin"
|
||||
mkdir -p "${PKG_DIR}/etc/default"
|
||||
mkdir -p "${PKG_DIR}/lib/systemd/system"
|
||||
mkdir -p "${PKG_DIR}/usr/share/doc/rustfs"
|
||||
|
||||
cp ./bin/rustfs "${PKG_DIR}/usr/bin/"
|
||||
chmod 755 "${PKG_DIR}/usr/bin/rustfs"
|
||||
|
||||
cp deploy/build/rustfs.service "${PKG_DIR}/lib/systemd/system/"
|
||||
|
||||
cat > "${PKG_DIR}/etc/default/rustfs" << 'ENVEOF'
|
||||
# RustFS Environment Configuration
|
||||
# See https://rustfs.com/docs/ for more information
|
||||
# RUSTFS_VOLUMES=""
|
||||
# RUSTFS_ROOT_USER=""
|
||||
# RUSTFS_ROOT_PASSWORD=""
|
||||
ENVEOF
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/control" << EOF
|
||||
Package: rustfs
|
||||
Version: ${DEB_VERSION}
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: ${DEB_ARCH}
|
||||
Depends: libc6 (>= 2.31)
|
||||
Maintainer: RustFS Team <support@rustfs.com>
|
||||
Description: High-performance distributed object storage
|
||||
RustFS is a high-performance distributed object storage software
|
||||
built using Rust. It is compatible with MinIO and S3 API.
|
||||
Homepage: https://rustfs.com
|
||||
EOF
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/postinst" << 'POSTINST'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if ! getent passwd rustfs > /dev/null 2>&1; then
|
||||
useradd -r -s /bin/false -d /opt/rustfs rustfs
|
||||
fi
|
||||
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
|
||||
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
echo "RustFS installed. Configure /etc/default/rustfs then: systemctl start rustfs"
|
||||
POSTINST
|
||||
chmod 755 "${PKG_DIR}/DEBIAN/postinst"
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/prerm" << 'PRERM'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
|
||||
systemctl stop rustfs
|
||||
fi
|
||||
PRERM
|
||||
chmod 755 "${PKG_DIR}/DEBIAN/prerm"
|
||||
|
||||
cat > "${PKG_DIR}/DEBIAN/postrm" << 'POSTRM'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
POSTRM
|
||||
chmod 755 "${PKG_DIR}/DEBIAN/postrm"
|
||||
|
||||
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||
cp README.md "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||
|
||||
fakeroot dpkg-deb --build "${PKG_DIR}"
|
||||
|
||||
DEB_FILE="${PKG_DIR}.deb"
|
||||
ls -lh "$DEB_FILE"
|
||||
echo "deb_file=$DEB_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ DEB built: $DEB_FILE"
|
||||
|
||||
- name: Build RPM package
|
||||
id: rpm
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${{ needs.resolve.outputs.version }}"
|
||||
RPM_ARCH="${{ matrix.rpm_arch }}"
|
||||
|
||||
echo "Building RPM for ${RPM_ARCH}"
|
||||
|
||||
sudo apt-get update && sudo apt-get install -y ruby ruby-dev build-essential
|
||||
sudo gem install fpm
|
||||
|
||||
fpm -s dir -t rpm \
|
||||
--name rustfs \
|
||||
--version "$VERSION" \
|
||||
--architecture "$RPM_ARCH" \
|
||||
--depends "glibc >= 2.31" \
|
||||
--maintainer "RustFS Team <support@rustfs.com>" \
|
||||
--description "High-performance distributed object storage" \
|
||||
--url "https://rustfs.com" \
|
||||
--license "Apache-2.0" \
|
||||
--after-install <(cat <<'POSTINST'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if ! getent passwd rustfs > /dev/null 2>&1; then
|
||||
useradd -r -s /bin/false -d /opt/rustfs rustfs
|
||||
fi
|
||||
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
|
||||
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
POSTINST
|
||||
) \
|
||||
--before-remove <(cat <<'PRERM'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
|
||||
systemctl stop rustfs
|
||||
fi
|
||||
PRERM
|
||||
) \
|
||||
--after-remove <(cat <<'POSTRM'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
POSTRM
|
||||
) \
|
||||
--config-files /etc/default/rustfs \
|
||||
./bin/rustfs=/usr/bin/rustfs \
|
||||
deploy/build/rustfs.service=/lib/systemd/system/rustfs.service \
|
||||
LICENSE=/usr/share/doc/rustfs/LICENSE \
|
||||
README.md=/usr/share/doc/rustfs/README.md
|
||||
|
||||
RPM_FILE=$(ls -1 rustfs-*.rpm 2>/dev/null | head -1)
|
||||
if [[ -z "$RPM_FILE" ]]; then
|
||||
echo "❌ RPM build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ls -lh "$RPM_FILE"
|
||||
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ RPM built: $RPM_FILE"
|
||||
|
||||
- name: Upload packages to artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: packages-${{ matrix.arch }}
|
||||
path: |
|
||||
*.deb
|
||||
*.rpm
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload packages to Cloudflare R2
|
||||
if: env.R2_ACCESS_KEY_ID != ''
|
||||
env:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "$R2_ACCESS_KEY_ID" || -z "$R2_SECRET_ACCESS_KEY" || -z "$R2_ENDPOINT" || -z "$R2_BUCKET" ]]; then
|
||||
echo "⚠️ R2 credentials missing, skipping upload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y awscli
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
|
||||
BUILD_TYPE="${{ needs.resolve.outputs.build_type }}"
|
||||
if [[ "$BUILD_TYPE" == "development" ]]; then
|
||||
R2_PREFIX="artifacts/rustfs/packages/dev"
|
||||
else
|
||||
R2_PREFIX="artifacts/rustfs/packages/release"
|
||||
fi
|
||||
R2_PATH="s3://${R2_BUCKET}/${R2_PREFIX}/"
|
||||
|
||||
echo "📤 Uploading to $R2_PATH"
|
||||
|
||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
||||
RPM_FILE="${{ steps.rpm.outputs.rpm_file }}"
|
||||
|
||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||
if [[ -n "$f" && -f "$f" ]]; then
|
||||
echo "Uploading: $f"
|
||||
aws s3 cp "$f" "$R2_PATH" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Upload complete"
|
||||
|
||||
# Also upload as latest for release/prerelease
|
||||
if [[ "$BUILD_TYPE" == "release" || "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
LATEST_PATH="s3://${R2_BUCKET}/artifacts/rustfs/packages/latest/"
|
||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||
if [[ -n "$f" && -f "$f" ]]; then
|
||||
echo "Uploading latest: $(basename "$f")"
|
||||
aws s3 cp "$f" "$LATEST_PATH" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
fi
|
||||
done
|
||||
echo "✅ Latest packages updated"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
summary:
|
||||
name: Summary
|
||||
needs: [ resolve, package ]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Print summary
|
||||
shell: bash
|
||||
run: |
|
||||
echo "## 📦 Package Summary" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Item | Value |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|------|-------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Version | \`${{ needs.resolve.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Build Type | ${{ needs.resolve.outputs.build_type }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Build Run | #${{ needs.resolve.outputs.build_run_id }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Package Status | ${{ needs.package.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -17,10 +17,10 @@
|
||||
# Two entry points, honestly scoped:
|
||||
# * schedule (nightly, on main): post-merge detection — catches a regression
|
||||
# within 24h of landing, not before merge.
|
||||
# * pull_request labeled `perf-ab`: opt-in pre-merge gate for a specific PR.
|
||||
# The `perf-deliberate-tradeoff` label runs the gate with --allow-regression so
|
||||
# a deliberate correctness cost (e.g. the #4221 fsync durability fix) is
|
||||
# recorded but does not block (rustfs/backlog#935 correction 1).
|
||||
# * workflow_dispatch: an explicitly selected trusted ref.
|
||||
# The dispatch input can run the gate with --allow-regression so a deliberate
|
||||
# correctness cost (e.g. the #4221 fsync durability fix) is recorded, not
|
||||
# blocked (rustfs/backlog#935 correction 1).
|
||||
|
||||
# DISABLED. This workflow is switched off in the repository's Actions settings
|
||||
# (state: disabled_manually) and does not run on any trigger, including its cron
|
||||
@@ -46,8 +46,6 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
pull_request:
|
||||
types: [labeled, synchronize, reopened]
|
||||
push:
|
||||
# Every main commit pre-builds and caches its release binary (perf-3) so the
|
||||
# nightly A/B restores a ready baseline instead of paying the double build.
|
||||
@@ -55,14 +53,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
# Per-PR: a new push cancels the previous (up to 90-minute) A/B run instead of
|
||||
# stacking them. Nightly schedule and manual dispatch get a unique group and
|
||||
# always run to completion.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -70,8 +60,8 @@ env:
|
||||
|
||||
jobs:
|
||||
# perf-3: on every push to main, build the release binary once and cache it
|
||||
# keyed by commit SHA (rustfs-baseline-<sha>). The nightly A/B (and, later, the
|
||||
# perf-7 PR gate) restore this instead of paying the ~32min-per-side source
|
||||
# keyed by commit SHA (rustfs-baseline-<sha>). The warp-ab measurements
|
||||
# restore this instead of paying the ~32min-per-side source
|
||||
# build. That double build is what pushed the expanded 24-cell nightly past its
|
||||
# ceiling — 2026-07-11..07-14 all cancelled on the 120min timeout. Incremental
|
||||
# builds off the shared cargo cache keep each push cheap, and building on the
|
||||
@@ -126,17 +116,11 @@ jobs:
|
||||
|
||||
warp-ab:
|
||||
name: Warp A/B budget gate
|
||||
# Always run on schedule / manual dispatch. Opt-in on PRs: only when the
|
||||
# `perf-ab` label is present, and for `labeled` events only when the label
|
||||
# being added is `perf-ab` itself (adding an unrelated label to an opted-in
|
||||
# PR must not re-run the gate). Never on push — that event only feeds
|
||||
# build-baseline-cache above.
|
||||
# Always run on schedule / manual dispatch. Never on push — that event only
|
||||
# feeds build-baseline-cache above.
|
||||
if: >-
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'perf-ab') &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'perf-ab'))
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: sm-standard-2
|
||||
# With perf-3's cached baseline binary the common (cache-hit) nightly is
|
||||
# measurement-only and finishes well under 50min. This ceiling stays
|
||||
@@ -174,10 +158,6 @@ jobs:
|
||||
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
|
||||
run: |
|
||||
allow="false"
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]] \
|
||||
&& ${{ contains(github.event.pull_request.labels.*.name, 'perf-deliberate-tradeoff') }}; then
|
||||
allow="true"
|
||||
fi
|
||||
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
|
||||
allow="true"
|
||||
fi
|
||||
@@ -314,10 +294,10 @@ jobs:
|
||||
echo "candidate binary: $cand_src"
|
||||
|
||||
if [[ "${{ steps.exempt.outputs.allow_regression }}" == "true" ]]; then
|
||||
args+=(--allow-regression --exemption-reason "labeled perf-deliberate-tradeoff / dispatch override")
|
||||
args+=(--allow-regression --exemption-reason "workflow dispatch override")
|
||||
fi
|
||||
# Do not let a gate FAIL abort the job here; capture status and surface
|
||||
# it after the PR comment is posted.
|
||||
# it after the step summary is written.
|
||||
set +e
|
||||
bash scripts/run_hotpath_warp_abba.sh "${args[@]}"
|
||||
echo "status=$?" >> "$GITHUB_OUTPUT"
|
||||
@@ -382,13 +362,6 @@ jobs:
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment gate result on PR
|
||||
if: always() && github.event_name == 'pull_request' && steps.ab.outputs.gate_md != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body-file "${{ steps.ab.outputs.gate_md }}"
|
||||
|
||||
# Scheduled failure alerting is handled by the alert-on-failure job below
|
||||
# (perf-2 consuming ci-8's schedule-failure-issue composite action).
|
||||
|
||||
@@ -397,7 +370,7 @@ jobs:
|
||||
run: |
|
||||
status="${{ steps.ab.outputs.status }}"
|
||||
if [[ "$status" != "0" ]]; then
|
||||
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / PR comment / gate.md artifact." >&2
|
||||
echo "::error::warp A/B budget gate failed (exit $status). See the step summary / gate.md artifact." >&2
|
||||
exit "$status"
|
||||
fi
|
||||
echo "warp A/B budget gate passed."
|
||||
@@ -407,14 +380,12 @@ jobs:
|
||||
needs: [warp-ab]
|
||||
# `always()` is required: without it this job is skipped when a needed
|
||||
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
|
||||
# ci-8); PR and manual dispatch failures are already watched by a human.
|
||||
# ci-8); manual dispatch failures are already watched by a human.
|
||||
# `cancelled` is included alongside `failure` on purpose: a job that hits
|
||||
# timeout-minutes ends as `cancelled`, and the 2026-07-11..07-14 nightly
|
||||
# timeouts went silent precisely because the guard was failure-only. The
|
||||
# composite action already reports cancelled/timed-out jobs in the issue
|
||||
# body. (Scheduled runs get a unique concurrency group with
|
||||
# cancel-in-progress off, so a cancellation here means a timeout/manual
|
||||
# abort, never a superseding run.)
|
||||
# body.
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
|
||||
@@ -83,3 +83,7 @@ worktrees/*
|
||||
|
||||
# Local AI-agent review artifacts (omo evidence dumps)
|
||||
.omo/
|
||||
|
||||
# insta scratch files; the accepted .snap files ARE the assertions and are committed
|
||||
*.snap.new
|
||||
*.pending-snap
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: issue-triage
|
||||
description: Triage a GitHub issue — determine if it is already fixed, needs implementation, or should be closed. Searches related commits and PRs, verifies implementation status, and posts a triage comment or closes the issue. Use when the user provides an issue URL and asks whether it can be closed or needs work.
|
||||
---
|
||||
|
||||
# Issue Triage
|
||||
|
||||
Use this skill when the user provides a GitHub issue URL and asks "can this be closed?", "is this already implemented?", "check completion status", or similar triage questions.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Fetch issue context
|
||||
|
||||
```bash
|
||||
gh issue view <N> --repo <owner/repo> --json title,body,state,comments,labels,updatedAt
|
||||
```
|
||||
|
||||
Read the issue body to understand what was requested. Extract:
|
||||
- The specific feature/fix/behavior described.
|
||||
- Any linked PRs or commits mentioned in the body or comments.
|
||||
- Any checklist items or sub-issues.
|
||||
|
||||
### 2. Search for related work
|
||||
|
||||
Search git history for commits referencing the issue:
|
||||
```bash
|
||||
git log --oneline --all --grep="<N>" | head -30
|
||||
```
|
||||
|
||||
Search for related PRs:
|
||||
```bash
|
||||
gh pr list --search "fixes #<N> OR closes #<N> OR #<N>" --state all --json number,title,state,mergedAt
|
||||
```
|
||||
|
||||
If the issue mentions specific PRs, check their status:
|
||||
```bash
|
||||
gh pr view <PR_N> --json state,mergedAt,title
|
||||
```
|
||||
|
||||
### 3. Verify implementation
|
||||
|
||||
For each linked or related PR that is merged, verify the fix is actually present on the current main branch:
|
||||
```bash
|
||||
git log --oneline main | grep -i "<keyword>"
|
||||
# or
|
||||
git log --oneline main --grep="<PR_N>"
|
||||
```
|
||||
|
||||
If the issue describes a specific defect, check the relevant code to confirm the fix is in place:
|
||||
```bash
|
||||
grep -n "<pattern>" crates/<relevant>/src/<file>.rs
|
||||
```
|
||||
|
||||
For issues with checklists, verify each item individually. If sub-items are tracked as separate issues, check those too:
|
||||
```bash
|
||||
gh issue view <SUB_N> --repo <owner/repo> --json state
|
||||
```
|
||||
|
||||
### 4. Determine verdict
|
||||
|
||||
- **All items fixed and merged**: Close with a summary comment listing what was fixed and which PRs.
|
||||
- **Some items fixed, some remaining**: Comment with status of each item. Do not close.
|
||||
- **Not yet implemented**: Comment with a summary of what remains. Do not close.
|
||||
- **Superseded or no longer relevant**: Close with explanation.
|
||||
|
||||
### 5. Take action
|
||||
|
||||
Close with comment:
|
||||
```bash
|
||||
gh issue close <N> --repo <owner/repo> --comment "<body>"
|
||||
```
|
||||
|
||||
Comment without closing:
|
||||
```bash
|
||||
gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md
|
||||
```
|
||||
|
||||
Update issue labels if needed:
|
||||
```bash
|
||||
gh issue edit <N> --repo <owner/repo> --add-label "completed" --remove-label "needs-triage"
|
||||
```
|
||||
|
||||
Always use `--body-file` for multiline content, never inline `--body`.
|
||||
|
||||
### 6. Handle multi-issue batches
|
||||
|
||||
When the user asks to check multiple issues (e.g., "check all issues by user X" or "scan backlog for closable issues"):
|
||||
1. List the issues: `gh issue list --repo <repo> --author <user> --state open --json number,title,updatedAt`
|
||||
2. For each issue, run steps 1-5 above.
|
||||
3. Report a summary table of all triaged issues with verdicts.
|
||||
|
||||
## Output format
|
||||
|
||||
### Issue Triage: #<N> — <title>
|
||||
|
||||
**State**: OPEN / CLOSED
|
||||
**Linked PRs**: <list with merge status>
|
||||
|
||||
#### Assessment
|
||||
<what was requested vs what is implemented>
|
||||
|
||||
#### Verdict
|
||||
- Close — all items resolved by <PR list>
|
||||
- Keep open — <remaining items>
|
||||
- Not started — <what needs to be done>
|
||||
|
||||
#### Action taken
|
||||
- Closed with comment / Commented / No action
|
||||
|
||||
## Notes
|
||||
|
||||
- The user may ask in Chinese ("是否可以关闭", "检查完成情况"); respond in the same language.
|
||||
- When closing, always include a summary of what was fixed and which PRs resolved it — this creates a useful audit trail.
|
||||
- For issues in `rustfs/backlog`, use `--repo rustfs/backlog`.
|
||||
- For issues in `rustfs/rustfs`, use `--repo rustfs/rustfs`.
|
||||
- If the issue has sub-issues (GitHub sub-issues API), check each one's state before declaring the parent complete.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
name: pr-review
|
||||
description: Review a GitHub PR end-to-end from a URL or number — fetch metadata, inspect the diff, run multi-role adversarial review, check CI status, and post the review comment. Use when the user provides a PR link and asks to review it.
|
||||
---
|
||||
|
||||
# PR Review
|
||||
|
||||
Use this skill when the user provides a GitHub PR URL or number and asks to review it. This covers the full review lifecycle: data gathering, code review, CI verification, and posting the result.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Read `AGENTS.md` for the repository's adversarial validation policy and change-style rules.
|
||||
- The `adversarial-validation` skill handles the review role playbooks; this skill orchestrates the workflow around it.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Gather PR context
|
||||
|
||||
```bash
|
||||
gh pr view <N> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName
|
||||
gh pr diff <N> --name-only
|
||||
```
|
||||
|
||||
Read the PR body and linked issues to understand the change's purpose. If the PR references an issue, fetch that too:
|
||||
```bash
|
||||
gh issue view <ISSUE> --json title,body,state
|
||||
```
|
||||
|
||||
### 2. Fetch the diff and classify the change
|
||||
|
||||
```bash
|
||||
git fetch origin pull/<N>/head:pr-<N>
|
||||
git diff main...pr-<N> --stat
|
||||
```
|
||||
|
||||
Classify the change by risk tier (per AGENTS.md):
|
||||
- **Exempt**: docs/comments/instruction-only, formatting, typos.
|
||||
- **Mechanical**: renames, file moves, test-only or tooling changes.
|
||||
- **Standard** (default): any behavior change.
|
||||
- **High risk**: locking, erasure coding, quorum/heal, replication, multipart, RPC, lifecycle/tiering, metadata formats, persistence/fsync, IAM/KMS/auth, on-disk/on-wire formats, S3 API-visible behavior.
|
||||
|
||||
### 3. Cluster changed files and delegate review
|
||||
|
||||
Group the changed files into logical clusters (by crate or functional area). For each cluster, spawn a subagent with a focused review prompt that includes:
|
||||
- The cluster's changed files and their diffs.
|
||||
- The applicable adversarial role probes (from the `adversarial-validation` skill).
|
||||
- The repository's AGENTS.md rules relevant to that domain.
|
||||
|
||||
For standard-tier changes: correctness adversary + simplicity adversary + test-coverage skeptic, plus every role whose domain the diff touches.
|
||||
For high-risk changes: run all seven roles.
|
||||
|
||||
Each subagent must produce findings (concrete failure scenario with file:line) or a null report ("attacked X, Y, Z — no break found").
|
||||
|
||||
### 4. Check CI status
|
||||
|
||||
```bash
|
||||
gh pr checks <N>
|
||||
```
|
||||
|
||||
If any checks fail, investigate:
|
||||
```bash
|
||||
gh run view --log-failed --job=<JOB_ID>
|
||||
```
|
||||
|
||||
Determine whether failures are pre-existing (on main), flaky, or caused by the PR.
|
||||
|
||||
### 5. Synthesize findings
|
||||
|
||||
Combine all subagent findings into a structured review:
|
||||
- **Summary**: one-paragraph overview of the change and overall assessment.
|
||||
- **Findings**: each finding with severity (critical/major/minor/nit), file:line, concrete failure scenario, and suggested fix.
|
||||
- **CI status**: pass/fail with notes on any failures.
|
||||
- **Verdict**: APPROVE, REQUEST_CHANGES, or COMMENT.
|
||||
|
||||
### 6. Post the review
|
||||
|
||||
Write the review body to a temp file and post via CLI:
|
||||
```bash
|
||||
# Request changes
|
||||
gh pr review <N> --request-changes --body-file /tmp/pr_review.md
|
||||
|
||||
# Approve
|
||||
gh pr review <N> --approve --body-file /tmp/pr_review.md
|
||||
|
||||
# Comment only (no verdict)
|
||||
gh pr review <N> --comment --body-file /tmp/pr_review.md
|
||||
```
|
||||
|
||||
For inline comments on specific lines, use the GitHub API:
|
||||
```bash
|
||||
cat > /tmp/pr_review.json <<'EOF'
|
||||
{
|
||||
"body": "review body",
|
||||
"event": "REQUEST_CHANGES",
|
||||
"comments": [
|
||||
{
|
||||
"path": "crates/foo/src/bar.rs",
|
||||
"line": 42,
|
||||
"body": "finding description"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
gh api --method POST /repos/{owner}/{repo}/pulls/<N>/reviews --input /tmp/pr_review.json
|
||||
```
|
||||
|
||||
Always use `--body-file` or `--input`, never inline multiline `--body`.
|
||||
|
||||
### 7. Handle follow-up
|
||||
|
||||
If the review requests changes:
|
||||
- Monitor for new commits: `gh pr view <N> --json commits`
|
||||
- Re-review changed files only: `git diff pr-<N>..origin/pull/<N>/head`
|
||||
- Update the review when findings are addressed.
|
||||
|
||||
If CI was failing due to pre-existing main breakage:
|
||||
- Comment on the PR noting the failure is pre-existing.
|
||||
- Suggest updating the branch: `gh pr update-branch <N>`
|
||||
|
||||
## Output format
|
||||
|
||||
### PR Review: #<N> — <title>
|
||||
|
||||
**Author**: <author>
|
||||
**Risk tier**: exempt | mechanical | standard | high-risk
|
||||
**Changed files**: <count> across <cluster count> clusters
|
||||
|
||||
#### Summary
|
||||
<one-paragraph overview>
|
||||
|
||||
#### Findings
|
||||
| Severity | Location | Finding |
|
||||
|----------|----------|---------|
|
||||
| critical | file:line | concrete failure scenario |
|
||||
|
||||
#### CI Status
|
||||
- All checks pass / Failing: <details>
|
||||
|
||||
#### Verdict
|
||||
APPROVE / REQUEST_CHANGES / COMMENT
|
||||
|
||||
## Notes
|
||||
|
||||
- The user may ask for review in Chinese; respond in the same language but keep the review body in English per AGENTS.md rules.
|
||||
- When the user asks for "多角色对抗 review", run the full adversarial validation protocol — this skill's step 3 covers that.
|
||||
- If the PR is from a fork, check `maintainerCanModify` before attempting to push fixes.
|
||||
- For very large PRs (>50 files), cluster aggressively and delegate in parallel to keep review time reasonable.
|
||||
@@ -322,6 +322,28 @@ High risk: all seven roles.
|
||||
- Use environment variables or vault tooling for sensitive configuration.
|
||||
- For localhost-sensitive tests, verify proxy settings to avoid traffic leakage.
|
||||
|
||||
## Logging
|
||||
|
||||
Applies to **every** `tracing` macro you add or edit, including a single line
|
||||
added in passing while fixing something else — not only to log-focused changes.
|
||||
|
||||
- Fields first, message second: `event`, `component`, `subsystem`,
|
||||
`result`/`state`, then key context. The message is a short label, not a
|
||||
sentence with values interpolated into it.
|
||||
- Reuse the existing `EVENT_*` / `LOG_COMPONENT_*` / `LOG_SUBSYSTEM_*`
|
||||
constants of the module you are editing; match the shape of the log sites
|
||||
already in that file rather than introducing a second style next to them.
|
||||
- Level policy: `error` for behavior/security-affecting failures, `warn` for
|
||||
degraded or fallback paths, `info` for low-frequency lifecycle, `debug` for
|
||||
targeted diagnostics, `trace` for hot paths. Per-object and per-request
|
||||
success paths are `trace`.
|
||||
- Never log secrets, tokens, credential payloads, or merged config dumps.
|
||||
- `scripts/check_logging_guardrails.sh` enforces a subset of this on the files
|
||||
it lists; passing it is a floor, not evidence the log matches the house style.
|
||||
|
||||
See `.agents/skills/rustfs-logging-governance/SKILL.md` for the full event
|
||||
model, level policy, and guardrail-update checklist.
|
||||
|
||||
## Tools
|
||||
|
||||
### xl.meta decode tool Quick Use
|
||||
|
||||
Generated
+279
-244
File diff suppressed because it is too large
Load Diff
+55
-56
@@ -69,7 +69,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.97.1"
|
||||
version = "1.0.0-beta.12"
|
||||
version = "1.0.0-rc.1"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -86,52 +86,52 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-beta.12" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.12" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.12" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.12" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-beta.12" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.12" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.12" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.12" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.12" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.12" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.12" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.12" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.12" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.12" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.12" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.12" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.12" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.12" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.12" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.12" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.12" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.12" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.12" }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.12" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.12" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.12" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.12" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.12" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.12" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.12" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.12" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.12" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.12" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.12" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.12" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.12" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.12" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.12" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.12" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.12" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.12" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.12" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.12" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.12" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.12" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.12" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.1" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.1" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.1" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.1" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.1" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.1" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.1" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.1" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.1" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.1" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.1" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.1" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.1" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.1" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.1" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.1" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.1" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.1" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.1" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.1" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.1" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.1" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.1", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.1" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.1" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.1" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.1" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.1" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.1" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.1" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.1" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.1" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.1" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.1" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.1" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.1" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.1" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.1" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.1" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.1" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.1" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.1" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.1" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.1" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.1" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.1" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -228,15 +228,15 @@ atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.10.1" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.114.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.140.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.141.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.110.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
|
||||
aws-smithy-runtime-api = { version = "1.14.0" }
|
||||
aws-smithy-types = { version = "1.6.1" }
|
||||
base64 = "0.23.0"
|
||||
base64 = "0.23.1"
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
clap = { version = "4.6.5" }
|
||||
clap = { version = "4.6.6" }
|
||||
const-str = { version = "1.1.0" }
|
||||
convert_case = "0.11.0"
|
||||
criterion = { version = "0.8" }
|
||||
@@ -244,7 +244,7 @@ crossbeam-queue = "0.3.13"
|
||||
crossbeam-channel = "0.5.16"
|
||||
crossbeam-deque = "0.8.7"
|
||||
crossbeam-utils = "0.8.22"
|
||||
datafusion = { default-features = false, git = "https://github.com/apache/datafusion.git", rev = "dae03ee062b2abf986de8df12ea82fb1578a2d99" }
|
||||
datafusion = { default-features = false, git = "https://github.com/apache/datafusion.git", rev = "e08aed1e5de41dcf81d529140dae07723b942a5e" }
|
||||
#datafusion = { default-features = false, version = "54.1.0" }
|
||||
derive_builder = "0.20.2"
|
||||
enumset = "1.1.14"
|
||||
@@ -274,7 +274,6 @@ num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.12.1"
|
||||
parking_lot = "0.12.5"
|
||||
path-absolutize = "4.0.1"
|
||||
path-clean = "1.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
pin-project-lite = "0.2.17"
|
||||
pretty_assertions = "1.4.1"
|
||||
@@ -342,15 +341,15 @@ unftp-core = "0.1.0"
|
||||
suppaftp = { version = "10.0.1" }
|
||||
rcgen = { version = "0.14.8", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.62.5" }
|
||||
russh-sftp = "2.3.0"
|
||||
russh-sftp = "2.4.0"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "1cdadea43e9c5a0f054b65be21200ce580e4eb13" }
|
||||
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "1cdadea43e9c5a0f054b65be21200ce580e4eb13", features = ["extended"] }
|
||||
hotpath = { version = "0.23.0", default-features = false }
|
||||
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "ce6338661179c8be22e516b00af7483f151485a7" }
|
||||
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "ce6338661179c8be22e516b00af7483f151485a7", features = ["extended"] }
|
||||
hotpath = { version = "0.23.1", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.12
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.1
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.12
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.1
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
|
||||
@@ -55,10 +55,10 @@ hotpath.workspace = true
|
||||
rustfs-targets = { workspace = true }
|
||||
rustfs-config = { workspace = true, features = ["audit", "server-config-model"] }
|
||||
rustfs-s3-types = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
const-str = { workspace = true, features = ["std", "proc"] }
|
||||
futures = { workspace = true }
|
||||
hashbrown = { workspace = true, features = ["serde", "rayon"] }
|
||||
jiff = { workspace = true, features = ["serde"] }
|
||||
metrics = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use hashbrown::HashMap;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_s3_types::EventName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -151,8 +151,8 @@ pub struct AuditEntry {
|
||||
pub deployment_id: Option<String>,
|
||||
#[serde(rename = "siteName", skip_serializing_if = "Option::is_none")]
|
||||
pub site_name: Option<String>,
|
||||
#[serde(with = "chrono::serde::ts_milliseconds")]
|
||||
pub time: DateTime<Utc>,
|
||||
#[serde(with = "jiff::fmt::serde::timestamp::millisecond::required")]
|
||||
pub time: Timestamp,
|
||||
pub event: EventName,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub entry_type: Option<String>,
|
||||
@@ -198,7 +198,7 @@ impl AuditEntryBuilder {
|
||||
pub fn new(version: impl Into<String>, event: EventName, trigger: impl Into<String>, api: ApiDetails) -> Self {
|
||||
Self(AuditEntry {
|
||||
version: version.into(),
|
||||
time: Utc::now(),
|
||||
time: Timestamp::now(),
|
||||
event,
|
||||
trigger: trigger.into(),
|
||||
api,
|
||||
@@ -232,7 +232,7 @@ impl AuditEntryBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn time(mut self, time: DateTime<Utc>) -> Self {
|
||||
pub fn time(mut self, time: Timestamp) -> Self {
|
||||
self.0.time = time;
|
||||
self
|
||||
}
|
||||
@@ -342,4 +342,23 @@ mod tests {
|
||||
assert_eq!(value["requestID"], Value::String("req-audit-123".to_string()));
|
||||
assert!(value.get("request_id").is_none(), "historical audit contract must not expose request_id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_entry_time_serializes_as_epoch_milliseconds() {
|
||||
let entry = AuditEntryBuilder::new(
|
||||
"1",
|
||||
EventName::ObjectCreatedPut,
|
||||
"s3",
|
||||
ApiDetailsBuilder::new()
|
||||
.name("PutObject")
|
||||
.status("OK")
|
||||
.status_code(200)
|
||||
.build(),
|
||||
)
|
||||
.time(Timestamp::from_millisecond(1_711_423_698_870).expect("timestamp should be valid"))
|
||||
.build();
|
||||
|
||||
let value = serde_json::to_value(entry).expect("audit entry should serialize");
|
||||
assert_eq!(value["time"], Value::Number(1_711_423_698_870_i64.into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ async fn test_audit_log_dispatch_performance() {
|
||||
return; // Alternatively: assert!(false, "AuditSystem failed to start");
|
||||
}
|
||||
|
||||
use chrono::Utc;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_targets::EventName;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@@ -136,7 +136,7 @@ async fn test_audit_log_dispatch_performance() {
|
||||
version: "1".to_string(),
|
||||
deployment_id: Some(format!("test-deployment-{id}")),
|
||||
site_name: Some("test-site".to_string()),
|
||||
time: Utc::now(),
|
||||
time: Timestamp::now(),
|
||||
event: EventName::ObjectCreatedPut,
|
||||
entry_type: Some("object".to_string()),
|
||||
trigger: "api".to_string(),
|
||||
@@ -298,7 +298,7 @@ fn test_performance_requirements() {
|
||||
for i in 0..3000 {
|
||||
// Simulate event name parsing and processing
|
||||
let _event_id = format!("s3:ObjectCreated:Put_{i}");
|
||||
let _timestamp = chrono::Utc::now().to_rfc3339();
|
||||
let _timestamp = jiff::Timestamp::now().to_string();
|
||||
|
||||
// Simulate basic audit entry creation overhead
|
||||
let _entry_size = 512; // bytes
|
||||
|
||||
@@ -264,7 +264,7 @@ fn create_sample_audit_entry() -> AuditEntry {
|
||||
}
|
||||
|
||||
fn create_sample_audit_entry_with_id(id: u32) -> AuditEntry {
|
||||
use chrono::Utc;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_targets::EventName;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -301,7 +301,7 @@ fn create_sample_audit_entry_with_id(id: u32) -> AuditEntry {
|
||||
version: "1".to_string(),
|
||||
deployment_id: Some(format!("test-deployment-{id}")),
|
||||
site_name: Some("test-site".to_string()),
|
||||
time: Utc::now(),
|
||||
time: Timestamp::now(),
|
||||
event: EventName::ObjectCreatedPut,
|
||||
entry_type: Some("object".to_string()),
|
||||
trigger: "api".to_string(),
|
||||
|
||||
@@ -39,11 +39,15 @@ tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
jiff = { workspace = true, features = ["serde"] }
|
||||
metrics = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
rmp-serde = { workspace = true }
|
||||
s3s = { workspace = true, features = ["minio"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -356,6 +356,8 @@ pub struct HealChannelRequest {
|
||||
pub recursive: Option<bool>,
|
||||
/// Whether to dry run
|
||||
pub dry_run: Option<bool>,
|
||||
/// Whether to skip namespace locking
|
||||
pub no_lock: Option<bool>,
|
||||
/// Timeout in seconds (optional)
|
||||
pub timeout_seconds: Option<u64>,
|
||||
/// Origin of the request for operational status and queue accounting
|
||||
@@ -560,6 +562,7 @@ pub fn create_heal_request(
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
source: HealRequestSource::Internal,
|
||||
disk: None,
|
||||
@@ -718,6 +721,7 @@ pub async fn send_heal_disk(set_disk_id: String, priority: Option<HealChannelPri
|
||||
update_parity: None,
|
||||
recursive: None,
|
||||
dry_run: None,
|
||||
no_lock: None,
|
||||
timeout_seconds: None,
|
||||
source: HealRequestSource::AutoHeal,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
use crate::heal_channel::HealScanMode;
|
||||
use crate::last_minute::{AccElem, LastMinuteLatency};
|
||||
use chrono::{DateTime, Utc};
|
||||
use jiff::Timestamp;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
@@ -669,7 +670,7 @@ impl LockedLastMinuteLatency {
|
||||
#[derive(Clone, Debug)]
|
||||
struct CurrentPathState {
|
||||
path: String,
|
||||
updated_at: DateTime<Utc>,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
struct CurrentPathTracker {
|
||||
@@ -678,10 +679,10 @@ struct CurrentPathTracker {
|
||||
|
||||
impl CurrentPathTracker {
|
||||
fn new(initial_path: String) -> Self {
|
||||
Self::new_at(initial_path, Utc::now())
|
||||
Self::new_at(initial_path, Timestamp::now())
|
||||
}
|
||||
|
||||
fn new_at(initial_path: String, updated_at: DateTime<Utc>) -> Self {
|
||||
fn new_at(initial_path: String, updated_at: Timestamp) -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(CurrentPathState {
|
||||
path: initial_path,
|
||||
@@ -693,7 +694,7 @@ impl CurrentPathTracker {
|
||||
async fn update_path(&self, path: String) {
|
||||
let mut state = self.state.write().await;
|
||||
state.path = path;
|
||||
state.updated_at = Utc::now();
|
||||
state.updated_at = Timestamp::now();
|
||||
}
|
||||
|
||||
async fn get_state(&self) -> CurrentPathState {
|
||||
@@ -701,6 +702,36 @@ impl CurrentPathTracker {
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_to_jiff_timestamp(dt: DateTime<Utc>) -> Timestamp {
|
||||
let seconds = dt.timestamp();
|
||||
let nanoseconds = match i32::try_from(dt.timestamp_subsec_nanos()) {
|
||||
Ok(nanoseconds) => nanoseconds,
|
||||
Err(_) => {
|
||||
return if seconds < 0 { Timestamp::MIN } else { Timestamp::MAX };
|
||||
}
|
||||
};
|
||||
|
||||
match Timestamp::new(seconds, nanoseconds) {
|
||||
Ok(timestamp) => timestamp,
|
||||
Err(_) => {
|
||||
if seconds < 0 {
|
||||
Timestamp::MIN
|
||||
} else {
|
||||
Timestamp::MAX
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp_elapsed_seconds_since(now: Timestamp, earlier: Timestamp) -> u64 {
|
||||
let duration = now.duration_since(earlier);
|
||||
if duration.is_negative() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
u64::try_from(duration.as_secs()).map_or(u64::MAX, |seconds| seconds)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ScannerDiskBucketScanState {
|
||||
concurrency_limit: u64,
|
||||
@@ -1166,12 +1197,12 @@ pub struct ScannerLastMinute {
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ScannerMetricsReport {
|
||||
pub collected_at: DateTime<Utc>,
|
||||
pub collected_at: Timestamp,
|
||||
pub current_cycle: u64,
|
||||
#[serde(default)]
|
||||
pub current_cycle_active: bool,
|
||||
pub current_started: DateTime<Utc>,
|
||||
pub cycles_completed_at: Vec<DateTime<Utc>>,
|
||||
pub current_started: Timestamp,
|
||||
pub cycles_completed_at: Vec<Timestamp>,
|
||||
pub ongoing_buckets: usize,
|
||||
#[serde(default)]
|
||||
pub active_scan_paths: usize,
|
||||
@@ -2988,8 +3019,8 @@ impl Metrics {
|
||||
let cycle = self.cycle_info.read().await;
|
||||
let has_cycle = if let Some(cycle) = cycle.as_ref() {
|
||||
m.current_cycle = cycle.current;
|
||||
m.cycles_completed_at = cycle.cycle_completed.clone();
|
||||
m.current_started = cycle.started;
|
||||
m.cycles_completed_at = cycle.cycle_completed.iter().copied().map(chrono_to_jiff_timestamp).collect();
|
||||
m.current_started = chrono_to_jiff_timestamp(cycle.started);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -3024,15 +3055,15 @@ impl Metrics {
|
||||
};
|
||||
|
||||
if !has_cycle && let Some(init_time) = crate::get_global_init_time().await {
|
||||
m.current_started = init_time;
|
||||
m.current_started = chrono_to_jiff_timestamp(init_time);
|
||||
}
|
||||
|
||||
m.collected_at = Utc::now();
|
||||
m.collected_at = Timestamp::now();
|
||||
let current_path_snapshots = self.current_path_snapshots().await;
|
||||
m.active_scan_paths = current_path_snapshots.len();
|
||||
m.oldest_active_path_age_seconds = current_path_snapshots
|
||||
.iter()
|
||||
.map(|(_, state)| m.collected_at.signed_duration_since(state.updated_at).num_seconds().max(0) as u64)
|
||||
.map(|(_, state)| timestamp_elapsed_seconds_since(m.collected_at, state.updated_at))
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
m.active_paths = current_path_snapshots
|
||||
@@ -3308,6 +3339,22 @@ impl Drop for CloseDiskGuard {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scanner_metrics_report_timestamps_serialize_as_rfc3339_utc() {
|
||||
let report = ScannerMetricsReport {
|
||||
collected_at: Timestamp::constant(1_700_000_000, 123_456_000),
|
||||
current_started: Timestamp::constant(1_699_999_940, 0),
|
||||
cycles_completed_at: vec![Timestamp::constant(1_700_000_060, 987_654_000)],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&report).expect("scanner metrics report should serialize");
|
||||
|
||||
assert_eq!(value["collected_at"].as_str(), Some("2023-11-14T22:13:20.123456Z"));
|
||||
assert_eq!(value["current_started"].as_str(), Some("2023-11-14T22:12:20Z"));
|
||||
assert_eq!(value["cycles_completed_at"][0].as_str(), Some("2023-11-14T22:14:20.987654Z"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_disk_guard_runs_cleanup_when_an_early_return_drops_it() {
|
||||
let (closed_tx, closed_rx) = tokio::sync::oneshot::channel();
|
||||
@@ -3366,7 +3413,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn report_counts_active_scan_paths() {
|
||||
let metrics = Metrics::new();
|
||||
let updated_at = Utc::now() - chrono::Duration::seconds(12);
|
||||
let updated_at = Timestamp::now() - jiff::SignedDuration::from_secs(12);
|
||||
metrics.current_paths.write().await.insert(
|
||||
"disk-a".to_string(),
|
||||
Arc::new(CurrentPathTracker::new_at("bucket-a".to_string(), updated_at)),
|
||||
@@ -3388,7 +3435,7 @@ mod tests {
|
||||
let metrics = Metrics::new();
|
||||
let tracker = Arc::new(CurrentPathTracker::new_at(
|
||||
"bucket-a".to_string(),
|
||||
Utc::now() - chrono::Duration::hours(1),
|
||||
Timestamp::now() - jiff::SignedDuration::from_secs(60 * 60),
|
||||
));
|
||||
metrics
|
||||
.current_paths
|
||||
@@ -4161,7 +4208,7 @@ mod tests {
|
||||
let report = metrics.report().await;
|
||||
*crate::globals::GLOBAL_INIT_TIME.write().await = previous_init_time;
|
||||
|
||||
assert_eq!(report.current_started, cycle_started);
|
||||
assert_eq!(report.current_started, chrono_to_jiff_timestamp(cycle_started));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4584,7 +4631,7 @@ mod tests {
|
||||
let active = metrics.report().await;
|
||||
assert!(active.current_cycle_active);
|
||||
assert_eq!(active.current_cycle, 12);
|
||||
assert_eq!(active.current_started, cycle_started);
|
||||
assert_eq!(active.current_started, chrono_to_jiff_timestamp(cycle_started));
|
||||
|
||||
let idle_cycle = CurrentCycle {
|
||||
current: 0,
|
||||
|
||||
@@ -177,10 +177,9 @@ const _: () = assert!(!DEFAULT_INTERNODE_RPC_REPLAY_SCOPE_STRICT);
|
||||
///
|
||||
/// The cache retains each nonce for the ~10-minute signature freshness envelope. Once peers use
|
||||
/// replay-scoped v3 authentication, every authenticated RPC consumes one entry, so the steady
|
||||
/// state holds roughly `authenticated RPC RPS x 601s` entries. The default sustains about 1,700
|
||||
/// authenticated RPCs per second (about 120 MiB worst case, allocated only under sustained load);
|
||||
/// operators must size it for the node's aggregate peak RPC rate before enabling strict replay
|
||||
/// scope. Overflow fails closed — legitimate signed traffic is the only thing that can fill the
|
||||
/// state holds roughly `authenticated RPC RPS x 601s` entries. This default is the minimum floor:
|
||||
/// explicit operator values and resource-aware auto sizing both clamp upward to at least this
|
||||
/// value. Overflow fails closed — legitimate signed traffic is the only thing that can fill the
|
||||
/// cache (replays are rejected before insertion, and an attacker cannot mint valid nonces without
|
||||
/// the shared secret) — and increments
|
||||
/// `rustfs_system_network_internode_replay_cache_overflow_total`, so a sustained non-zero overflow
|
||||
|
||||
@@ -37,6 +37,10 @@ pub const USAGE_LAST_UPDATE_FUTURE_TOLERANCE: Duration = Duration::from_secs(5 *
|
||||
/// Keeping the existing object name preserves rolling-upgrade and rollback
|
||||
/// compatibility without allowing an ambiguous snapshot to become authoritative.
|
||||
pub const DATA_USAGE_OBJECT_NAME: &str = ".usage.v2.json";
|
||||
/// Latest structurally complete scanner observation. Unlike
|
||||
/// [`DATA_USAGE_OBJECT_NAME`], this object is never authoritative for quota
|
||||
/// admission because namespace activity may have raced the scan.
|
||||
pub const DATA_USAGE_OBSERVED_OBJECT_NAME: &str = ".usage.observed.json";
|
||||
|
||||
/// Usage snapshot written by scanner implementations predating distributed
|
||||
/// leadership fencing. It is read only when neither authoritative snapshot
|
||||
@@ -218,6 +222,20 @@ pub struct DataUsageInfo {
|
||||
/// explicit entry for every bucket, including confirmed-empty buckets.
|
||||
#[serde(default)]
|
||||
pub usage_snapshot_complete: bool,
|
||||
/// Whether no namespace activity or dirty-usage generation changed while
|
||||
/// the coordinated snapshot was being produced.
|
||||
///
|
||||
/// `false` still describes a structurally complete, useful point-in-time
|
||||
/// usage view, but follow-up scanner work remains pending. `None` is kept
|
||||
/// for snapshots written before this status became observable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage_snapshot_converged: Option<bool>,
|
||||
/// Identity of the authoritative snapshot from which a nonconverged
|
||||
/// observation started. Admin readers require an exact match before using
|
||||
/// the observation, so bucket namespace mutations fence old observations
|
||||
/// without relying on synchronized clocks.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage_snapshot_authoritative_baseline: Option<DataUsageSnapshotIdentity>,
|
||||
/// Deprecated kept here for backward compatibility reasons
|
||||
pub bucket_sizes: HashMap<String, u64>,
|
||||
/// Per-disk snapshot information when available
|
||||
@@ -225,6 +243,59 @@ pub struct DataUsageInfo {
|
||||
pub disk_usage_status: Vec<DiskUsageStatus>,
|
||||
}
|
||||
|
||||
/// Stable identity fields changed by both coordinated scanner publication and
|
||||
/// backward-compatible bucket namespace cleanup.
|
||||
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DataUsageSnapshotIdentity {
|
||||
pub last_update: Option<SystemTime>,
|
||||
pub scanner_cycle: Option<u64>,
|
||||
pub scanner_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
impl DataUsageInfo {
|
||||
pub fn snapshot_identity(&self) -> DataUsageSnapshotIdentity {
|
||||
DataUsageSnapshotIdentity {
|
||||
last_update: self.last_update,
|
||||
scanner_cycle: self.scanner_cycle,
|
||||
scanner_epoch: self.scanner_epoch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether `candidate` was produced after `baseline`.
|
||||
///
|
||||
/// New coordinated snapshots are ordered by leadership epoch and scanner
|
||||
/// cycle. The timestamp fallback preserves ordering for legacy snapshots that
|
||||
/// predate those fields.
|
||||
pub fn data_usage_snapshot_is_newer(candidate: &DataUsageInfo, baseline: &DataUsageInfo) -> bool {
|
||||
match (
|
||||
candidate.scanner_epoch.zip(candidate.scanner_cycle),
|
||||
baseline.scanner_epoch.zip(baseline.scanner_cycle),
|
||||
) {
|
||||
(Some(candidate), Some(baseline)) => candidate > baseline,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => match (candidate.last_update, baseline.last_update) {
|
||||
(Some(candidate), Some(baseline)) => candidate > baseline,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_) | None) => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether a nonconverged observation may safely supersede the admin
|
||||
/// view of `authoritative`.
|
||||
///
|
||||
/// The exact baseline identity is independent of clock ordering. Older binaries
|
||||
/// already advance the authoritative timestamp when deleting a bucket, so a
|
||||
/// rollback delete/recreate fences the previous bucket incarnation too.
|
||||
pub fn observed_data_usage_is_newer(observed: &DataUsageInfo, authoritative: &DataUsageInfo) -> bool {
|
||||
observed.usage_snapshot_converged == Some(false)
|
||||
&& observed.is_complete_bucket_usage_snapshot()
|
||||
&& observed.usage_snapshot_authoritative_baseline.as_ref() == Some(&authoritative.snapshot_identity())
|
||||
&& data_usage_snapshot_is_newer(observed, authoritative)
|
||||
}
|
||||
|
||||
/// Metadata describing the status of a disk-level data usage snapshot.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DiskUsageStatus {
|
||||
@@ -1783,6 +1854,8 @@ mod tests {
|
||||
let current = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||
usage_snapshot_complete: true,
|
||||
usage_snapshot_converged: Some(false),
|
||||
usage_snapshot_authoritative_baseline: Some(DataUsageSnapshotIdentity::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(¤t).expect("encode current data usage snapshot");
|
||||
@@ -1790,6 +1863,76 @@ mod tests {
|
||||
|
||||
assert_eq!(legacy.buckets_count, 0);
|
||||
assert!(current.is_complete_bucket_usage_snapshot());
|
||||
assert_eq!(current.usage_snapshot_converged, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convergence_marker_defaults_to_unknown_for_older_snapshots() {
|
||||
let encoded = rmp_serde::to_vec_named(&DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("encode pre-convergence data usage snapshot");
|
||||
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode older data usage snapshot");
|
||||
|
||||
assert!(decoded.is_complete_bucket_usage_snapshot());
|
||||
assert_eq!(decoded.usage_snapshot_converged, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_selection_is_clock_independent_and_baseline_fenced() {
|
||||
let mut authoritative = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(600)),
|
||||
scanner_epoch: Some(7),
|
||||
scanner_cycle: Some(10),
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
let observed = DataUsageInfo {
|
||||
// A newer leader may have a slower wall clock.
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(300)),
|
||||
scanner_epoch: Some(8),
|
||||
scanner_cycle: Some(1),
|
||||
usage_snapshot_complete: true,
|
||||
usage_snapshot_converged: Some(false),
|
||||
usage_snapshot_authoritative_baseline: Some(authoritative.snapshot_identity()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(observed_data_usage_is_newer(&observed, &authoritative));
|
||||
|
||||
authoritative.last_update = Some(SystemTime::UNIX_EPOCH + Duration::from_secs(601));
|
||||
assert!(
|
||||
!observed_data_usage_is_newer(&observed, &authoritative),
|
||||
"an old-binary namespace mutation must fence the prior bucket incarnation regardless of clock skew"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observation_selection_requires_nonconverged_complete_newer_data() {
|
||||
let authoritative = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||
scanner_epoch: Some(2),
|
||||
scanner_cycle: Some(10),
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
let baseline = Some(authoritative.snapshot_identity());
|
||||
let candidate = |epoch, cycle, converged, complete| DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1)),
|
||||
scanner_epoch: Some(epoch),
|
||||
scanner_cycle: Some(cycle),
|
||||
usage_snapshot_complete: complete,
|
||||
usage_snapshot_converged: converged,
|
||||
usage_snapshot_authoritative_baseline: baseline,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(observed_data_usage_is_newer(&candidate(2, 11, Some(false), true), &authoritative));
|
||||
assert!(!observed_data_usage_is_newer(&candidate(2, 9, Some(false), true), &authoritative));
|
||||
assert!(!observed_data_usage_is_newer(&candidate(2, 11, Some(true), true), &authoritative));
|
||||
assert!(!observed_data_usage_is_newer(&candidate(2, 11, Some(false), false), &authoritative));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,91 +16,17 @@
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, signed_s3_request};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{
|
||||
AccelerateConfiguration, BucketAccelerateStatus, BucketLoggingStatus, IndexDocument, LoggingEnabled, Payer,
|
||||
RequestPaymentConfiguration, WebsiteConfiguration,
|
||||
};
|
||||
use http::Method;
|
||||
use http::header::CONTENT_TYPE;
|
||||
use serial_test::serial;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tracing::info;
|
||||
|
||||
fn awscurl_binary_path() -> PathBuf {
|
||||
std::env::var_os("AWSCURL_PATH")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("awscurl"))
|
||||
}
|
||||
|
||||
fn awscurl_available() -> bool {
|
||||
Command::new(awscurl_binary_path()).arg("--version").output().is_ok()
|
||||
}
|
||||
|
||||
fn execute_s3_awscurl(
|
||||
method: &str,
|
||||
url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let output = Command::new(awscurl_binary_path())
|
||||
.args([
|
||||
"--service",
|
||||
"s3",
|
||||
"--region",
|
||||
"us-east-1",
|
||||
"--access_key",
|
||||
access_key,
|
||||
"--secret_key",
|
||||
secret_key,
|
||||
"-i",
|
||||
"-X",
|
||||
method,
|
||||
url,
|
||||
])
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
return Err(format!("awscurl failed: stderr='{stderr}', stdout='{stdout}'").into());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
fn parse_status(raw: &str) -> Option<u16> {
|
||||
raw.lines()
|
||||
.filter_map(|line| {
|
||||
if line.starts_with("HTTP/") {
|
||||
line.split_whitespace().nth(1)?.parse::<u16>().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next_back()
|
||||
}
|
||||
|
||||
fn parse_body(raw: &str) -> String {
|
||||
if let Some(pos) = raw.rfind("\r\n\r\n") {
|
||||
return raw[pos + 4..].to_string();
|
||||
}
|
||||
if let Some(pos) = raw.rfind("\n\n") {
|
||||
return raw[pos + 2..].to_string();
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn parse_headers(raw: &str) -> String {
|
||||
let start = raw.rfind("HTTP/").unwrap_or(0);
|
||||
let tail = &raw[start..];
|
||||
if let Some(pos) = tail.find("\r\n\r\n") {
|
||||
return tail[..pos].to_string();
|
||||
}
|
||||
if let Some(pos) = tail.find("\n\n") {
|
||||
return tail[..pos].to_string();
|
||||
}
|
||||
tail.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_dummy_bucket_compatibility_endpoints() {
|
||||
@@ -470,10 +396,6 @@ mod tests {
|
||||
async fn test_dummy_bucket_endpoints_http_contracts() {
|
||||
init_logging();
|
||||
info!("Starting test: dummy-compat bucket API HTTP contracts");
|
||||
if !awscurl_available() {
|
||||
info!("Skipping test_dummy_bucket_endpoints_http_contracts: awscurl binary not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
@@ -488,56 +410,112 @@ mod tests {
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
|
||||
let logging_raw = execute_s3_awscurl("GET", &format!("{}/{bucket}?logging=", env.url), &env.access_key, &env.secret_key)
|
||||
.expect("GetBucketLogging HTTP request failed");
|
||||
assert_eq!(parse_status(&logging_raw), Some(200), "GetBucketLogging should return 200");
|
||||
let logging_body = parse_body(&logging_raw);
|
||||
let logging_response = signed_s3_request(
|
||||
Method::GET,
|
||||
&format!("{}/{bucket}?logging=", env.url),
|
||||
None,
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await
|
||||
.expect("GetBucketLogging HTTP request failed");
|
||||
assert_eq!(logging_response.status(), 200, "GetBucketLogging should return 200");
|
||||
let logging_body = logging_response
|
||||
.text()
|
||||
.await
|
||||
.expect("Failed to read GetBucketLogging response body");
|
||||
assert!(
|
||||
logging_body.contains("<BucketLoggingStatus"),
|
||||
"GetBucketLogging response should contain BucketLoggingStatus XML, got: {logging_body}"
|
||||
);
|
||||
|
||||
let accel_raw = execute_s3_awscurl("GET", &format!("{}/{bucket}?accelerate=", env.url), &env.access_key, &env.secret_key)
|
||||
.expect("GetBucketAccelerateConfiguration HTTP request failed");
|
||||
assert_eq!(parse_status(&accel_raw), Some(200), "GetBucketAccelerateConfiguration should return 200");
|
||||
let accel_body = parse_body(&accel_raw);
|
||||
let accel_response = signed_s3_request(
|
||||
Method::GET,
|
||||
&format!("{}/{bucket}?accelerate=", env.url),
|
||||
None,
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await
|
||||
.expect("GetBucketAccelerateConfiguration HTTP request failed");
|
||||
assert_eq!(accel_response.status(), 200, "GetBucketAccelerateConfiguration should return 200");
|
||||
let accel_body = accel_response
|
||||
.text()
|
||||
.await
|
||||
.expect("Failed to read GetBucketAccelerateConfiguration response body");
|
||||
assert!(
|
||||
accel_body.contains("<AccelerateConfiguration"),
|
||||
"GetBucketAccelerateConfiguration response should contain AccelerateConfiguration XML, got: {accel_body}"
|
||||
);
|
||||
|
||||
let payment_raw =
|
||||
execute_s3_awscurl("GET", &format!("{}/{bucket}?requestPayment=", env.url), &env.access_key, &env.secret_key)
|
||||
.expect("GetBucketRequestPayment HTTP request failed");
|
||||
assert_eq!(parse_status(&payment_raw), Some(200), "GetBucketRequestPayment should return 200");
|
||||
let payment_body = parse_body(&payment_raw);
|
||||
let payment_response = signed_s3_request(
|
||||
Method::GET,
|
||||
&format!("{}/{bucket}?requestPayment=", env.url),
|
||||
None,
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await
|
||||
.expect("GetBucketRequestPayment HTTP request failed");
|
||||
assert_eq!(payment_response.status(), 200, "GetBucketRequestPayment should return 200");
|
||||
let payment_body = payment_response
|
||||
.text()
|
||||
.await
|
||||
.expect("Failed to read GetBucketRequestPayment response body");
|
||||
assert!(
|
||||
payment_body.contains("<Payer>BucketOwner</Payer>"),
|
||||
"GetBucketRequestPayment should return BucketOwner payer, got: {payment_body}"
|
||||
);
|
||||
|
||||
let website_raw = execute_s3_awscurl("GET", &format!("{}/{bucket}?website=", env.url), &env.access_key, &env.secret_key)
|
||||
.expect("GetBucketWebsite HTTP request failed");
|
||||
let website_response = signed_s3_request(
|
||||
Method::GET,
|
||||
&format!("{}/{bucket}?website=", env.url),
|
||||
None,
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await
|
||||
.expect("GetBucketWebsite HTTP request failed");
|
||||
assert_eq!(
|
||||
parse_status(&website_raw),
|
||||
Some(404),
|
||||
website_response.status(),
|
||||
404,
|
||||
"GetBucketWebsite should return 404 when website config is absent"
|
||||
);
|
||||
let website_content_type = parse_headers(&website_raw).to_ascii_lowercase();
|
||||
let website_content_type = website_response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.expect("GetBucketWebsite response should include Content-Type")
|
||||
.to_str()
|
||||
.expect("GetBucketWebsite Content-Type should be valid ASCII")
|
||||
.to_ascii_lowercase();
|
||||
assert!(
|
||||
website_content_type.contains("content-type:") && website_content_type.contains("xml"),
|
||||
website_content_type.contains("xml"),
|
||||
"GetBucketWebsite error response should be XML, got content-type: {website_content_type}"
|
||||
);
|
||||
let website_body = parse_body(&website_raw);
|
||||
let website_body = website_response
|
||||
.text()
|
||||
.await
|
||||
.expect("Failed to read GetBucketWebsite response body");
|
||||
assert!(
|
||||
website_body.contains("<Code>NoSuchWebsiteConfiguration</Code>"),
|
||||
"GetBucketWebsite should return NoSuchWebsiteConfiguration code, got: {website_body}"
|
||||
);
|
||||
|
||||
let delete_raw =
|
||||
execute_s3_awscurl("DELETE", &format!("{}/{bucket}?website=", env.url), &env.access_key, &env.secret_key)
|
||||
.expect("DeleteBucketWebsite HTTP request failed");
|
||||
assert_eq!(parse_status(&delete_raw), Some(204), "DeleteBucketWebsite should return 204");
|
||||
let delete_response = signed_s3_request(
|
||||
Method::DELETE,
|
||||
&format!("{}/{bucket}?website=", env.url),
|
||||
None,
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await
|
||||
.expect("DeleteBucketWebsite HTTP request failed");
|
||||
assert_eq!(delete_response.status(), 204, "DeleteBucketWebsite should return 204");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// 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.
|
||||
|
||||
//! Regression tests for bucket statistics and data usage accuracy.
|
||||
//!
|
||||
//! Covers the recurring pattern where bucket statistics (object count, size)
|
||||
//! show stale/incorrect values, remain at 0, or oscillate between complete,
|
||||
//! partial, and zero. This has regressed 10+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5615: bucket statistics remain unchanged after data expiration
|
||||
//! - rustfs#5008: Admin usage reports only one pool
|
||||
//! - rustfs#5116: Admin usage reports stale 0/0 for non-empty bucket after upgrade
|
||||
//! - rustfs#5055: console object count and size still loading
|
||||
//! - rustfs#5010: Storage usage info changed abnormally
|
||||
//! - rustfs#3662: Incorrect bucket, object count and size
|
||||
//! - rustfs#3898: DataUsageInfo undercounts versioned bucket versions
|
||||
//! - rustfs#1012: Object count in the console doesn't change
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, RustFSTestEnvironment, awscurl_get, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use rustfs_data_usage::DataUsageInfo;
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
async fn get_data_usage(env: &RustFSTestEnvironment) -> Result<DataUsageInfo, Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/datausageinfo", env.url);
|
||||
let resp = awscurl_get(&url, &env.access_key, &env.secret_key).await?;
|
||||
Ok(serde_json::from_str(&resp)?)
|
||||
}
|
||||
|
||||
/// RT-09: Verify bucket object count updates after PUT.
|
||||
///
|
||||
/// Regression pattern: bucket stats remain at 0 after objects are uploaded
|
||||
/// (rustfs#5055, rustfs#1012).
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Create a bucket
|
||||
/// 2. Upload 10 objects
|
||||
/// 3. Query admin data usage API
|
||||
/// 4. Verify object count > 0
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_object_count_updates_after_put() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-09: bucket object count updates after PUT");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], FAST_DATA_USAGE_SCANNER_ENV)
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt09-stats-put";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload 10 objects
|
||||
for i in 0..10 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("stat-obj-{i:04}.txt"))
|
||||
.body(ByteStream::from_static(b"statistical data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// Wait for scanner to process (up to 90 seconds)
|
||||
let mut found_nonzero = false;
|
||||
let mut last_query_error = None;
|
||||
for attempt in 0..18 {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let usage = match get_data_usage(&env).await {
|
||||
Ok(usage) => {
|
||||
last_query_error = None;
|
||||
usage
|
||||
}
|
||||
Err(err) => {
|
||||
last_query_error = Some(err.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(bucket_usage) = usage.buckets_usage.get(bucket) {
|
||||
info!(" attempt {attempt}: objectsCount = {}", bucket_usage.objects_count);
|
||||
if bucket_usage.objects_count >= 10 {
|
||||
found_nonzero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
found_nonzero,
|
||||
"RT-09 FAIL: bucket object count did not update after PUT 10 objects (regression: stats stuck at 0); last query error: {}",
|
||||
last_query_error.as_deref().unwrap_or("none")
|
||||
);
|
||||
|
||||
info!("RT-09 PASS: bucket object count updates after PUT");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-09b: Verify bucket stats update after DELETE.
|
||||
///
|
||||
/// Regression pattern: stats remain unchanged after objects are deleted
|
||||
/// (rustfs#5615).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_object_count_updates_after_delete() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-09b: bucket object count updates after DELETE");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], FAST_DATA_USAGE_SCANNER_ENV)
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt09b-stats-delete";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload 5 objects
|
||||
for i in 0..5 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("del-stat-{i}.txt"))
|
||||
.body(ByteStream::from_static(b"data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
let mut found_nonzero = false;
|
||||
for attempt in 0..18 {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
if let Ok(usage) = get_data_usage(&env).await
|
||||
&& let Some(bucket_usage) = usage.buckets_usage.get(bucket)
|
||||
{
|
||||
info!(" baseline attempt {attempt}: objectsCount = {}", bucket_usage.objects_count);
|
||||
if bucket_usage.objects_count >= 5 {
|
||||
found_nonzero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(found_nonzero, "RT-09b setup failed: scanner did not observe the 5 uploaded objects");
|
||||
|
||||
// Delete all objects
|
||||
for i in 0..5 {
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("del-stat-{i}.txt"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete object");
|
||||
}
|
||||
|
||||
// Wait for scanner to update stats (up to 90 seconds)
|
||||
let mut found_zero = false;
|
||||
let mut last_query_error = None;
|
||||
for attempt in 0..18 {
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let usage = match get_data_usage(&env).await {
|
||||
Ok(usage) => {
|
||||
last_query_error = None;
|
||||
usage
|
||||
}
|
||||
Err(err) => {
|
||||
last_query_error = Some(err.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(bucket_usage) = usage.buckets_usage.get(bucket) {
|
||||
info!(" attempt {attempt}: objectsCount = {}", bucket_usage.objects_count);
|
||||
if bucket_usage.objects_count == 0 {
|
||||
found_zero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
found_zero,
|
||||
"RT-09b FAIL: bucket object count did not update to 0 after deleting all objects (regression rustfs#5615); last query error: {}",
|
||||
last_query_error.as_deref().unwrap_or("none")
|
||||
);
|
||||
|
||||
info!("RT-09b PASS: bucket object count updates to 0 after DELETE");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-09c: Verify versioned bucket stats count all versions.
|
||||
///
|
||||
/// Regression pattern: DataUsageInfo undercounts versioned bucket versions
|
||||
/// and delete markers (rustfs#3898).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioned_bucket_stats_count_all_versions() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-09c: versioned bucket stats count all versions");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt09c-versioned-stats";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("enable versioning");
|
||||
|
||||
// Create 3 versions of the same object
|
||||
for i in 0..3 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("multi-version.txt")
|
||||
.body(ByteStream::from(format!("version-{i}").into_bytes()))
|
||||
.send()
|
||||
.await
|
||||
.expect("put version");
|
||||
}
|
||||
|
||||
// Create a delete marker
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("multi-version.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("create delete marker");
|
||||
|
||||
// Verify versions via API (immediate, no scanner wait)
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
assert_eq!(
|
||||
versions.versions().len(),
|
||||
3,
|
||||
"RT-09c FAIL: expected 3 versions, found {}",
|
||||
versions.versions().len()
|
||||
);
|
||||
assert_eq!(
|
||||
versions.delete_markers().len(),
|
||||
1,
|
||||
"RT-09c FAIL: expected 1 delete marker, found {}",
|
||||
versions.delete_markers().len()
|
||||
);
|
||||
|
||||
info!("RT-09c PASS: versioned bucket correctly tracks all versions and delete markers");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ use walkdir::WalkDir;
|
||||
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||
pub const DEFAULT_SECRET_KEY: &str = "rustfsadmin";
|
||||
pub const ENV_RUSTFS_BUILD_FEATURES: &str = "RUSTFS_BUILD_FEATURES";
|
||||
pub(crate) const FAST_DATA_USAGE_SCANNER_ENV: &[(&str, &str)] =
|
||||
&[("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")];
|
||||
pub const TEST_BUCKET: &str = "e2e-test-bucket";
|
||||
const RUSTFS_FULL_FEATURE: &str = "full";
|
||||
|
||||
@@ -65,8 +67,14 @@ fn configured_capture_log_path(temp_dir: &str) -> Option<String> {
|
||||
capture_log_path(Path::new(&log_dir), temp_dir).map(|path| path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
fn build_test_s3_config(endpoint_url: &str, access_key: &str, secret_key: &str, provider_name: &'static str) -> Config {
|
||||
let credentials = Credentials::new(access_key, secret_key, None, None, provider_name);
|
||||
pub(crate) fn build_test_s3_config(
|
||||
endpoint_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
session_token: Option<&str>,
|
||||
provider_name: &'static str,
|
||||
) -> Config {
|
||||
let credentials = Credentials::new(access_key, secret_key, session_token.map(str::to_owned), None, provider_name);
|
||||
let mut config = Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(Region::new("us-east-1"))
|
||||
@@ -81,6 +89,33 @@ fn build_test_s3_config(endpoint_url: &str, access_key: &str, secret_key: &str,
|
||||
config.build()
|
||||
}
|
||||
|
||||
pub(crate) fn build_test_sts_client(
|
||||
endpoint_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
session_token: Option<&str>,
|
||||
provider_name: &'static str,
|
||||
) -> aws_sdk_sts::Client {
|
||||
let mut config = aws_sdk_sts::Config::builder()
|
||||
.credentials_provider(aws_sdk_sts::config::Credentials::new(
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token.map(str::to_owned),
|
||||
None,
|
||||
provider_name,
|
||||
))
|
||||
.region(aws_sdk_sts::config::Region::new("us-east-1"))
|
||||
.endpoint_url(endpoint_url)
|
||||
.retry_config(aws_sdk_sts::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||
.behavior_version_latest();
|
||||
|
||||
if endpoint_url.starts_with("http://") {
|
||||
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
|
||||
}
|
||||
|
||||
aws_sdk_sts::Client::from_conf(config.build())
|
||||
}
|
||||
|
||||
pub fn workspace_root() -> PathBuf {
|
||||
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
path.pop(); // e2e_test
|
||||
@@ -95,6 +130,38 @@ pub fn local_http_client() -> HttpClient {
|
||||
.expect("failed to build local reqwest client")
|
||||
}
|
||||
|
||||
pub(crate) async fn signed_s3_request(
|
||||
method: http::Method,
|
||||
url: &str,
|
||||
body: Option<String>,
|
||||
content_type: Option<&str>,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let uri = url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("S3 URL missing authority")?.to_string();
|
||||
let mut request = http::Request::builder()
|
||||
.method(method.clone())
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||
if let Some(content_type) = content_type {
|
||||
request = request.header(CONTENT_TYPE, content_type);
|
||||
}
|
||||
|
||||
let content_length = i64::try_from(body.as_ref().map_or(0, String::len)).map_err(|_| "S3 request body is too large")?;
|
||||
let signed = sign_v4(request.body(Body::empty())?, content_length, access_key, secret_key, "", "us-east-1");
|
||||
|
||||
let mut request = local_http_client().request(method, url);
|
||||
for (name, value) in signed.headers() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
if let Some(body) = body {
|
||||
request = request.body(body);
|
||||
}
|
||||
Ok(request.send().await?)
|
||||
}
|
||||
|
||||
/// Signs and sends an admin HTTP request with the given credentials.
|
||||
pub(crate) async fn admin_request(
|
||||
base_url: &str,
|
||||
@@ -105,28 +172,8 @@ pub(crate) async fn admin_request(
|
||||
secret_key: &str,
|
||||
) -> Result<(StatusCode, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{base_url}{path_and_query}");
|
||||
let uri = url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
||||
let mut request = http::Request::builder()
|
||||
.method(method.clone())
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||
if body.is_some() {
|
||||
request = request.header(CONTENT_TYPE, "application/json");
|
||||
}
|
||||
|
||||
let content_length = i64::try_from(body.as_ref().map_or(0, String::len)).map_err(|_| "admin request body is too large")?;
|
||||
let signed = sign_v4(request.body(Body::empty())?, content_length, access_key, secret_key, "", "us-east-1");
|
||||
|
||||
let mut request = local_http_client().request(method, &url);
|
||||
for (name, value) in signed.headers() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
if let Some(body) = body {
|
||||
request = request.body(body);
|
||||
}
|
||||
let response = request.send().await?;
|
||||
let content_type = body.as_ref().map(|_| "application/json");
|
||||
let response = signed_s3_request(method, &url, body, content_type, access_key, secret_key).await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
Ok((status, body))
|
||||
@@ -564,7 +611,12 @@ impl RustFSTestEnvironment {
|
||||
|
||||
/// Create an AWS S3 client configured for this RustFS instance
|
||||
pub fn create_s3_client(&self) -> Client {
|
||||
Client::from_conf(build_test_s3_config(&self.url, &self.access_key, &self.secret_key, "e2e-test"))
|
||||
self.create_s3_client_with_credentials(&self.access_key, &self.secret_key)
|
||||
}
|
||||
|
||||
/// Create an AWS S3 client with explicit credentials for this RustFS instance.
|
||||
pub fn create_s3_client_with_credentials(&self, access_key: &str, secret_key: &str) -> Client {
|
||||
Client::from_conf(build_test_s3_config(&self.url, access_key, secret_key, None, "e2e-test"))
|
||||
}
|
||||
|
||||
/// Create test bucket
|
||||
@@ -1296,6 +1348,7 @@ impl RustFSTestClusterEnvironment {
|
||||
&self.nodes[node_idx].url,
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
None,
|
||||
"cluster-test",
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use rustfs_data_usage::DataUsageInfo;
|
||||
use serial_test::serial;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, TEST_BUCKET, awscurl_get, init_logging};
|
||||
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, RustFSTestEnvironment, TEST_BUCKET, awscurl_get, init_logging};
|
||||
|
||||
async fn get_data_usage_info(env: &RustFSTestEnvironment) -> Result<DataUsageInfo, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/datausageinfo", env.url);
|
||||
@@ -35,16 +35,26 @@ where
|
||||
F: FnMut(&DataUsageInfo) -> bool,
|
||||
{
|
||||
let mut last_usage = DataUsageInfo::default();
|
||||
let mut last_query_error = None;
|
||||
for _ in 0..45 {
|
||||
let usage = get_data_usage_info(env).await?;
|
||||
if usage.buckets_usage.contains_key(bucket) && predicate(&usage) {
|
||||
return Ok(usage);
|
||||
match get_data_usage_info(env).await {
|
||||
Ok(usage) => {
|
||||
last_query_error = None;
|
||||
if usage.buckets_usage.contains_key(bucket) && predicate(&usage) {
|
||||
return Ok(usage);
|
||||
}
|
||||
last_usage = usage;
|
||||
}
|
||||
Err(err) => last_query_error = Some(err.to_string()),
|
||||
}
|
||||
last_usage = usage;
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
Err(format!("bucket usage did not converge for {bucket}; last usage: {last_usage:?}").into())
|
||||
Err(format!(
|
||||
"bucket usage did not converge for {bucket}; last usage: {last_usage:?}; last query error: {}",
|
||||
last_query_error.as_deref().unwrap_or("none")
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
/// Regression test for data usage accuracy (issue #1012).
|
||||
@@ -56,7 +66,7 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server_with_env(vec![], FAST_DATA_USAGE_SCANNER_ENV).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
|
||||
@@ -74,8 +84,14 @@ async fn data_usage_reports_all_objects() -> Result<(), Box<dyn std::error::Erro
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Query admin data usage API
|
||||
let usage = get_data_usage_info(&env).await?;
|
||||
let usage = wait_for_bucket_usage(&env, TEST_BUCKET, |usage| {
|
||||
usage
|
||||
.buckets_usage
|
||||
.get(TEST_BUCKET)
|
||||
.map(|bucket_usage| usage.objects_total_count >= 1000 && bucket_usage.objects_count >= 1000)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Assert total object count and per-bucket count are not truncated
|
||||
let bucket_usage = usage
|
||||
@@ -108,7 +124,7 @@ async fn data_usage_reports_versioned_objects_and_delete_markers() -> Result<(),
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server_with_env(vec![], FAST_DATA_USAGE_SCANNER_ENV).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "data-usage-versioned";
|
||||
@@ -184,8 +200,8 @@ async fn data_usage_reports_versioned_objects_and_delete_markers() -> Result<(),
|
||||
assert_eq!(usage.versions_total_count, 3, "total version count should match bucket usage");
|
||||
assert_eq!(usage.delete_markers_total_count, 1, "total delete marker count should match bucket usage");
|
||||
|
||||
env.stop_server();
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.restart_server_preserving_data(vec![], FAST_DATA_USAGE_SCANNER_ENV)
|
||||
.await?;
|
||||
|
||||
let restarted_usage = wait_for_bucket_usage(&env, bucket, |usage| {
|
||||
usage
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
// 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.
|
||||
|
||||
//! Regression tests for object delete operations.
|
||||
//!
|
||||
//! Covers the recurring pattern where DELETE succeeds at the API level but the
|
||||
//! object remains visible in LIST, or deleted objects reappear after restart,
|
||||
//! or versioned delete operations fail with FileAccessDenied.
|
||||
//! This has regressed 15+ times across the entire release history.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5375: delete object in a bucket list api also exist this object
|
||||
//! - rustfs#5349: The deleted bucket was rebuilt after some time
|
||||
//! - rustfs#5339: data not delete in Object Lock bucket
|
||||
//! - rustfs#5029: Node Does Not Remove Files After Reconnect to Cluster
|
||||
//! - rustfs#4978: DELETE fails with InternalError/FileAccessDenied on beta 10
|
||||
//! - rustfs#760: Cannot delete a versioned bucket
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-05: Verify DELETE → LIST → HEAD consistency.
|
||||
///
|
||||
/// Regression pattern: DELETE returns 200 but the object remains in LIST.
|
||||
/// Covers rustfs#5375.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Create a bucket and upload an object
|
||||
/// 2. Verify the object is in LIST
|
||||
/// 3. DELETE the object
|
||||
/// 4. Verify the object is NOT in LIST
|
||||
/// 5. Verify HEAD returns 404
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_removes_object_from_list() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05: delete removes object from list");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05-delete-consistency";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload an object
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("to-delete.txt")
|
||||
.body(ByteStream::from_static(b"will be deleted"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
|
||||
// Verify it appears in LIST
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list objects before delete");
|
||||
|
||||
assert!(
|
||||
list.contents()
|
||||
.iter()
|
||||
.map(|o| o.key().unwrap_or(""))
|
||||
.any(|key| key == "to-delete.txt"),
|
||||
"RT-05 FAIL: object not in LIST before delete"
|
||||
);
|
||||
|
||||
// DELETE
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("to-delete.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("delete object");
|
||||
|
||||
// Verify NOT in LIST
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list objects after delete");
|
||||
|
||||
assert!(
|
||||
!list
|
||||
.contents()
|
||||
.iter()
|
||||
.map(|o| o.key().unwrap_or(""))
|
||||
.any(|key| key == "to-delete.txt"),
|
||||
"RT-05 FAIL: deleted object still in LIST (regression rustfs#5375)"
|
||||
);
|
||||
|
||||
// Verify HEAD returns 404
|
||||
let head = client.head_object().bucket(bucket).key("to-delete.txt").send().await;
|
||||
|
||||
assert!(head.is_err(), "RT-05 FAIL: HEAD on deleted object should return error, got success");
|
||||
|
||||
info!("RT-05 PASS: delete correctly removes object from LIST and HEAD");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05c: Verify batch delete (DeleteObjects) consistency.
|
||||
///
|
||||
/// Regression pattern: batch delete returns success but some objects
|
||||
/// remain in LIST.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_batch_delete_removes_all_objects() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05c: batch delete removes all objects");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05c-batch-delete";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload multiple objects
|
||||
let keys: Vec<String> = (0..5).map(|i| format!("batch-{i:04}.txt")).collect();
|
||||
for key in &keys {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"batch-delete-me"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// Verify all in LIST
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list before batch delete");
|
||||
|
||||
assert_eq!(
|
||||
list.contents().len(),
|
||||
5,
|
||||
"RT-05c FAIL: expected 5 objects before batch delete, found {}",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
// Batch delete
|
||||
let objects: Vec<ObjectIdentifier> = keys
|
||||
.iter()
|
||||
.map(|k| ObjectIdentifier::builder().key(k).build().expect("build object id"))
|
||||
.collect();
|
||||
|
||||
client
|
||||
.delete_objects()
|
||||
.bucket(bucket)
|
||||
.delete(Delete::builder().set_objects(Some(objects)).build().expect("build delete"))
|
||||
.send()
|
||||
.await
|
||||
.expect("batch delete");
|
||||
|
||||
// Verify all removed
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list after batch delete");
|
||||
|
||||
assert!(
|
||||
list.contents().is_empty(),
|
||||
"RT-05c FAIL: {} objects remain after batch delete (regression: delete objects not fully applied)",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-05c PASS: batch delete removes all objects");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05d: Verify versioned delete → permanent delete → object gone.
|
||||
///
|
||||
/// Covers the pattern where permanent deletion of a specific version
|
||||
/// fails with FileAccessDenied (rustfs#4978).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioned_permanent_delete() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05d: versioned permanent delete");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05d-permanent-delete";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("enable versioning");
|
||||
|
||||
// Upload a single object (single version)
|
||||
let put_resp = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("single-version.txt")
|
||||
.body(ByteStream::from_static(b"to-be-permanently-deleted"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
|
||||
let version_id = put_resp.version_id().expect("version ID should be present").to_string();
|
||||
|
||||
// Permanently delete the specific version (rustfs#4978: FileAccessDenied)
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("single-version.txt")
|
||||
.version_id(&version_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("permanent delete should succeed (regression rustfs#4978)");
|
||||
|
||||
// Verify the object is completely gone
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
assert!(
|
||||
versions.versions().is_empty(),
|
||||
"RT-05d FAIL: version still present after permanent delete"
|
||||
);
|
||||
|
||||
info!("RT-05d PASS: versioned permanent delete succeeds");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05e: Verify delete marker + version history interaction.
|
||||
///
|
||||
/// Covers the pattern where creating a delete marker and then listing
|
||||
/// versions shows incorrect state (rustfs#760).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioned_delete_marker_and_list_consistency() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05e: versioned delete marker and list consistency");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05e-dm-consistency";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("enable versioning");
|
||||
|
||||
// Create 3 versions
|
||||
for i in 0..3 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("history.txt")
|
||||
.body(ByteStream::from(format!("v{i}").into_bytes()))
|
||||
.send()
|
||||
.await
|
||||
.expect("put version");
|
||||
}
|
||||
|
||||
// Create a delete marker
|
||||
let del = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("history.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("delete (create marker)");
|
||||
|
||||
assert!(del.delete_marker().unwrap_or(false), "RT-05e FAIL: should have created a delete marker");
|
||||
|
||||
// ListObjectVersions should show 3 versions + 1 delete marker
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
assert_eq!(
|
||||
versions.versions().len(),
|
||||
3,
|
||||
"RT-05e FAIL: expected 3 versions, found {}",
|
||||
versions.versions().len()
|
||||
);
|
||||
assert_eq!(
|
||||
versions.delete_markers().len(),
|
||||
1,
|
||||
"RT-05e FAIL: expected 1 delete marker, found {}",
|
||||
versions.delete_markers().len()
|
||||
);
|
||||
|
||||
// Now delete the delete marker (restore the object)
|
||||
let dm_version = &versions.delete_markers()[0];
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("history.txt")
|
||||
.version_id(dm_version.version_id().expect("dm version id"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete delete-marker");
|
||||
|
||||
// HEAD should succeed now (latest version is accessible)
|
||||
let head = client.head_object().bucket(bucket).key("history.txt").send().await;
|
||||
|
||||
assert!(head.is_ok(), "RT-05e FAIL: HEAD should succeed after removing delete marker");
|
||||
|
||||
info!("RT-05e PASS: versioned delete marker and list consistency");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05f: Verify object deletion does not leave orphan data on disk.
|
||||
///
|
||||
/// Regression pattern: after delete, the object data files remain on disk
|
||||
/// (rustfs#5029: Node Does Not Remove Files After Reconnect).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_removes_object_head_returns_404() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05f: delete → HEAD 404 consistency");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05f-delete-head";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload, delete, verify HEAD returns 404
|
||||
let keys = vec!["small.txt", "medium.txt", "with-slash.txt", "special+chars.txt"];
|
||||
|
||||
for key in &keys {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.body(ByteStream::from_static(b"delete-me"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
for key in &keys {
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.send()
|
||||
.await
|
||||
.expect("delete object");
|
||||
}
|
||||
|
||||
// All HEAD requests should return 404
|
||||
for key in &keys {
|
||||
let head = client.head_object().bucket(bucket).key(*key).send().await;
|
||||
|
||||
assert!(head.is_err(), "RT-05f FAIL: HEAD on deleted key '{key}' should return error");
|
||||
}
|
||||
|
||||
// LIST should be empty
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list after all deletes");
|
||||
|
||||
assert!(
|
||||
list.contents().is_empty(),
|
||||
"RT-05f FAIL: {} objects remain after deleting all",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-05f PASS: all deleted objects return 404 on HEAD");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// 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.
|
||||
|
||||
//! Regression tests for distributed cluster startup and quorum.
|
||||
//!
|
||||
//! Covers the recurring pattern where multi-node clusters fail to start due to
|
||||
//! lock quorum issues, DNS resolution delays, or erasure quorum deadlocks.
|
||||
//! This has regressed 7+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5416: RustFS cannot cold-start with 2/3 quorum when Pod DNS missing
|
||||
//! - rustfs#2945: Distributed mode fails on K8s: erasure quorum deadlock
|
||||
//! - rustfs#2794: distributed deployment does not become ready
|
||||
//! - rustfs#2601: fresh pod immediately enters FaultyDisk state
|
||||
//! - rustfs#4040: Distributed startup can fail lock quorum before AppContext initializes
|
||||
//! - rustfs#5655: fix(ecstore): bootstrap fresh four-node clusters reliably
|
||||
//! - rustfs#4954: S3/health endpoint unavailability after multi-pool scale-up
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestClusterEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-10: Verify 4-node cluster starts successfully and all nodes are ready.
|
||||
///
|
||||
/// Regression pattern: distributed startup fails with quorum deadlock or
|
||||
/// lock acquisition timeout (rustfs#2945, rustfs#5655).
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Create a 4-node cluster
|
||||
/// 2. Start all nodes simultaneously
|
||||
/// 3. Verify all nodes report healthy
|
||||
/// 4. Verify S3 operations work through any node
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_four_node_cluster_startup_and_health() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-10: 4-node cluster startup and health");
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await.expect("create 4-node cluster");
|
||||
|
||||
cluster.start().await.expect("start 4-node cluster");
|
||||
|
||||
// Create a bucket and verify it's accessible from all nodes
|
||||
cluster
|
||||
.create_test_bucket("rt10-startup")
|
||||
.await
|
||||
.expect("create bucket on cluster");
|
||||
|
||||
let clients = cluster.create_all_clients().expect("create per-node clients");
|
||||
|
||||
// Verify S3 operations work from every node
|
||||
for (i, client) in clients.iter().enumerate() {
|
||||
client
|
||||
.put_object()
|
||||
.bucket("rt10-startup")
|
||||
.key(format!("from-node-{i}.txt"))
|
||||
.body(ByteStream::from_static(b"hello from node"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("PUT from node {i} failed: {e}"));
|
||||
}
|
||||
|
||||
// Verify all objects are visible from node 0
|
||||
let list = clients[0]
|
||||
.list_objects_v2()
|
||||
.bucket("rt10-startup")
|
||||
.send()
|
||||
.await
|
||||
.expect("list objects from node 0");
|
||||
|
||||
assert_eq!(
|
||||
list.contents().len(),
|
||||
4,
|
||||
"RT-10 FAIL: expected 4 objects (one per node), found {}",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-10 PASS: 4-node cluster starts and serves S3 from all nodes");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-10b: Verify cluster handles node restart gracefully.
|
||||
///
|
||||
/// Regression pattern: after a node restart, it cannot rejoin the cluster
|
||||
/// or enters a faulty state (rustfs#2601).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_cluster_survives_node_restart() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-10b: cluster survives node restart");
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await.expect("create 4-node cluster");
|
||||
|
||||
cluster.start().await.expect("start cluster");
|
||||
|
||||
cluster.create_test_bucket("rt10b-restart").await.expect("create bucket");
|
||||
|
||||
// Write data
|
||||
let clients = cluster.create_all_clients()?;
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket("rt10b-restart")
|
||||
.key("before-restart.txt")
|
||||
.body(ByteStream::from_static(b"persistent data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object before restart");
|
||||
|
||||
// Stop node 3
|
||||
cluster.stop_node(3).expect("stop node 3");
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Verify cluster still works with 3/4 nodes (quorum)
|
||||
clients[0]
|
||||
.put_object()
|
||||
.bucket("rt10b-restart")
|
||||
.key("during-offline.txt")
|
||||
.body(ByteStream::from_static(b"written while node 3 down"))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT should succeed with 3/4 nodes");
|
||||
|
||||
// Restart node 3
|
||||
cluster.start_node(3).await.expect("restart node 3");
|
||||
|
||||
// Wait for node to rejoin
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// Verify the restarted node can serve reads
|
||||
let list = clients[3]
|
||||
.list_objects_v2()
|
||||
.bucket("rt10b-restart")
|
||||
.send()
|
||||
.await
|
||||
.expect("list from restarted node");
|
||||
|
||||
assert!(
|
||||
list.contents().len() >= 2,
|
||||
"RT-10b FAIL: restarted node sees {} objects, expected >= 2",
|
||||
list.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-10b PASS: cluster survives and recovers from node restart");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-10c: Verify bucket creation persists across all nodes.
|
||||
///
|
||||
/// Regression pattern: bucket metadata is not replicated to all nodes,
|
||||
/// causing NoSuchBucket errors on some nodes (rustfs#3191).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_visible_from_all_nodes() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-10c: bucket visible from all nodes");
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await.expect("create 4-node cluster");
|
||||
|
||||
cluster.start().await.expect("start cluster");
|
||||
|
||||
cluster
|
||||
.create_test_bucket("rt10c-bucket-visibility")
|
||||
.await
|
||||
.expect("create bucket");
|
||||
|
||||
let clients = cluster.create_all_clients()?;
|
||||
|
||||
// Verify the bucket is visible from every node
|
||||
for (i, client) in clients.iter().enumerate() {
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket("rt10c-bucket-visibility")
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("list from node {i} failed (NoSuchBucket?): {e}"));
|
||||
|
||||
assert!(resp.contents().is_empty(), "RT-10c: fresh bucket should be empty on node {i}");
|
||||
}
|
||||
|
||||
info!("RT-10c PASS: bucket visible from all 4 nodes");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -574,7 +574,8 @@ fn parse_request(method: &Method, uri: &Uri) -> ParsedRequest {
|
||||
(&Method::POST, true) if query.contains_key("uploads") => Operation::CreateMultipartUpload,
|
||||
(&Method::POST, true) if upload_id.is_some() => Operation::CompleteMultipartUpload,
|
||||
(&Method::DELETE, true) if upload_id.is_some() => Operation::AbortMultipartUpload,
|
||||
(&Method::PUT, true) if only_query_keys(&[]) => Operation::PutObject,
|
||||
// A replication PUT addresses the source version via `?versionId=`.
|
||||
(&Method::PUT, true) if only_query_keys(&["versionId"]) => Operation::PutObject,
|
||||
(&Method::GET, true) if only_query_keys(&["versionId"]) => Operation::GetObject,
|
||||
(&Method::HEAD, true) if only_query_keys(&["versionId"]) => Operation::HeadObject,
|
||||
(&Method::DELETE, true) if only_query_keys(&["versionId"]) => Operation::DeleteObject,
|
||||
|
||||
@@ -1687,6 +1687,44 @@ async fn four_node_inline_storage_and_get_boundaries() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn four_node_empty_legacy_volumes_start_as_fresh() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
for data_dir in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||
tokio::fs::create_dir_all(Path::new(data_dir).join(".minio.sys")).await?;
|
||||
}
|
||||
|
||||
cluster.start().await?;
|
||||
|
||||
// Starting is not the assertion. The regression is that an empty legacy
|
||||
// `.minio.sys` must be classified as a *fresh* volume, not as an existing
|
||||
// MinIO deployment to adopt or migrate. Pin what that classification leaves
|
||||
// on disk and in the namespace.
|
||||
let buckets = cluster.create_s3_client(0)?.list_buckets().send().await?;
|
||||
assert!(
|
||||
buckets.buckets().is_empty(),
|
||||
"a fresh classification must not adopt buckets from the pre-existing directories, got {:?}",
|
||||
buckets.buckets().iter().filter_map(|b| b.name()).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
for data_dir in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||
assert!(
|
||||
Path::new(data_dir).join(".rustfs.sys").join("format.json").is_file(),
|
||||
"each drive must be formatted as fresh: {data_dir} has no .rustfs.sys/format.json"
|
||||
);
|
||||
let mut legacy = tokio::fs::read_dir(Path::new(data_dir).join(".minio.sys")).await?;
|
||||
assert!(
|
||||
legacy.next_entry().await?.is_none(),
|
||||
"the empty legacy directory must be left untouched, not migrated into: {data_dir}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn four_node_inline_fallback_controls() -> TestResult {
|
||||
@@ -2173,11 +2211,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
||||
"queue_snapshot.{field} must be readable in terminal status: {terminal}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
cold_tier_object_count(&cold_client).await? < 64,
|
||||
"queue pressure should leave at least one object untransitioned"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,12 @@
|
||||
|
||||
use super::common::LocalKMSTestEnvironment;
|
||||
use crate::common::{TEST_BUCKET, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
||||
ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ServerSideEncryption,
|
||||
ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
||||
};
|
||||
use rustfs_rio::{Checksum, ChecksumType};
|
||||
use serial_test::serial;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -273,7 +276,7 @@ async fn test_bucket_default_sse_kms_put_object() -> Result<(), Box<dyn std::err
|
||||
/// Test 3: When bucket is configured with default encryption, create_multipart_upload should inherit the configuration
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_default_encryption_multipart_upload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
async fn test_bucket_default_sse_kms_multipart_crc32() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Testing bucket default encryption impact on create_multipart_upload");
|
||||
|
||||
@@ -309,15 +312,16 @@ async fn test_bucket_default_encryption_multipart_upload() -> Result<(), Box<dyn
|
||||
.await
|
||||
.expect("Failed to set bucket encryption");
|
||||
|
||||
// Step 2: Create multipart upload (without specifying encryption parameters)
|
||||
info!("Creating multipart upload (without specifying encryption parameters, should use bucket default configuration)");
|
||||
let test_key = "test-multipart-bucket-default.txt";
|
||||
// Step 2: Declare CRC32 without specifying encryption parameters. The AWS SDK
|
||||
// calculates each UploadPart checksum and sends it as a flexible checksum.
|
||||
info!("Creating CRC32 multipart upload that should use bucket default encryption");
|
||||
let test_key = "test-multipart-bucket-default-crc32.bin";
|
||||
|
||||
let create_multipart_response = s3_client
|
||||
.create_multipart_upload()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
// Note: No encryption parameters specified here, should use bucket default configuration
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
@@ -343,28 +347,61 @@ async fn test_bucket_default_encryption_multipart_upload() -> Result<(), Box<dyn
|
||||
"create_multipart_upload response should contain correct KMS key ID"
|
||||
);
|
||||
|
||||
// Step 3: Upload a part and complete multipart upload
|
||||
info!("Uploading part and completing multipart upload");
|
||||
let test_data = b"test-multipart-bucket-default-encryption-data";
|
||||
// Step 3: Upload two parts. The first is exactly the S3 minimum size so this
|
||||
// follows the same managed SSE-KMS multipart path as issue #5756.
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 251) as u8).collect();
|
||||
let part2: Vec<u8> = (0..1024 * 1024).map(|i| ((i + 17) % 251) as u8).collect();
|
||||
let expected_body: Vec<u8> = part1.iter().chain(&part2).copied().collect();
|
||||
|
||||
// Upload part 1
|
||||
let upload_part_response = s3_client
|
||||
.upload_part()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(test_data.to_vec().into())
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to upload part");
|
||||
let upload_part = |part_number: i32, body: Vec<u8>| {
|
||||
s3_client
|
||||
.upload_part()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(part_number)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.body(ByteStream::from(body))
|
||||
.send()
|
||||
};
|
||||
|
||||
let etag = upload_part_response.e_tag().unwrap().to_string();
|
||||
let expected_part1_crc32 = Checksum::new_from_data(ChecksumType::CRC32, &part1)
|
||||
.expect("calculate part 1 CRC32")
|
||||
.encoded;
|
||||
let upload1 = upload_part(1, part1).await.expect("Failed to upload part 1 with CRC32");
|
||||
assert_eq!(
|
||||
upload1.checksum_crc32(),
|
||||
Some(expected_part1_crc32.as_str()),
|
||||
"UploadPart must return the CRC32 calculated over plaintext"
|
||||
);
|
||||
|
||||
let expected_part2_crc32 = Checksum::new_from_data(ChecksumType::CRC32, &part2)
|
||||
.expect("calculate part 2 CRC32")
|
||||
.encoded;
|
||||
let upload2 = upload_part(2, part2).await.expect("Failed to upload part 2 with CRC32");
|
||||
assert_eq!(
|
||||
upload2.checksum_crc32(),
|
||||
Some(expected_part2_crc32.as_str()),
|
||||
"UploadPart must return the CRC32 calculated over plaintext"
|
||||
);
|
||||
|
||||
// Complete multipart upload
|
||||
let completed_part = aws_sdk_s3::types::CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(&etag)
|
||||
let completed_upload = CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(upload1.e_tag().expect("No ETag for part 1"))
|
||||
.checksum_crc32(upload1.checksum_crc32().expect("No CRC32 for part 1"))
|
||||
.build(),
|
||||
)
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(2)
|
||||
.e_tag(upload2.e_tag().expect("No ETag for part 2"))
|
||||
.checksum_crc32(upload2.checksum_crc32().expect("No CRC32 for part 2"))
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
|
||||
let complete_multipart_response = s3_client
|
||||
@@ -372,11 +409,7 @@ async fn test_bucket_default_encryption_multipart_upload() -> Result<(), Box<dyn
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
aws_sdk_s3::types::CompletedMultipartUpload::builder()
|
||||
.parts(completed_part)
|
||||
.build(),
|
||||
)
|
||||
.multipart_upload(completed_upload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to complete multipart upload");
|
||||
@@ -400,6 +433,7 @@ async fn test_bucket_default_encryption_multipart_upload() -> Result<(), Box<dyn
|
||||
.get_object()
|
||||
.bucket(TEST_BUCKET)
|
||||
.key(test_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to get object");
|
||||
@@ -410,6 +444,13 @@ async fn test_bucket_default_encryption_multipart_upload() -> Result<(), Box<dyn
|
||||
Some(&ServerSideEncryption::AwsKms),
|
||||
"Final object should contain SSE-KMS encryption information"
|
||||
);
|
||||
if let Some(completed_crc32) = complete_multipart_response.checksum_crc32() {
|
||||
assert_eq!(
|
||||
get_response.checksum_crc32(),
|
||||
Some(completed_crc32),
|
||||
"GetObject should return the persisted composite CRC32 when completion reports it"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify data integrity
|
||||
let downloaded_data = get_response
|
||||
@@ -418,7 +459,11 @@ async fn test_bucket_default_encryption_multipart_upload() -> Result<(), Box<dyn
|
||||
.await
|
||||
.expect("Failed to collect body")
|
||||
.into_bytes();
|
||||
assert_eq!(&downloaded_data[..], test_data, "Downloaded data should match original data");
|
||||
assert_eq!(
|
||||
downloaded_data.as_ref(),
|
||||
expected_body.as_slice(),
|
||||
"Downloaded data should match the uploaded multipart body"
|
||||
);
|
||||
|
||||
// Cleanup is handled automatically when the test environment is dropped
|
||||
info!("Test passed: bucket default encryption correctly applied to multipart upload");
|
||||
|
||||
@@ -290,6 +290,14 @@ mod overwrite_cleanup_regression_test;
|
||||
#[cfg(test)]
|
||||
mod list_buckets_double_slash_test;
|
||||
|
||||
// Regression coverage for bucket-scoped ListBuckets authorization fallback.
|
||||
#[cfg(test)]
|
||||
mod list_buckets_auth_test;
|
||||
|
||||
// ListBuckets visibility follows IAM authorization, not bucket policy.
|
||||
#[cfg(test)]
|
||||
mod list_buckets_iam_filter_test;
|
||||
|
||||
// Regression test for backlog#629(b): region-aware CreateBucket SigV4.
|
||||
#[cfg(test)]
|
||||
mod create_bucket_region_test;
|
||||
@@ -298,4 +306,32 @@ mod create_bucket_region_test;
|
||||
#[cfg(test)]
|
||||
mod copy_source_invalid_date_test;
|
||||
|
||||
// P0 regression: event notification startup race (rustfs#5387, #5681, #5401, #5183, #5115, #4796)
|
||||
#[cfg(test)]
|
||||
mod notification_startup_regression_test;
|
||||
|
||||
// P0 regression: lifecycle/ILM object expiration (rustfs#5407, #5167, #4963, #5615, #4879)
|
||||
#[cfg(test)]
|
||||
mod lifecycle_regression_test;
|
||||
|
||||
// P0 regression: delete operations consistency (rustfs#5375, #5349, #5339, #5029, #4978, #760)
|
||||
#[cfg(test)]
|
||||
mod delete_regression_test;
|
||||
|
||||
// P1 regression: listing/metacache completeness (rustfs#5166, #5156, #5051, #4810, #4648, #3191)
|
||||
#[cfg(test)]
|
||||
mod listing_regression_test;
|
||||
|
||||
// P1 regression: bucket statistics accuracy (rustfs#5615, #5008, #5116, #5055, #3898, #1012)
|
||||
#[cfg(test)]
|
||||
mod bucket_stats_regression_test;
|
||||
|
||||
// P1 regression: distributed startup/quorum (rustfs#5416, #2945, #2794, #2601, #4040, #5655)
|
||||
#[cfg(test)]
|
||||
mod distributed_startup_regression_test;
|
||||
|
||||
// P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024)
|
||||
#[cfg(test)]
|
||||
mod tier_transition_regression_test;
|
||||
|
||||
pub mod tls_gen;
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// 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.
|
||||
|
||||
//! Regression tests for lifecycle/ILM object expiration and transition.
|
||||
//!
|
||||
//! Covers the recurring pattern where ILM expiration rules do not actually
|
||||
//! delete objects, or lifecycle rule parameters are silently corrupted.
|
||||
//! This has regressed 6+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5407: lifecycle not delete any bucket object
|
||||
//! - rustfs#5167: lifecycle not delete object
|
||||
//! - rustfs#4963: lifecycle rule 3 days → effective value 0 days
|
||||
//! - rustfs#5615: bucket statistics remain unchanged after data expiration
|
||||
//! - rustfs#4879: ILM serial lane: restore transition never completes
|
||||
//! - rustfs#5442: Uncheck of Replicate Delete still deletes the file
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, BucketVersioningStatus, ExpirationStatus, LifecycleExpiration, LifecycleRule,
|
||||
LifecycleRuleFilter, NoncurrentVersionExpiration, VersioningConfiguration,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
async fn setup_versioned_bucket(client: &Client, bucket: &str) -> TestResult {
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("create bucket: {e}"))?;
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("enable versioning: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-03: Verify that a lifecycle expiration rule actually deletes objects.
|
||||
///
|
||||
/// Regression pattern: lifecycle rules are accepted but the scanner never
|
||||
/// processes them, leaving expired objects in place.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Create a versioned bucket
|
||||
/// 2. Upload several objects
|
||||
/// 3. Apply a lifecycle rule with 1-day expiration
|
||||
/// 4. Wait for the scanner to process
|
||||
/// 5. Verify objects are still present (they shouldn't expire yet — 1 day)
|
||||
/// 6. Verify the lifecycle rule was persisted correctly (not corrupted to 0 days)
|
||||
///
|
||||
/// This tests the rule persistence path (rustfs#4963: 3 days → 0 days).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_lifecycle_expiration_rule_persists_correctly() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-03: lifecycle expiration rule persists correctly");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt03-lifecycle-persist";
|
||||
setup_versioned_bucket(&client, bucket).await?;
|
||||
|
||||
// Apply a lifecycle rule with 1-day expiration on a prefix
|
||||
let rule = LifecycleRule::builder()
|
||||
.id("expire-after-1-day")
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix("logs/").build())
|
||||
.expiration(LifecycleExpiration::builder().days(1).build())
|
||||
.build()
|
||||
.expect("build lifecycle rule");
|
||||
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(
|
||||
BucketLifecycleConfiguration::builder()
|
||||
.rules(rule)
|
||||
.build()
|
||||
.expect("build lifecycle config"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("put lifecycle configuration");
|
||||
|
||||
// Read back and verify the rule was not corrupted (rustfs#4963: days → 0)
|
||||
let resp = client
|
||||
.get_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("get lifecycle configuration");
|
||||
|
||||
let rules = resp.rules();
|
||||
assert_eq!(rules.len(), 1, "RT-03 FAIL: expected exactly 1 lifecycle rule");
|
||||
|
||||
let retrieved = &rules[0];
|
||||
assert_eq!(retrieved.id(), Some("expire-after-1-day"), "RT-03 FAIL: rule ID mismatch");
|
||||
assert_eq!(retrieved.status(), &ExpirationStatus::Enabled, "RT-03 FAIL: rule should be Enabled");
|
||||
|
||||
let exp = retrieved.expiration().expect("expiration should be set");
|
||||
assert_eq!(
|
||||
exp.days(),
|
||||
Some(1),
|
||||
"RT-03 FAIL: expiration days corrupted (regression rustfs#4963: expected 1, got {:?})",
|
||||
exp.days()
|
||||
);
|
||||
|
||||
info!("RT-03 PASS: lifecycle expiration rule persists correctly");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-03b: Verify lifecycle rule with noncurrent version expiration.
|
||||
///
|
||||
/// Covers the pattern where noncurrent version expiration rules are
|
||||
/// accepted but old versions are never cleaned up.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_lifecycle_noncurrent_version_expiration_rule_persists() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-03b: noncurrent version expiration rule persists");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt03b-noncurrent-expire";
|
||||
setup_versioned_bucket(&client, bucket).await?;
|
||||
|
||||
// Create multiple versions of the same object
|
||||
for i in 0..3 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("versioned-obj.txt")
|
||||
.body(ByteStream::from(format!("version-{i}").into_bytes()))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object version");
|
||||
}
|
||||
|
||||
// Verify we have 3 versions
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
let count = versions.versions().len();
|
||||
assert_eq!(count, 3, "RT-03b FAIL: expected 3 versions, found {count}");
|
||||
|
||||
// Apply noncurrent version expiration rule
|
||||
let rule = LifecycleRule::builder()
|
||||
.id("expire-noncurrent-after-1-day")
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix("").build())
|
||||
.noncurrent_version_expiration(NoncurrentVersionExpiration::builder().noncurrent_days(1).build())
|
||||
.build()
|
||||
.expect("build lifecycle rule");
|
||||
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(
|
||||
BucketLifecycleConfiguration::builder()
|
||||
.rules(rule)
|
||||
.build()
|
||||
.expect("build lifecycle config"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("put lifecycle configuration");
|
||||
|
||||
// Read back and verify
|
||||
let resp = client
|
||||
.get_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("get lifecycle configuration");
|
||||
|
||||
let rules = resp.rules();
|
||||
assert_eq!(rules.len(), 1, "RT-03b FAIL: expected 1 rule");
|
||||
|
||||
let nc_exp = rules[0]
|
||||
.noncurrent_version_expiration()
|
||||
.expect("noncurrent expiration should be set");
|
||||
assert_eq!(nc_exp.noncurrent_days(), Some(1), "RT-03b FAIL: noncurrent days corrupted");
|
||||
|
||||
info!("RT-03b PASS: noncurrent version expiration rule persists correctly");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-04: Verify lifecycle rule with prefix filter persists after restart.
|
||||
///
|
||||
/// Covers the pattern where lifecycle rules are accepted but silently lost
|
||||
/// after restart. Transition rules require a configured remote tier
|
||||
/// (tested in reliant/tiering.rs), so this test uses expiration only.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_lifecycle_prefix_rule_persists() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-04: lifecycle prefix rule persists");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt04-lifecycle-prefix";
|
||||
setup_versioned_bucket(&client, bucket).await?;
|
||||
|
||||
let rule = LifecycleRule::builder()
|
||||
.id("expire-archive-after-7-days")
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix("archive/").build())
|
||||
.expiration(LifecycleExpiration::builder().days(7).build())
|
||||
.build()
|
||||
.expect("build lifecycle rule");
|
||||
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(
|
||||
BucketLifecycleConfiguration::builder()
|
||||
.rules(rule)
|
||||
.build()
|
||||
.expect("build lifecycle config"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("put lifecycle configuration");
|
||||
|
||||
// Restart server
|
||||
env.restart_server_preserving_data(vec![], &[]).await.expect("restart RustFS");
|
||||
|
||||
// Verify the rule survived restart
|
||||
let resp = client
|
||||
.get_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("get lifecycle after restart");
|
||||
|
||||
let rules = resp.rules();
|
||||
assert_eq!(rules.len(), 1, "RT-04 FAIL: expected 1 rule after restart");
|
||||
|
||||
let exp = rules[0].expiration().expect("expiration should be set");
|
||||
assert_eq!(exp.days(), Some(7), "RT-04 FAIL: expiration days corrupted after restart");
|
||||
|
||||
info!("RT-04 PASS: lifecycle prefix rule persists after restart");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-05b: Verify delete marker creation in versioned bucket.
|
||||
///
|
||||
/// Regression pattern: DELETE on a versioned object fails or does not
|
||||
/// create a delete marker, or the delete marker is not visible in LIST.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_delete_marker_creation_and_visibility() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-05b: delete marker creation and visibility");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt05b-delete-marker";
|
||||
setup_versioned_bucket(&client, bucket).await?;
|
||||
|
||||
// Put an object
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("marker-test.txt")
|
||||
.body(ByteStream::from_static(b"to-be-deleted"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
|
||||
// Delete without specifying versionId → should create a delete marker
|
||||
let del_resp = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("marker-test.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("delete object");
|
||||
|
||||
// The response should indicate a delete marker was created
|
||||
assert!(
|
||||
del_resp.delete_marker().unwrap_or(false),
|
||||
"RT-05b FAIL: DELETE on versioned object did not create a delete marker"
|
||||
);
|
||||
|
||||
// ListObjectVersions should show both the original version and the delete marker
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list versions");
|
||||
|
||||
let delete_markers: Vec<_> = versions
|
||||
.delete_markers()
|
||||
.iter()
|
||||
.filter(|dm| dm.key() == Some("marker-test.txt"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
delete_markers.len(),
|
||||
1,
|
||||
"RT-05b FAIL: expected 1 delete marker, found {}",
|
||||
delete_markers.len()
|
||||
);
|
||||
|
||||
info!("RT-05b PASS: delete marker created and visible");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression coverage for the MinIO-compatible filtered ListBuckets fallback.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, admin_ok, init_logging};
|
||||
use std::error::Error;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
#[tokio::test]
|
||||
async fn bucket_scoped_policy_returns_only_authorized_bucket() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let root_client = env.create_s3_client();
|
||||
let allowed_bucket = "list-buckets-authorized";
|
||||
let hidden_bucket = "list-buckets-hidden";
|
||||
let user = "listbucketsuser";
|
||||
let secret = "listbucketssecret";
|
||||
let policy = "list-buckets-scoped";
|
||||
|
||||
root_client.create_bucket().bucket(allowed_bucket).send().await?;
|
||||
root_client.create_bucket().bucket(hidden_bucket).send().await?;
|
||||
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-canned-policy?name={policy}"),
|
||||
Some(
|
||||
serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": [
|
||||
format!("arn:aws:s3:::{allowed_bucket}"),
|
||||
format!("arn:aws:s3:::{allowed_bucket}/*")
|
||||
]
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-user?accessKey={user}"),
|
||||
Some(serde_json::json!({ "secretKey": secret, "status": "enabled" }).to_string()),
|
||||
)
|
||||
.await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/idp/builtin/policy/attach",
|
||||
Some(serde_json::json!({ "policies": [policy], "user": user }).to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client = env.create_s3_client_with_credentials(user, secret);
|
||||
// Capture ListBuckets first so the direct-access control cannot warm bucket metadata and mask the regression.
|
||||
let listed = client.list_buckets().send().await;
|
||||
client.list_objects_v2().bucket(allowed_bucket).send().await?;
|
||||
|
||||
let listed = listed?;
|
||||
let names = listed
|
||||
.buckets()
|
||||
.iter()
|
||||
.filter_map(|bucket| bucket.name().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, vec![allowed_bucket]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// 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 crate::common::{RustFSTestEnvironment, admin_ok, build_test_s3_config, build_test_sts_client, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use serial_test::serial;
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
fn user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client {
|
||||
Client::from_conf(build_test_s3_config(
|
||||
&env.url,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
"list-buckets-iam-filter",
|
||||
))
|
||||
}
|
||||
|
||||
fn bucket_names(buckets: &[aws_sdk_s3::types::Bucket]) -> Vec<String> {
|
||||
let mut names = buckets
|
||||
.iter()
|
||||
.filter_map(|bucket| bucket.name().map(str::to_owned))
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
async fn create_user(
|
||||
env: &RustFSTestEnvironment,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let body = serde_json::json!({ "secretKey": secret_key, "status": "enabled" }).to_string();
|
||||
admin_ok(
|
||||
env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-user?accessKey={access_key}"),
|
||||
Some(body),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_service_account(
|
||||
env: &RustFSTestEnvironment,
|
||||
target_user: &str,
|
||||
policy: Option<&serde_json::Value>,
|
||||
) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let request = match policy {
|
||||
Some(policy) => serde_json::json!({ "targetUser": target_user, "policy": policy }),
|
||||
None => serde_json::json!({ "targetUser": target_user }),
|
||||
};
|
||||
let response = admin_ok(env, http::Method::PUT, "/rustfs/admin/v3/add-service-accounts", Some(request.to_string())).await?;
|
||||
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||
let access_key = response["credentials"]["accessKey"]
|
||||
.as_str()
|
||||
.ok_or("service account response should contain credentials.accessKey")?
|
||||
.to_owned();
|
||||
let secret_key = response["credentials"]["secretKey"]
|
||||
.as_str()
|
||||
.ok_or("service account response should contain credentials.secretKey")?
|
||||
.to_owned();
|
||||
Ok((access_key, secret_key))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn list_buckets_filters_with_iam_bucket_resources() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.capture_log_path = Some(format!("{}/server.log", env.temp_dir));
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUST_LOG", "rustfs=debug,rustfs_notify=debug")])
|
||||
.await?;
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
for bucket in [
|
||||
"benchmark-artifacts",
|
||||
"benchmark-denied",
|
||||
"benchmark-location-only",
|
||||
"benchmark-test1",
|
||||
"testuser1-artifacts",
|
||||
] {
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
}
|
||||
assert_eq!(
|
||||
bucket_names(admin_client.list_buckets().send().await?.buckets()),
|
||||
vec![
|
||||
"benchmark-artifacts",
|
||||
"benchmark-denied",
|
||||
"benchmark-location-only",
|
||||
"benchmark-test1",
|
||||
"testuser1-artifacts"
|
||||
]
|
||||
);
|
||||
|
||||
let access_key = "benchmark";
|
||||
let secret_key = "benchmark-secret-1234567890";
|
||||
create_user(&env, access_key, secret_key).await?;
|
||||
|
||||
let policy_name = "benchmark-bucket-prefix";
|
||||
let policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:*"],
|
||||
"Resource": ["arn:aws:s3:::benchmark-*", "arn:aws:s3:::benchmark-*/*"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:prefix": [""],
|
||||
"s3:delimiter": ["/"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
|
||||
"Resource": ["arn:aws:s3:::benchmark-denied"]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": ["arn:aws:s3:::benchmark-location-only"]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["sts:AssumeRole"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-canned-policy?name={policy_name}"),
|
||||
Some(policy),
|
||||
)
|
||||
.await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/set-user-or-group-policy?policyName={policy_name}&userOrGroup={access_key}&isGroup=false"),
|
||||
Some(String::new()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bucket_policy_allow = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [access_key] },
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": ["arn:aws:s3:::testuser1-artifacts"]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
admin_client
|
||||
.put_bucket_policy()
|
||||
.bucket("testuser1-artifacts")
|
||||
.policy(bucket_policy_allow)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let bucket_policy_deny = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Deny",
|
||||
"Principal": { "AWS": [access_key] },
|
||||
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
|
||||
"Resource": ["arn:aws:s3:::benchmark-artifacts"]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
admin_client
|
||||
.put_bucket_policy()
|
||||
.bucket("benchmark-artifacts")
|
||||
.policy(bucket_policy_deny)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let benchmark_client = user_client(&env, access_key, secret_key, None);
|
||||
benchmark_client
|
||||
.list_objects_v2()
|
||||
.bucket("testuser1-artifacts")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
bucket_names(benchmark_client.list_buckets().send().await?.buckets()),
|
||||
vec!["benchmark-artifacts", "benchmark-location-only", "benchmark-test1"]
|
||||
);
|
||||
let log_path = env.capture_log_path.as_deref().expect("server log path should be configured");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let audit_log = loop {
|
||||
let audit_log = tokio::fs::read_to_string(log_path).await?;
|
||||
if [
|
||||
"iam_implicit_deny",
|
||||
"s3_authorization_denied",
|
||||
"ListAllMyBucketsAction",
|
||||
"benchmark",
|
||||
"DEBUG",
|
||||
]
|
||||
.iter()
|
||||
.all(|field| audit_log.contains(field))
|
||||
|| Instant::now() >= deadline
|
||||
{
|
||||
break audit_log;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
assert_eq!(audit_log.matches("iam_implicit_deny").count(), 1, "{audit_log}");
|
||||
for field in ["s3_authorization_denied", "ListAllMyBucketsAction", "benchmark", "DEBUG"] {
|
||||
assert!(audit_log.contains(field), "missing {field} in {audit_log}");
|
||||
}
|
||||
|
||||
let denied_access_key = "no-bucket-access";
|
||||
let denied_secret_key = "no-bucket-access-secret-1234567890";
|
||||
create_user(&env, denied_access_key, denied_secret_key).await?;
|
||||
let denied = user_client(&env, denied_access_key, denied_secret_key, None)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a user without IAM bucket permissions must be denied");
|
||||
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
|
||||
let put_only_policy_name = "put-only-no-bucket-discovery";
|
||||
let put_only_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": ["arn:aws:s3:::benchmark-*/*"]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-canned-policy?name={put_only_policy_name}"),
|
||||
Some(put_only_policy),
|
||||
)
|
||||
.await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!(
|
||||
"/rustfs/admin/v3/set-user-or-group-policy?policyName={put_only_policy_name}&userOrGroup={denied_access_key}&isGroup=false"
|
||||
),
|
||||
Some(String::new()),
|
||||
)
|
||||
.await?;
|
||||
let denied = user_client(&env, denied_access_key, denied_secret_key, None)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an unrelated IAM action must not reveal bucket names");
|
||||
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
|
||||
let list_all_policy_name = "list-all-buckets";
|
||||
let list_all_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListAllMyBuckets"],
|
||||
"Resource": ["arn:aws:s3:::*"]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/add-canned-policy?name={list_all_policy_name}"),
|
||||
Some(list_all_policy),
|
||||
)
|
||||
.await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!(
|
||||
"/rustfs/admin/v3/set-user-or-group-policy?policyName={list_all_policy_name}&userOrGroup={denied_access_key}&isGroup=false"
|
||||
),
|
||||
Some(String::new()),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
bucket_names(
|
||||
user_client(&env, denied_access_key, denied_secret_key, None)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await?
|
||||
.buckets()
|
||||
),
|
||||
vec![
|
||||
"benchmark-artifacts",
|
||||
"benchmark-denied",
|
||||
"benchmark-location-only",
|
||||
"benchmark-test1",
|
||||
"testuser1-artifacts"
|
||||
]
|
||||
);
|
||||
|
||||
let group_user = "benchmark-group-user";
|
||||
let group_secret = "benchmark-group-secret-1234567890";
|
||||
let group_name = "benchmark-group";
|
||||
create_user(&env, group_user, group_secret).await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/update-group-members",
|
||||
Some(
|
||||
serde_json::json!({
|
||||
"group": group_name,
|
||||
"members": [group_user],
|
||||
"isRemove": false,
|
||||
"groupStatus": "enabled"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/set-user-or-group-policy?policyName={policy_name}&userOrGroup={group_name}&isGroup=true"),
|
||||
Some(String::new()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
bucket_names(
|
||||
user_client(&env, group_user, group_secret, None)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await?
|
||||
.buckets()
|
||||
),
|
||||
vec!["benchmark-artifacts", "benchmark-location-only", "benchmark-test1"]
|
||||
);
|
||||
|
||||
let (service_access_key, service_secret_key) = create_service_account(&env, group_user, None).await?;
|
||||
assert_eq!(
|
||||
bucket_names(
|
||||
user_client(&env, &service_access_key, &service_secret_key, None)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await?
|
||||
.buckets()
|
||||
),
|
||||
vec!["benchmark-artifacts", "benchmark-location-only", "benchmark-test1"]
|
||||
);
|
||||
|
||||
let service_account_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
|
||||
"Resource": ["arn:aws:s3:::benchmark-test1"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:prefix": [""],
|
||||
"s3:delimiter": ["/"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
let (restricted_service_access_key, restricted_service_secret_key) =
|
||||
create_service_account(&env, group_user, Some(&service_account_policy)).await?;
|
||||
assert_eq!(
|
||||
bucket_names(
|
||||
user_client(&env, &restricted_service_access_key, &restricted_service_secret_key, None,)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await?
|
||||
.buckets()
|
||||
),
|
||||
vec!["benchmark-test1"]
|
||||
);
|
||||
|
||||
let sts_client = build_test_sts_client(&env.url, group_user, group_secret, None, "list-buckets-iam-filter-sts");
|
||||
let inherited = sts_client
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/list-buckets")
|
||||
.role_session_name("list-buckets-iam-filter-inherited")
|
||||
.send()
|
||||
.await?;
|
||||
let inherited = inherited
|
||||
.credentials()
|
||||
.ok_or("AssumeRole response should contain inherited temporary credentials")?;
|
||||
assert_eq!(
|
||||
bucket_names(
|
||||
user_client(
|
||||
&env,
|
||||
inherited.access_key_id(),
|
||||
inherited.secret_access_key(),
|
||||
Some(inherited.session_token()),
|
||||
)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await?
|
||||
.buckets()
|
||||
),
|
||||
vec!["benchmark-artifacts", "benchmark-location-only", "benchmark-test1"]
|
||||
);
|
||||
|
||||
let session_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
|
||||
"Resource": ["arn:aws:s3:::benchmark-test1"],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:prefix": [""],
|
||||
"s3:delimiter": ["/"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
let assumed = sts_client
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/list-buckets")
|
||||
.role_session_name("list-buckets-iam-filter")
|
||||
.policy(session_policy)
|
||||
.send()
|
||||
.await?;
|
||||
let temporary = assumed
|
||||
.credentials()
|
||||
.ok_or("AssumeRole response should contain temporary credentials")?;
|
||||
assert_eq!(
|
||||
bucket_names(
|
||||
user_client(
|
||||
&env,
|
||||
temporary.access_key_id(),
|
||||
temporary.secret_access_key(),
|
||||
Some(temporary.session_token()),
|
||||
)
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await?
|
||||
.buckets()
|
||||
),
|
||||
vec!["benchmark-test1"]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// 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.
|
||||
|
||||
//! Regression tests for object listing and metacache consistency.
|
||||
//!
|
||||
//! Covers the recurring pattern where ListObjectsV2 returns incomplete results,
|
||||
//! silently truncates with IsTruncated=false, or corrupts the metadata cache.
|
||||
//! This has regressed 8+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5166: Metacache listing quorum failed timeout after cluster startup
|
||||
//! - rustfs#5156: Metacache producer failed
|
||||
//! - rustfs#5051: ListObjectsV2 returns empty results for shallow prefixes
|
||||
//! - rustfs#4810: walk_dir timeout silently truncates listings (200, IsTruncated=false)
|
||||
//! - rustfs#4648: Object listing oscillates between complete, partial, and zero
|
||||
//! - rustfs#3191: ListObjectsV2 timeout corrupts metadata cache → NoSuchBucket
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use serial_test::serial;
|
||||
use std::collections::HashSet;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-06: Verify ListObjectsV2 pagination completeness for medium-sized bucket.
|
||||
///
|
||||
/// Regression pattern: listing returns 200 with IsTruncated=false but
|
||||
/// misses objects (rustfs#4810: walk_dir timeout truncation).
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Upload 100 objects with known keys
|
||||
/// 2. List all objects via pagination (max_keys=10)
|
||||
/// 3. Verify all 100 keys are returned exactly once
|
||||
/// 4. Verify no duplicates or skipped keys
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_completeness_100_objects() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-06: listing completeness with 100 objects");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt06-list-completeness";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload 100 objects
|
||||
let expected_keys: Vec<String> = (0..100).map(|i| format!("obj-{i:04}.txt")).collect();
|
||||
for key in &expected_keys {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// Paginate through all objects (small page size to force multiple pages)
|
||||
let mut all_keys: Vec<String> = Vec::new();
|
||||
let mut continuation_token: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let mut req = client.list_objects_v2().bucket(bucket).max_keys(10);
|
||||
|
||||
if let Some(ref token) = continuation_token {
|
||||
req = req.continuation_token(token);
|
||||
}
|
||||
|
||||
let resp = req.send().await.expect("list objects page");
|
||||
|
||||
for obj in resp.contents() {
|
||||
all_keys.push(obj.key().unwrap_or("").to_string());
|
||||
}
|
||||
|
||||
if !resp.is_truncated().unwrap_or(false) {
|
||||
break;
|
||||
}
|
||||
continuation_token = resp.next_continuation_token().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
// Verify completeness and uniqueness
|
||||
let unique_keys: HashSet<&str> = all_keys.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
assert_eq!(
|
||||
all_keys.len(),
|
||||
100,
|
||||
"RT-06 FAIL: expected 100 objects, listed {} (regression: walk_dir truncation)",
|
||||
all_keys.len()
|
||||
);
|
||||
assert_eq!(
|
||||
unique_keys.len(),
|
||||
100,
|
||||
"RT-06 FAIL: found {} unique keys but listed {} total (duplicates!)",
|
||||
unique_keys.len(),
|
||||
all_keys.len()
|
||||
);
|
||||
|
||||
for key in &expected_keys {
|
||||
assert!(
|
||||
unique_keys.contains(key.as_str()),
|
||||
"RT-06 FAIL: key '{key}' missing from listing (regression rustfs#4810)"
|
||||
);
|
||||
}
|
||||
|
||||
info!("RT-06 PASS: all 100 objects listed completely and uniquely");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-06b: Verify listing with prefix filter returns correct subset.
|
||||
///
|
||||
/// Regression pattern: prefix filter returns empty or includes wrong keys
|
||||
/// (rustfs#5051: empty results for shallow prefixes).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_prefix_filter_correctness() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-06b: prefix filter correctness");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt06b-prefix-filter";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload objects with different prefixes
|
||||
for i in 0..5 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("logs/app-{i:04}.log"))
|
||||
.body(ByteStream::from_static(b"log data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put log object");
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("data/file-{i:04}.csv"))
|
||||
.body(ByteStream::from_static(b"csv data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put data object");
|
||||
}
|
||||
|
||||
// List with prefix "logs/" — should return exactly 5
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix("logs/")
|
||||
.send()
|
||||
.await
|
||||
.expect("list with prefix");
|
||||
|
||||
assert_eq!(
|
||||
resp.contents().len(),
|
||||
5,
|
||||
"RT-06b FAIL: expected 5 objects with prefix 'logs/', found {} (regression rustfs#5051)",
|
||||
resp.contents().len()
|
||||
);
|
||||
|
||||
for obj in resp.contents() {
|
||||
assert!(
|
||||
obj.key().unwrap_or("").starts_with("logs/"),
|
||||
"RT-06b FAIL: object '{}' does not match prefix 'logs/'",
|
||||
obj.key().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
|
||||
// List with prefix "data/" — should return exactly 5
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix("data/")
|
||||
.send()
|
||||
.await
|
||||
.expect("list with data/ prefix");
|
||||
|
||||
assert_eq!(
|
||||
resp.contents().len(),
|
||||
5,
|
||||
"RT-06b FAIL: expected 5 objects with prefix 'data/', found {}",
|
||||
resp.contents().len()
|
||||
);
|
||||
|
||||
// List with prefix "nonexistent/" — should return 0
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix("nonexistent/")
|
||||
.send()
|
||||
.await
|
||||
.expect("list with nonexistent prefix");
|
||||
|
||||
assert!(
|
||||
resp.contents().is_empty(),
|
||||
"RT-06b FAIL: expected 0 objects with prefix 'nonexistent/', found {}",
|
||||
resp.contents().len()
|
||||
);
|
||||
|
||||
info!("RT-06b PASS: prefix filter returns correct subset");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-06c: Verify listing with delimiter and CommonPrefixes.
|
||||
///
|
||||
/// Regression pattern: delimiter handling produces incorrect CommonPrefixes
|
||||
/// or misses objects at the delimiter boundary.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_delimiter_common_prefixes() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-06c: delimiter and CommonPrefixes");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt06c-delimiter";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Create a hierarchical structure
|
||||
let keys = vec!["a.txt", "dir1/b.txt", "dir1/sub1/c.txt", "dir1/sub2/d.txt", "dir2/e.txt"];
|
||||
|
||||
for key in &keys {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(*key)
|
||||
.body(ByteStream::from_static(b"content"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// List with delimiter "/" at root level
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.delimiter("/")
|
||||
.send()
|
||||
.await
|
||||
.expect("list with delimiter");
|
||||
|
||||
// Should have 1 object (a.txt) and 2 common prefixes (dir1/, dir2/)
|
||||
let contents: Vec<_> = resp.contents().iter().map(|o| o.key().unwrap_or("")).collect();
|
||||
let prefixes: Vec<_> = resp.common_prefixes().iter().map(|p| p.prefix().unwrap_or("")).collect();
|
||||
|
||||
assert!(contents.contains(&"a.txt"), "RT-06c FAIL: root object 'a.txt' missing from listing");
|
||||
assert_eq!(contents.len(), 1, "RT-06c FAIL: expected 1 root-level object, found {}", contents.len());
|
||||
assert_eq!(prefixes.len(), 2, "RT-06c FAIL: expected 2 common prefixes, found {:?}", prefixes);
|
||||
assert!(prefixes.contains(&"dir1/"), "RT-06c FAIL: 'dir1/' missing from CommonPrefixes");
|
||||
assert!(prefixes.contains(&"dir2/"), "RT-06c FAIL: 'dir2/' missing from CommonPrefixes");
|
||||
|
||||
info!("RT-06c PASS: delimiter and CommonPrefixes correct");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-06d: Verify listing returns correct IsTruncated flag.
|
||||
///
|
||||
/// Regression pattern: IsTruncated=false when there are more objects
|
||||
/// (rustfs#4810: walk_dir timeout truncation with false IsTruncated).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_list_objects_v2_is_truncated_correctness() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-06d: IsTruncated correctness");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt06d-truncated";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Upload 15 objects
|
||||
for i in 0..15 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("item-{i:04}.txt"))
|
||||
.body(ByteStream::from_static(b"data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object");
|
||||
}
|
||||
|
||||
// List with max_keys=5 — should be truncated
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(5)
|
||||
.send()
|
||||
.await
|
||||
.expect("list with max_keys=5");
|
||||
|
||||
assert!(
|
||||
resp.is_truncated().unwrap_or(false),
|
||||
"RT-06d FAIL: IsTruncated should be true with 15 objects and max_keys=5"
|
||||
);
|
||||
assert_eq!(resp.contents().len(), 5, "RT-06d FAIL: expected 5 objects in first page");
|
||||
assert!(
|
||||
resp.next_continuation_token().is_some(),
|
||||
"RT-06d FAIL: NextContinuationToken should be present when truncated"
|
||||
);
|
||||
|
||||
// List with max_keys=100 — should NOT be truncated
|
||||
let resp = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(100)
|
||||
.send()
|
||||
.await
|
||||
.expect("list with max_keys=100");
|
||||
|
||||
assert!(
|
||||
!resp.is_truncated().unwrap_or(false),
|
||||
"RT-06d FAIL: IsTruncated should be false with 15 objects and max_keys=100"
|
||||
);
|
||||
assert_eq!(resp.contents().len(), 15, "RT-06d FAIL: expected 15 objects with max_keys=100");
|
||||
|
||||
info!("RT-06d PASS: IsTruncated flag is correct");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,33 @@ fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
async fn create_restricted_user(
|
||||
env: &RustFSTestEnvironment,
|
||||
username: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/add-user?accessKey={username}", env.url);
|
||||
let body = serde_json::json!({
|
||||
"secretKey": secret_key,
|
||||
"status": "enabled"
|
||||
})
|
||||
.to_string();
|
||||
crate::common::awscurl_put(&url, &body, &env.access_key, &env.secret_key).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restricted_user_client(env: &RustFSTestEnvironment, username: &str, secret_key: &str) -> aws_sdk_s3::Client {
|
||||
let credentials = aws_sdk_s3::config::Credentials::new(username, secret_key, None, None, "snowball-pax-auth-test");
|
||||
let config = aws_sdk_s3::Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
||||
.endpoint_url(&env.url)
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.build();
|
||||
aws_sdk_s3::Client::from_conf(config)
|
||||
}
|
||||
|
||||
/// Env var consumed by the local SSE-S3 DEK provider when KMS is not configured.
|
||||
///
|
||||
/// Since rustfs#3564 the server fails closed on managed SSE (SSE-S3 or
|
||||
@@ -3557,8 +3584,8 @@ async fn test_anonymous_post_object_rejects_expires_field_missing_from_policy_co
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_object_lock_retention_fields() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
async fn test_anonymous_post_object_rejects_object_lock_retention_without_permission()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
@@ -3567,8 +3594,6 @@ async fn test_anonymous_post_object_accepts_object_lock_retention_fields() -> Re
|
||||
let bucket = "anon-post-policy-object-lock-retention";
|
||||
let object_key = "uploads/object-lock-retention.txt";
|
||||
let retain_until = "2037-10-21T07:28:00Z";
|
||||
let expected_body = b"post-policy-object-lock-retention-body".to_vec();
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client
|
||||
.create_bucket()
|
||||
@@ -3593,7 +3618,7 @@ async fn test_anonymous_post_object_accepts_object_lock_retention_fields() -> Re
|
||||
.text("x-amz-object-lock-retain-until-date", retain_until)
|
||||
.part(
|
||||
"file",
|
||||
reqwest::multipart::Part::bytes(expected_body.clone())
|
||||
reqwest::multipart::Part::bytes(b"post-policy-object-lock-retention-body".to_vec())
|
||||
.file_name("upload.txt")
|
||||
.mime_str("text/plain")?,
|
||||
);
|
||||
@@ -3607,26 +3632,8 @@ async fn test_anonymous_post_object_accepts_object_lock_retention_fields() -> Re
|
||||
let status = post_resp.status();
|
||||
let response_body = post_resp.text().await?;
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
|
||||
|
||||
let retention = admin_client
|
||||
.get_object_retention()
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
let retention = retention.retention().expect("retention should be present");
|
||||
assert_eq!(retention.mode().map(|value| value.as_str()), Some("GOVERNANCE"));
|
||||
let retain_until_out = retention
|
||||
.retain_until_date()
|
||||
.expect("retain_until_date should be present")
|
||||
.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)?;
|
||||
assert_eq!(retain_until_out, retain_until);
|
||||
|
||||
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
|
||||
let uploaded = get_out.body.collect().await?.into_bytes();
|
||||
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
|
||||
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
|
||||
assert!(response_body.contains("AccessDenied"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -3815,8 +3822,8 @@ async fn test_anonymous_post_object_rejects_object_lock_retention_missing_from_p
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_anonymous_post_object_accepts_object_lock_legal_hold_field() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
async fn test_anonymous_post_object_rejects_object_lock_legal_hold_without_permission()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
@@ -3824,8 +3831,6 @@ async fn test_anonymous_post_object_accepts_object_lock_legal_hold_field() -> Re
|
||||
|
||||
let bucket = "anon-post-policy-object-lock-legal-hold";
|
||||
let object_key = "uploads/object-lock-legal-hold.txt";
|
||||
let expected_body = b"post-policy-object-lock-legal-hold-body".to_vec();
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client
|
||||
.create_bucket()
|
||||
@@ -3848,7 +3853,7 @@ async fn test_anonymous_post_object_accepts_object_lock_legal_hold_field() -> Re
|
||||
.text("x-amz-object-lock-legal-hold", "ON")
|
||||
.part(
|
||||
"file",
|
||||
reqwest::multipart::Part::bytes(expected_body.clone())
|
||||
reqwest::multipart::Part::bytes(b"post-policy-object-lock-legal-hold-body".to_vec())
|
||||
.file_name("upload.txt")
|
||||
.mime_str("text/plain")?,
|
||||
);
|
||||
@@ -3862,26 +3867,8 @@ async fn test_anonymous_post_object_accepts_object_lock_legal_hold_field() -> Re
|
||||
let status = post_resp.status();
|
||||
let response_body = post_resp.text().await?;
|
||||
|
||||
assert_eq!(status, reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(response_body.is_empty(), "204 response should not contain a body, got: {response_body}");
|
||||
|
||||
let legal_hold = admin_client
|
||||
.get_object_legal_hold()
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
legal_hold
|
||||
.legal_hold()
|
||||
.and_then(|value| value.status())
|
||||
.map(|value| value.as_str()),
|
||||
Some("ON")
|
||||
);
|
||||
|
||||
let get_out = admin_client.get_object().bucket(bucket).key(object_key).send().await?;
|
||||
let uploaded = get_out.body.collect().await?.into_bytes();
|
||||
assert_eq!(uploaded.as_ref(), expected_body.as_slice());
|
||||
assert_eq!(status, reqwest::StatusCode::FORBIDDEN);
|
||||
assert!(response_body.contains("AccessDenied"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -5658,6 +5645,70 @@ async fn test_signed_put_object_extract_preserves_object_lock_retention() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_pax_retention_overrides_request_retention()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let bucket = "signed-extract-pax-retention-precedence";
|
||||
let archive_key = "retention.tar";
|
||||
let extracted_key = "alpha.txt";
|
||||
let request_retain_until = aws_sdk_s3::primitives::DateTime::from_secs(2_114_380_800);
|
||||
let pax_retain_until = "2040-01-01T00:00:00Z";
|
||||
|
||||
let client = env.create_s3_client();
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let pax = HashMap::from([
|
||||
("minio.metadata.x-amz-object-lock-mode", "COMPLIANCE".to_string()),
|
||||
("minio.metadata.x-amz-object-lock-retain-until-date", pax_retain_until.to_string()),
|
||||
]);
|
||||
let archive = make_tar_with_pax_entry(extracted_key, b"alpha-body", None, &pax).await;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
|
||||
.object_lock_retain_until_date(request_retain_until)
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let retention = client
|
||||
.get_object_retention()
|
||||
.bucket(bucket)
|
||||
.key(extracted_key)
|
||||
.send()
|
||||
.await?
|
||||
.retention()
|
||||
.expect("retention should be present")
|
||||
.clone();
|
||||
assert_eq!(retention.mode().map(|value| value.as_str()), Some("COMPLIANCE"));
|
||||
assert_eq!(
|
||||
retention
|
||||
.retain_until_date()
|
||||
.expect("retain_until_date should be present")
|
||||
.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)?,
|
||||
pax_retain_until
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_returns_archive_etag() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -5782,6 +5833,316 @@ async fn test_signed_put_object_extract_preserves_pax_metadata_and_version_id()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retention_conditions()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !crate::common::awscurl_available() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let bucket = "signed-extract-pax-auth";
|
||||
let put_only_user = "snowball-put-only";
|
||||
let put_only_secret = "snowball-put-only-secret";
|
||||
let conditional_user = "snowball-retention-condition";
|
||||
let conditional_secret = "snowball-retention-condition-secret";
|
||||
let wrong_action_user = "snowball-wrong-action";
|
||||
let wrong_action_secret = "snowball-wrong-action-secret";
|
||||
let version_condition_user = "snowball-version-condition";
|
||||
let version_condition_secret = "snowball-version-condition-secret";
|
||||
let pax_context_user = "snowball-pax-context";
|
||||
let pax_context_secret = "snowball-pax-context-secret";
|
||||
let conditional_version_id = Uuid::new_v4().to_string();
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
create_restricted_user(&env, put_only_user, put_only_secret).await?;
|
||||
create_restricted_user(&env, conditional_user, conditional_secret).await?;
|
||||
create_restricted_user(&env, wrong_action_user, wrong_action_secret).await?;
|
||||
create_restricted_user(&env, version_condition_user, version_condition_secret).await?;
|
||||
create_restricted_user(&env, pax_context_user, pax_context_secret).await?;
|
||||
|
||||
let object_resource = format!("arn:aws:s3:::{bucket}/*");
|
||||
let context_archive_resources = [
|
||||
format!("arn:aws:s3:::{bucket}/tag-context.tar"),
|
||||
format!("arn:aws:s3:::{bucket}/lock-context.tar"),
|
||||
];
|
||||
let tag_entry_resource = format!("arn:aws:s3:::{bucket}/tag-context-entry.txt");
|
||||
let lock_entry_resource = format!("arn:aws:s3:::{bucket}/lock-context-entry.txt");
|
||||
let policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "PutOnly",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [put_only_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [object_resource.clone()]
|
||||
},
|
||||
{
|
||||
"Sid": "RetentionWithLimit",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [conditional_user] },
|
||||
"Action": ["s3:PutObject", "s3:PutObjectRetention"],
|
||||
"Resource": [object_resource.clone()]
|
||||
},
|
||||
{
|
||||
"Sid": "DenyRetentionBeyondCutoff",
|
||||
"Effect": "Deny",
|
||||
"Principal": { "AWS": [conditional_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [object_resource.clone()],
|
||||
"Condition": {
|
||||
"DateGreaterThan": {
|
||||
"s3:object-lock-retain-until-date": "2030-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "WrongAdditionalAction",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [wrong_action_user] },
|
||||
"Action": ["s3:PutObject", "s3:PutObjectLegalHold"],
|
||||
"Resource": [object_resource.clone()]
|
||||
},
|
||||
{
|
||||
"Sid": "VersionConditionPut",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [version_condition_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [object_resource.clone()]
|
||||
},
|
||||
{
|
||||
"Sid": "VersionConditionReplicate",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [version_condition_user] },
|
||||
"Action": ["s3:ReplicateObject"],
|
||||
"Resource": [object_resource],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:VersionId": conditional_version_id.clone()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "PaxContextArchives",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject", "s3:PutObjectRetention", "s3:PutObjectTagging"],
|
||||
"Resource": context_archive_resources
|
||||
},
|
||||
{
|
||||
"Sid": "PaxTagContextPut",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [tag_entry_resource.clone()],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:RequestObjectTag/classification": "public"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "PaxTagContextAction",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObjectTagging"],
|
||||
"Resource": [tag_entry_resource]
|
||||
},
|
||||
{
|
||||
"Sid": "PaxLockContextPut",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [lock_entry_resource.clone()],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:object-lock-mode": "COMPLIANCE"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "PaxLockContextAction",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObjectRetention"],
|
||||
"Resource": [lock_entry_resource]
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
admin_client.put_bucket_policy().bucket(bucket).policy(policy).send().await?;
|
||||
|
||||
let put_only_client = restricted_user_client(&env, put_only_user, put_only_secret);
|
||||
let conditional_client = restricted_user_client(&env, conditional_user, conditional_secret);
|
||||
let wrong_action_client = restricted_user_client(&env, wrong_action_user, wrong_action_secret);
|
||||
let cases = [
|
||||
(
|
||||
"legal-hold.tar",
|
||||
put_only_client,
|
||||
HashMap::from([("minio.metadata.x-amz-object-lock-legal-hold", "ON".to_string())]),
|
||||
),
|
||||
(
|
||||
"retention-condition.tar",
|
||||
conditional_client,
|
||||
HashMap::from([
|
||||
("minio.metadata.x-amz-object-lock-mode", "COMPLIANCE".to_string()),
|
||||
("minio.metadata.x-amz-object-lock-retain-until-date", "2099-01-01T00:00:00Z".to_string()),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"version-id.tar",
|
||||
wrong_action_client,
|
||||
HashMap::from([("minio.versionId", Uuid::new_v4().to_string())]),
|
||||
),
|
||||
];
|
||||
|
||||
for (archive_key, client, pax) in cases {
|
||||
let archive = make_tar_with_pax_entry("entry.txt", b"must-not-write", None, &pax).await;
|
||||
let err = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect_err("missing, conditional, or wrong PAX privilege must be rejected");
|
||||
assert_eq!(
|
||||
err.as_service_error().and_then(|error| error.meta().code()),
|
||||
Some("AccessDenied"),
|
||||
"{archive_key}"
|
||||
);
|
||||
}
|
||||
|
||||
let version_condition_client = restricted_user_client(&env, version_condition_user, version_condition_secret);
|
||||
let mismatching_version_pax = HashMap::from([("minio.versionId", Uuid::new_v4().to_string())]);
|
||||
let archive = make_tar_with_pax_entry("version-mismatch-entry.txt", b"must-not-write", None, &mismatching_version_pax).await;
|
||||
let err = version_condition_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("version-mismatch.tar")
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a mismatching PAX version ID must fail the replication condition");
|
||||
assert_eq!(err.as_service_error().and_then(|error| error.meta().code()), Some("AccessDenied"));
|
||||
let err = admin_client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("version-mismatch-entry.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a denied PAX entry must not be written");
|
||||
assert!(matches!(
|
||||
err.as_service_error().and_then(|error| error.meta().code()),
|
||||
Some("NoSuchKey" | "NotFound")
|
||||
));
|
||||
|
||||
let matching_version_pax = HashMap::from([("minio.versionId", conditional_version_id)]);
|
||||
let archive = make_tar_with_pax_entry("condition-entry.txt", b"condition-body", None, &matching_version_pax).await;
|
||||
version_condition_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("version-condition.tar")
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let stored = admin_client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("condition-entry.txt")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(stored.body.collect().await?.into_bytes().as_ref(), b"condition-body");
|
||||
|
||||
let pax_context_client = restricted_user_client(&env, pax_context_user, pax_context_secret);
|
||||
let tag_pax = HashMap::from([("minio.metadata.x-amz-tagging", "classification=public".to_string())]);
|
||||
let archive = make_tar_with_pax_entry("tag-context-entry.txt", b"tag-context-body", None, &tag_pax).await;
|
||||
pax_context_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("tag-context.tar")
|
||||
.tagging("classification=restricted")
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let tags = admin_client
|
||||
.get_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key("tag-context-entry.txt")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
tags.tag_set()
|
||||
.iter()
|
||||
.any(|tag| tag.key() == "classification" && tag.value() == "public")
|
||||
);
|
||||
|
||||
let pax_retain_until = "2040-01-01T00:00:00Z";
|
||||
let lock_pax = HashMap::from([
|
||||
("minio.metadata.x-amz-object-lock-mode", "COMPLIANCE".to_string()),
|
||||
("minio.metadata.x-amz-object-lock-retain-until-date", pax_retain_until.to_string()),
|
||||
]);
|
||||
let archive = make_tar_with_pax_entry("lock-context-entry.txt", b"lock-context-body", None, &lock_pax).await;
|
||||
pax_context_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("lock-context.tar")
|
||||
.object_lock_mode(aws_sdk_s3::types::ObjectLockMode::Governance)
|
||||
.object_lock_retain_until_date(aws_sdk_s3::primitives::DateTime::from_secs(2_114_380_800))
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
let retention = admin_client
|
||||
.get_object_retention()
|
||||
.bucket(bucket)
|
||||
.key("lock-context-entry.txt")
|
||||
.send()
|
||||
.await?
|
||||
.retention()
|
||||
.expect("PAX retention should be present")
|
||||
.clone();
|
||||
assert_eq!(retention.mode().map(|mode| mode.as_str()), Some("COMPLIANCE"));
|
||||
assert_eq!(
|
||||
retention
|
||||
.retain_until_date()
|
||||
.expect("PAX retain-until should be present")
|
||||
.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)?,
|
||||
pax_retain_until
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_signed_put_object_extract_accepts_compat_header() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// 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.
|
||||
|
||||
//! Regression tests for the event notification startup race.
|
||||
//!
|
||||
//! Covers the recurring pattern where webhook/audit targets fail to load at boot
|
||||
//! due to startup ordering (notification runtime starts before server config is
|
||||
//! loaded). This has regressed 9+ times across beta.3 ~ beta.12.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5387: webhook notifications broken again in beta.9+
|
||||
//! - rustfs#5681: Audit webhook targets are not loaded at boot
|
||||
//! - rustfs#5401: Event Destinations broken again
|
||||
//! - rustfs#5183: Audit webhooks stay offline after restart
|
||||
//! - rustfs#5115: init_event_notifier loses startup race against server config load
|
||||
//! - rustfs#4796: Pulsar event destinations offline after restart
|
||||
//! - rustfs#5428: MQTT bucket notifications stop on restarted cluster node
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-01: Verify that the notification runtime initializes correctly at boot.
|
||||
///
|
||||
/// Regression pattern: notification runtime initializes before server config
|
||||
/// is fully loaded, causing webhook targets to never come online.
|
||||
///
|
||||
/// This test verifies the startup ordering by checking that the server
|
||||
/// starts successfully with notification enabled and can serve S3 requests.
|
||||
/// A full webhook delivery test is in notification_webhook_test.rs.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_notification_enabled_server_starts_cleanly() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-01: notification enabled server starts cleanly");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false"), ("RUSTFS_NOTIFY_ENABLE", "true")])
|
||||
.await
|
||||
.expect("start RustFS with notifications enabled");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt01-notify-startup";
|
||||
|
||||
// Server should be healthy and able to serve S3 requests
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("create bucket with notifications enabled");
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("test.txt")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"test"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object with notifications enabled");
|
||||
|
||||
info!("RT-01 PASS: notification enabled server starts and serves S3");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-02: Verify notification config persists after server restart.
|
||||
///
|
||||
/// Regression pattern: after a node restart, notification targets stay
|
||||
/// offline permanently because the config is not re-loaded.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Start server with notification enabled
|
||||
/// 2. Create bucket and configure notification
|
||||
/// 3. Restart server
|
||||
/// 4. Verify notification config still exists
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_notification_config_survives_restart() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-02: notification config survives restart");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false"), ("RUSTFS_NOTIFY_ENABLE", "true")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt02-notify-restart";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Enable versioning (required for notification configuration)
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("enable versioning");
|
||||
|
||||
// Note: We can't fully test notification config persistence without a
|
||||
// configured target. But we verify the server restarts cleanly with
|
||||
// notification enabled, which is the core regression scenario.
|
||||
env.restart_server_preserving_data(vec![], &[])
|
||||
.await
|
||||
.expect("restart RustFS with notifications enabled");
|
||||
|
||||
// Verify bucket still exists and is accessible after restart
|
||||
let list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("list objects after restart");
|
||||
|
||||
assert!(list.contents().is_empty(), "RT-02: bucket should be empty after restart");
|
||||
|
||||
// Verify we can still write objects (notification runtime initialized)
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("after-restart.txt")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"post-restart"))
|
||||
.send()
|
||||
.await
|
||||
.expect("put object after restart — notification runtime must be initialized");
|
||||
|
||||
info!("RT-02 PASS: server with notifications survives restart");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
//! * PUT / multipart-complete / DeleteObject / DeleteObjects each deliver one event with the correct
|
||||
//! eventName, bucket, key, versionId and eTag.
|
||||
//! * prefix/suffix filters drop non-matching keys (rule-engine gate).
|
||||
//! * an event queued while the target endpoint is unreachable is redelivered
|
||||
//! * an event queued while the target endpoint rejects delivery is redelivered
|
||||
//! from the on-disk store once the endpoint recovers (store-and-forward).
|
||||
//! * responseElements and the S3 response use the canonical request ID while
|
||||
//! requestParameters preserve a conflicting client-supplied value.
|
||||
@@ -897,11 +897,10 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An event queued while the target endpoint is unreachable survives on the
|
||||
/// An event queued while the target endpoint rejects delivery survives on the
|
||||
/// durable store and is redelivered once the endpoint comes back.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "FAILING deterministically on main since it landed (#4821): the target is created but never appears in /rustfs/admin/v3/target/arns, so wait_for_target_registered times out. Quarantined per the flake policy; remove with the fix for rustfs#4852"]
|
||||
async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -932,28 +931,55 @@ async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
|
||||
wait_for_target_registered(&env, target).await?;
|
||||
put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;
|
||||
|
||||
// Take the endpoint down (drops the listener, so connections are refused —
|
||||
// a retryable NotConnected), then PUT: the event cannot be delivered and
|
||||
// must survive on the durable queue store.
|
||||
// Replace the healthy setup listener with one that rejects the first POST.
|
||||
// Waiting for that response below proves the queued event reached a failed
|
||||
// delivery attempt before the endpoint recovers.
|
||||
setup_handle.abort();
|
||||
let _ = setup_handle.await;
|
||||
|
||||
let listener = TcpListener::bind(("0.0.0.0", port)).await?;
|
||||
let key = "uploads/redeliver.dat";
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"queued while target down"))
|
||||
.body(ByteStream::from_static(b"queued while target rejects"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Hold the endpoint down long enough for at least one replay attempt to
|
||||
// fail (the replay worker scans the store every 500ms), so recovery below
|
||||
// exercises real redelivery rather than a first-attempt success.
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let mut failure_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await?;
|
||||
let (method, _) = timeout(Duration::from_secs(5), read_http_message(&mut stream)).await??;
|
||||
if method == "HEAD" {
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||
.await?;
|
||||
stream.shutdown().await?;
|
||||
continue;
|
||||
}
|
||||
if method == "POST" {
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||
.await?;
|
||||
stream.shutdown().await?;
|
||||
return Ok::<(), BoxError>(());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bring the endpoint back on the same port; the replay worker retries with
|
||||
// exponential backoff and delivers the queued event.
|
||||
let rejected = match timeout(Duration::from_secs(20), &mut failure_handle).await {
|
||||
Ok(rejected) => rejected,
|
||||
Err(_) => {
|
||||
failure_handle.abort();
|
||||
let _ = failure_handle.await;
|
||||
return Err("webhook replay did not reach the rejecting endpoint".into());
|
||||
}
|
||||
};
|
||||
rejected??;
|
||||
|
||||
// Bring the endpoint back on the same port; the replay worker rescans the
|
||||
// durable queue and delivers the retained event.
|
||||
let listener = TcpListener::bind(("0.0.0.0", port)).await?;
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let handle = serve_event_collector(listener, tx);
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, awscurl_delete, awscurl_get, awscurl_post, awscurl_put, init_logging};
|
||||
use crate::common::{RustFSTestEnvironment, admin_request, awscurl_delete, awscurl_get, awscurl_post, awscurl_put, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use http::{Method, StatusCode};
|
||||
use serial_test::serial;
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
use tracing::{debug, info};
|
||||
|
||||
fn skip_without_awscurl() -> bool {
|
||||
@@ -37,7 +39,8 @@ impl QuotaTestEnv {
|
||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let bucket_name = format!("quota-test-{}", uuid::Uuid::new_v4());
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")])
|
||||
.await?;
|
||||
let client = env.create_s3_client();
|
||||
|
||||
Ok(Self {
|
||||
@@ -67,18 +70,7 @@ impl QuotaTestEnv {
|
||||
}
|
||||
|
||||
pub async fn set_bucket_quota(&self, quota_bytes: u64) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", self.env.url, self.bucket_name);
|
||||
let quota_config = serde_json::json!({
|
||||
"quota": quota_bytes,
|
||||
"quota_type": "HARD"
|
||||
});
|
||||
|
||||
let response = awscurl_put(&url, "a_config.to_string(), &self.env.access_key, &self.env.secret_key).await?;
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to set quota: {}", response).into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
self.set_bucket_quota_for(&self.bucket_name, quota_bytes).await
|
||||
}
|
||||
|
||||
pub async fn get_bucket_quota(&self) -> Result<Option<u64>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -178,6 +170,29 @@ impl QuotaTestEnv {
|
||||
bucket: &str,
|
||||
quota_bytes: u64,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let stats_path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
|
||||
let readiness = async {
|
||||
loop {
|
||||
let (status, response) =
|
||||
admin_request(&self.env.url, Method::GET, &stats_path, None, &self.env.access_key, &self.env.secret_key)
|
||||
.await?;
|
||||
if status.is_success() {
|
||||
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
|
||||
}
|
||||
if status != StatusCode::SERVICE_UNAVAILABLE {
|
||||
return Err(format!("quota usage readiness failed for {bucket}: {status} {response}").into());
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
};
|
||||
match timeout(Duration::from_secs(30), readiness).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => {
|
||||
return Err(format!("quota usage did not become authoritative for {bucket} within 30 seconds").into());
|
||||
}
|
||||
}
|
||||
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", self.env.url, bucket);
|
||||
let quota_config = serde_json::json!({
|
||||
"quota": quota_bytes,
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::common::{
|
||||
RustFSTestEnvironment, awscurl_available, awscurl_post_sts_form_urlencoded, init_logging, local_http_client,
|
||||
replication_fast_env, rustfs_binary_path,
|
||||
};
|
||||
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation as FakeTargetOperation};
|
||||
use crate::kms::common::{create_key_with_specific_id, sse_customer_key_md5_base64};
|
||||
use crate::storage_api::replication_extension::BucketTargetSys;
|
||||
use aws_sdk_s3::config::{Credentials, Region};
|
||||
@@ -362,19 +363,21 @@ impl Drop for SlowReplicationTargetGuard {
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors madmin-go `ResyncTargetsInfo`/`ResyncTarget` json tags — the same
|
||||
// shape `mc replicate resync status` decodes.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
struct ReplicationResetStatusResponse {
|
||||
#[serde(rename = "Targets", default)]
|
||||
#[serde(rename = "target", default)]
|
||||
targets: Vec<ReplicationResetStatusTarget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
struct ReplicationResetStatusTarget {
|
||||
#[serde(rename = "Arn", default)]
|
||||
#[serde(rename = "arn", default)]
|
||||
arn: String,
|
||||
#[serde(rename = "ResetID", default)]
|
||||
#[serde(rename = "resetid", default)]
|
||||
reset_id: String,
|
||||
#[serde(rename = "Status", default)]
|
||||
#[serde(rename = "resyncStatus", default)]
|
||||
status: String,
|
||||
}
|
||||
|
||||
@@ -1654,19 +1657,30 @@ async fn wait_for_source_delete_marker_replication_failed(
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(format!("replication diff failed with status {}", response.status()).into());
|
||||
}
|
||||
let diff: serde_json::Value = response.json().await?;
|
||||
let failed = diff["Entries"].as_array().is_some_and(|entries| {
|
||||
entries.iter().any(|entry| {
|
||||
entry["Object"].as_str() == Some(key)
|
||||
&& entry["IsDeleteMarker"].as_bool() == Some(true)
|
||||
&& entry["ReplicationStatus"].as_str() == Some("FAILED")
|
||||
})
|
||||
// The default diff response is a madmin-style stream of bare DiffInfo
|
||||
// JSON documents (one per line) with no envelope; assert the envelope
|
||||
// is gone so an aggregate-shaped regression fails loudly here.
|
||||
let body = response.text().await?;
|
||||
let entries = body
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(serde_json::from_str::<serde_json::Value>)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for entry in &entries {
|
||||
if entry.get("Entries").is_some() {
|
||||
return Err(format!("replication diff must stream bare DiffInfo documents, got envelope: {entry}").into());
|
||||
}
|
||||
}
|
||||
let failed = entries.iter().any(|entry| {
|
||||
entry["object"].as_str() == Some(key)
|
||||
&& entry["deletemarker"].as_bool() == Some(true)
|
||||
&& entry["rStatus"].as_str() == Some("FAILED")
|
||||
});
|
||||
if failed {
|
||||
return Ok(());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("source delete marker {key} never reported FAILED; last diff={diff}").into());
|
||||
return Err(format!("source delete marker {key} never reported FAILED; last diff={body}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
@@ -2275,6 +2289,30 @@ async fn site_replication_state_edit(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a bucket-level replication resync (`PUT ?replication-reset`) and
|
||||
/// return the target `(arn, reset_id)`, asserting the response carries the
|
||||
/// madmin `ResyncTargetsInfo` shape (`target[0].arn` / `target[0].resetid`)
|
||||
/// that `mc replicate resync start` decodes.
|
||||
async fn start_bucket_replication_reset(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
) -> Result<(String, String), Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}/{bucket}?replication-reset", env.url);
|
||||
let response = signed_request(http::Method::PUT, &url, &env.access_key, &env.secret_key, None, None).await?;
|
||||
if response.status() != StatusCode::OK {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("replication reset start failed: {status} {body}").into());
|
||||
}
|
||||
let payload: serde_json::Value = response.json().await?;
|
||||
let arn = payload["target"][0]["arn"].as_str().unwrap_or_default().to_string();
|
||||
let reset_id = payload["target"][0]["resetid"].as_str().unwrap_or_default().to_string();
|
||||
if arn.is_empty() || reset_id.is_empty() {
|
||||
return Err(format!("replication reset response missing madmin target[0].arn/resetid: {payload}").into());
|
||||
}
|
||||
Ok((arn, reset_id))
|
||||
}
|
||||
|
||||
async fn get_replication_reset_status(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
@@ -2435,6 +2473,107 @@ async fn build_replication_pair(
|
||||
Ok((source_env, target_env, source_bucket.to_string()))
|
||||
}
|
||||
|
||||
/// P0-6: CopyObject creates a new object on the destination key, so it must be
|
||||
/// scheduled for bucket replication exactly like PutObject (MinIO
|
||||
/// CopyObjectHandler parity). Before the fix the copy path never consulted the
|
||||
/// replication config: the destination object stayed local forever (its status
|
||||
/// metadata was inherited wholesale from the source, so the scanner heal pass
|
||||
/// skipped it too — no PENDING/FAILED marker meant nothing to re-drive).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_object_replicates_to_target() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let (source_env, target_env, source_bucket) = build_replication_pair(true).await?;
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
let target_bucket = "replication-check-dst";
|
||||
|
||||
let src_key = "copy-repl-source.txt";
|
||||
let dst_key = "copy-repl-destination.txt";
|
||||
let payload = b"copy object replication payload".to_vec();
|
||||
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(&source_bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from(payload.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(wait_for_object_on_target(&target_client, target_bucket, src_key).await?, payload);
|
||||
// Wait for the source object's terminal COMPLETED status so the copy below
|
||||
// starts from metadata that carries a stale terminal replication state; the
|
||||
// copy must not inherit it (MinIO filterReplicationStatusMetadata parity)
|
||||
// and must drive its own PENDING -> COMPLETED cycle.
|
||||
wait_for_source_replication_status(&source_client, &source_bucket, src_key, "COMPLETED", false).await?;
|
||||
|
||||
source_client
|
||||
.copy_object()
|
||||
.bucket(&source_bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{source_bucket}/{src_key}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
wait_for_object_on_target(&target_client, target_bucket, dst_key).await?,
|
||||
payload,
|
||||
"CopyObject destination must replicate to the remote target"
|
||||
);
|
||||
wait_for_source_replication_status(&source_client, &source_bucket, dst_key, "COMPLETED", false).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// P0-6 companion: snowball auto-extract writes each archive member as an
|
||||
/// independent object; every member must replicate to the remote target like a
|
||||
/// regular PUT (MinIO PutObjectExtract parity).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_snowball_extract_replicates_members_to_target() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let (source_env, target_env, source_bucket) = build_replication_pair(true).await?;
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
let target_bucket = "replication-check-dst";
|
||||
|
||||
let members: [(&str, &[u8]); 2] = [
|
||||
("snowball/member-one.txt", b"first member payload"),
|
||||
("snowball/member-two.txt", b"second member payload"),
|
||||
];
|
||||
|
||||
let mut builder = tokio_tar::Builder::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, data) in members {
|
||||
let mut header = tokio_tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, path, std::io::Cursor::new(data)).await?;
|
||||
}
|
||||
let archive = builder.into_inner().await?.into_inner();
|
||||
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(&source_bucket)
|
||||
.key("members.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.body(ByteStream::from(archive))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
for (key, data) in members {
|
||||
assert_eq!(
|
||||
wait_for_object_on_target(&target_client, target_bucket, key).await?,
|
||||
data,
|
||||
"snowball-extracted member {key} must replicate to the remote target"
|
||||
);
|
||||
wait_for_source_replication_status(&source_client, &source_bucket, key, "COMPLETED", false).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
@@ -2754,7 +2893,7 @@ async fn test_set_remote_target_update_requires_arn() -> Result<(), Box<dyn Erro
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
|
||||
assert!(body.to_ascii_lowercase().contains("arn is empty"), "unexpected response: {body}");
|
||||
assert!(body.to_ascii_lowercase().contains("arn is required"), "unexpected response: {body}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2812,6 +2951,128 @@ async fn test_set_remote_target_update_rejects_missing_target() -> Result<(), Bo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_set_replication_target_update_request(
|
||||
source_env: &RustFSTestEnvironment,
|
||||
source_bucket: &str,
|
||||
ops: &[&str],
|
||||
body: serde_json::Value,
|
||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
let mut url = format!(
|
||||
"{}/rustfs/admin/v3/set-remote-target?bucket={}&update=true",
|
||||
source_env.url,
|
||||
urlencoding::encode(source_bucket)
|
||||
);
|
||||
for op in ops {
|
||||
url.push_str(&format!("&{op}=true"));
|
||||
}
|
||||
signed_request(
|
||||
http::Method::PUT,
|
||||
&url,
|
||||
&source_env.access_key,
|
||||
&source_env.secret_key,
|
||||
Some(body.to_string().into_bytes()),
|
||||
Some("application/json"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_single_target(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
|
||||
let response = list_replication_targets_request(env, Some(bucket)).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let mut targets: Vec<serde_json::Value> = response.json().await?;
|
||||
assert_eq!(targets.len(), 1, "expected exactly one remote target");
|
||||
Ok(targets.remove(0))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_partial_update_preserves_credentials() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
source_env
|
||||
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
|
||||
.await?;
|
||||
|
||||
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||
|
||||
let source_bucket = "replication-partial-update-src";
|
||||
let target_bucket = "replication-partial-update-dst";
|
||||
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
|
||||
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||
target_client.create_bucket().bucket(target_bucket).send().await?;
|
||||
|
||||
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||
enable_bucket_versioning(&target_env, target_bucket).await?;
|
||||
|
||||
let arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
|
||||
|
||||
// A sync-only update whose body omits credentials entirely must succeed and
|
||||
// leave the stored connection settings untouched.
|
||||
let response = send_set_replication_target_update_request(
|
||||
&source_env,
|
||||
source_bucket,
|
||||
&["sync"],
|
||||
serde_json::json!({
|
||||
"arn": arn,
|
||||
"type": "replication",
|
||||
"replicationSync": true
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::OK, "sync-only update failed: {}", response.text().await?);
|
||||
|
||||
let target = fetch_single_target(&source_env, source_bucket).await?;
|
||||
assert_eq!(target["replicationSync"], serde_json::json!(true));
|
||||
assert_eq!(target["endpoint"], serde_json::json!(target_env.address));
|
||||
assert_eq!(target["credentials"]["accessKey"], serde_json::json!(target_env.access_key));
|
||||
|
||||
// An update naming no field groups is a no-op: a body carrying a different
|
||||
// endpoint and credentials must not leak into the stored target.
|
||||
let response = send_set_replication_target_update_request(
|
||||
&source_env,
|
||||
source_bucket,
|
||||
&[],
|
||||
serde_json::json!({
|
||||
"arn": arn,
|
||||
"type": "replication",
|
||||
"endpoint": "203.0.113.1:9000",
|
||||
"credentials": { "accessKey": "other-access", "secretKey": "other-secret" },
|
||||
"targetbucket": "elsewhere",
|
||||
"secure": false,
|
||||
"replicationSync": false
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(response.status(), StatusCode::OK, "no-op update failed: {}", response.text().await?);
|
||||
|
||||
let target = fetch_single_target(&source_env, source_bucket).await?;
|
||||
assert_eq!(
|
||||
target["replicationSync"],
|
||||
serde_json::json!(true),
|
||||
"no-op update must not change sync mode"
|
||||
);
|
||||
assert_eq!(
|
||||
target["endpoint"],
|
||||
serde_json::json!(target_env.address),
|
||||
"no-op update must not change endpoint"
|
||||
);
|
||||
assert_eq!(
|
||||
target["credentials"]["accessKey"],
|
||||
serde_json::json!(target_env.access_key),
|
||||
"no-op update must not change credentials"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_set_remote_target_rejects_invalid_target_url() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
@@ -3714,7 +3975,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
<Role></Role>
|
||||
<Rule>
|
||||
<ID>matrix-prefix</ID>
|
||||
<Priority>100</Priority>
|
||||
<Priority>110</Priority>
|
||||
<Status>Enabled</Status>
|
||||
<Filter><Prefix>prefix/</Prefix></Filter>
|
||||
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
|
||||
@@ -3725,7 +3986,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>matrix-both-prefix</ID>
|
||||
<Priority>100</Priority>
|
||||
<Priority>120</Priority>
|
||||
<Status>Enabled</Status>
|
||||
<Filter><Prefix>both/</Prefix></Filter>
|
||||
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
|
||||
@@ -3735,7 +3996,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>matrix-tag</ID>
|
||||
<Priority>100</Priority>
|
||||
<Priority>130</Priority>
|
||||
<Status>Enabled</Status>
|
||||
<Filter><Tag><Key>route</Key><Value>tagged</Value></Tag></Filter>
|
||||
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
|
||||
@@ -3745,7 +4006,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>matrix-disabled</ID>
|
||||
<Priority>100</Priority>
|
||||
<Priority>140</Priority>
|
||||
<Status>Disabled</Status>
|
||||
<Filter><Prefix>disabled/</Prefix></Filter>
|
||||
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
|
||||
@@ -4227,16 +4488,76 @@ async fn test_bucket_replication_sse_c_contract() -> TestResult {
|
||||
}
|
||||
|
||||
/// backlog#1147 repl-17 / backlog#1291: SSE-S3 must fail closed until managed
|
||||
/// encryption is supported on the target. The current plaintext replication is
|
||||
/// a known security bug, so this pins the required contract without blessing it.
|
||||
/// encryption is supported on the target. The silent plaintext replication
|
||||
/// that originally kept this test ignored was fixed by the fail-closed gate in
|
||||
/// `crates/ecstore/src/bucket/replication/replication_target_boundary.rs`
|
||||
/// (all replication modes route through it), so this now pins the current
|
||||
/// fail-closed contract: FAILED status, failure event, readable source, and a
|
||||
/// stable absence of all target versions.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "backlog#1291: SSE-S3 replication silently drops encryption"]
|
||||
async fn test_bucket_replication_sse_s3_contract() -> TestResult {
|
||||
init_logging();
|
||||
assert_managed_sse_replication_fails_explicitly("sse-s3", false).await
|
||||
}
|
||||
|
||||
/// P1-22 stage 0: the existing-object resync path must fail closed for
|
||||
/// managed-SSE objects exactly like inline replication (which
|
||||
/// `test_bucket_replication_sse_s3_contract` pins, including the scanner heal
|
||||
/// re-drive). Resync re-drives every object version through the same
|
||||
/// fail-closed target boundary, so a resync over an encrypted bucket must
|
||||
/// terminate without ever materializing a plaintext (or unreadable) replica;
|
||||
/// the post-resync stays-absent window also spans further fast-scanner heal
|
||||
/// cycles.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_sse_s3_resync_stays_fail_closed() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let (source_env, target_env, source_bucket, target_bucket) = build_sse_replication_pair("sse-resync", true).await?;
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
let key = "sse-resync-contract.txt";
|
||||
let body = b"repl-22 sse resync payload".to_vec();
|
||||
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(&source_bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.send()
|
||||
.await?;
|
||||
wait_for_source_replication_status(&source_client, &source_bucket, key, "FAILED", false).await?;
|
||||
|
||||
// Resync: drive the existing-object resync path over the failed object.
|
||||
let (target_arn, reset_id) = start_bucket_replication_reset(&source_env, &source_bucket).await?;
|
||||
let terminal = wait_for_replication_reset_target(&source_env, &source_bucket, &target_arn, |target| {
|
||||
target.reset_id == reset_id && matches!(target.status.as_str(), "Completed" | "Failed")
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(terminal.reset_id, reset_id);
|
||||
|
||||
// The resync pass must have failed closed: still no target version (the
|
||||
// window also spans further scanner heal cycles), and the source object
|
||||
// stays readable and encrypted.
|
||||
assert_failed_replication_stays_absent_for(
|
||||
&source_client,
|
||||
&source_bucket,
|
||||
&target_client,
|
||||
&target_bucket,
|
||||
key,
|
||||
false,
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.await?;
|
||||
let source = source_client.get_object().bucket(&source_bucket).key(key).send().await?;
|
||||
assert_eq!(source.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||
assert_eq!(source.body.collect().await?.into_bytes().as_ref(), body.as_slice());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// backlog#1147 repl-17: SSE-KMS currently fails closed rather than creating an
|
||||
/// unreadable replica; the shared helper verifies FAILED, the failure event,
|
||||
/// source readability, and a stable absence of all target versions.
|
||||
@@ -6517,3 +6838,140 @@ async fn test_site_replication_replicates_service_accounts_created_from_sts_sess
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll the fake target journal until `operation` arrives for `key`, then
|
||||
/// return the `versionId` query value the request carried.
|
||||
async fn wait_for_target_request_version_id(
|
||||
target: &FakeS3Target,
|
||||
operation: FakeTargetOperation,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if let Some(record) = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.operation == operation && record.key.as_deref() == Some(key))
|
||||
{
|
||||
return Ok(record.version_id);
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("fake target never received {operation:?} for {key}; journal: {:?}", target.requests()).into());
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// P0-5: MinIO derives the replicated version exclusively from the `versionId`
|
||||
/// query parameter (`putOptsFromReq`); the internal x-*-source-version-id
|
||||
/// headers do not exist there. Without the query, a MinIO target mints fresh
|
||||
/// version ids and RustFS -> MinIO replication drifts. PutObject and
|
||||
/// CreateMultipartUpload (the version is decided at initiate time) must both
|
||||
/// carry the source version as `?versionId=`.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_replication_put_and_create_multipart_carry_source_version_id_query() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let target = FakeS3Target::start().await?;
|
||||
let target_bucket = "versionid-query-dst";
|
||||
target.create_bucket(target_bucket);
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut source_process_env = replication_fast_env();
|
||||
source_process_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
source_process_env.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
|
||||
source_env.start_rustfs_server_with_env(vec![], &source_process_env).await?;
|
||||
|
||||
let source_bucket = "versionid-query-src";
|
||||
let source_client = source_env.create_s3_client();
|
||||
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||
|
||||
let target_arn = set_replication_target_with_options(
|
||||
&source_env,
|
||||
source_bucket,
|
||||
ReplicationTargetOptions {
|
||||
endpoint: &target.address(),
|
||||
access_key: FAKE_ACCESS_KEY,
|
||||
secret_key: FAKE_SECRET_KEY,
|
||||
target_bucket,
|
||||
secure: false,
|
||||
skip_tls_verify: false,
|
||||
ca_cert_pem: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||
|
||||
// Small object -> replicated through a single PutObject.
|
||||
let put = source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("small.txt")
|
||||
.body(ByteStream::from_static(b"versionid query payload"))
|
||||
.send()
|
||||
.await?;
|
||||
let put_source_version = put
|
||||
.version_id()
|
||||
.ok_or("versioned source PUT must return a version id")?
|
||||
.to_string();
|
||||
let recorded = wait_for_target_request_version_id(&target, FakeTargetOperation::PutObject, "small.txt").await?;
|
||||
assert_eq!(
|
||||
recorded.as_deref(),
|
||||
Some(put_source_version.as_str()),
|
||||
"replication PutObject must carry the source version in the versionId query"
|
||||
);
|
||||
|
||||
// Multipart source object -> replicated through CreateMultipartUpload;
|
||||
// the target version is fixed at initiate time.
|
||||
let create = source_client
|
||||
.create_multipart_upload()
|
||||
.bucket(source_bucket)
|
||||
.key("large.bin")
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = create
|
||||
.upload_id()
|
||||
.ok_or("multipart initiate must return an upload id")?
|
||||
.to_string();
|
||||
let mut completed_parts = Vec::new();
|
||||
for (part_number, body) in [(1, vec![b'a'; 5 * 1024 * 1024]), (2, vec![b'b'; 1024])] {
|
||||
let uploaded = source_client
|
||||
.upload_part()
|
||||
.bucket(source_bucket)
|
||||
.key("large.bin")
|
||||
.upload_id(&upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from(body))
|
||||
.send()
|
||||
.await?;
|
||||
completed_parts.push(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.e_tag(uploaded.e_tag().unwrap_or_default())
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
let complete = source_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(source_bucket)
|
||||
.key("large.bin")
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
.send()
|
||||
.await?;
|
||||
let multipart_source_version = complete
|
||||
.version_id()
|
||||
.ok_or("versioned multipart completion must return a version id")?
|
||||
.to_string();
|
||||
let recorded = wait_for_target_request_version_id(&target, FakeTargetOperation::CreateMultipartUpload, "large.bin").await?;
|
||||
assert_eq!(
|
||||
recorded.as_deref(),
|
||||
Some(multipart_source_version.as_str()),
|
||||
"replication CreateMultipartUpload must carry the source version in the versionId query"
|
||||
);
|
||||
|
||||
target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,13 +12,10 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, admin_ok, init_logging};
|
||||
use aws_sdk_sts::config::retry::RetryConfig;
|
||||
use aws_sdk_sts::config::{Credentials, Region};
|
||||
use crate::common::{RustFSTestEnvironment, admin_ok, build_test_s3_config, build_test_sts_client, init_logging};
|
||||
use aws_sdk_sts::Client;
|
||||
use aws_sdk_sts::error::ProvideErrorMetadata;
|
||||
use aws_sdk_sts::operation::RequestId;
|
||||
use aws_sdk_sts::{Client, Config};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use bytes::Bytes;
|
||||
use http::header::{AUTHORIZATION, CONTENT_TYPE};
|
||||
use http::{Request, Response};
|
||||
@@ -32,9 +29,8 @@ use serial_test::serial;
|
||||
use std::collections::BTreeSet;
|
||||
use std::convert::Infallible;
|
||||
use std::error::Error;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
@@ -43,22 +39,7 @@ type TestResult = Result<(), BoxError>;
|
||||
const OPA_AUTH_TOKEN: &str = "sts-opa-token";
|
||||
|
||||
fn sts_client(url: &str, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client {
|
||||
let mut config = Config::builder()
|
||||
.credentials_provider(Credentials::new(
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token.map(str::to_owned),
|
||||
None,
|
||||
"e2e-sts-query-compat",
|
||||
))
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(url)
|
||||
.retry_config(RetryConfig::standard().with_max_attempts(1))
|
||||
.behavior_version_latest();
|
||||
if url.starts_with("http://") {
|
||||
config = config.http_client(SmithyHttpClientBuilder::new().build_http());
|
||||
}
|
||||
Client::from_conf(config.build())
|
||||
build_test_sts_client(url, access_key, secret_key, session_token, "e2e-sts-query-compat")
|
||||
}
|
||||
|
||||
async fn create_root_service_account(env: &RustFSTestEnvironment) -> Result<(String, String), BoxError> {
|
||||
@@ -145,6 +126,52 @@ async fn assert_access_denied(client: &Client, context: &str) -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_list_buckets_access_denied(
|
||||
env: &RustFSTestEnvironment,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
context: &str,
|
||||
) -> TestResult {
|
||||
let error = aws_sdk_s3::Client::from_conf(build_test_s3_config(
|
||||
&env.url,
|
||||
access_key,
|
||||
secret_key,
|
||||
None,
|
||||
"e2e-list-buckets-opa-unavailable",
|
||||
))
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await
|
||||
.expect_err("ListBuckets must be denied while OPA is unavailable");
|
||||
let service_error = error
|
||||
.as_service_error()
|
||||
.ok_or_else(|| format!("{context} should deserialize as an S3 service error: {error:?}"))?;
|
||||
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||
assert_eq!(service_error.code(), Some("AccessDenied"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_opa_unavailable_denies_sts_and_list_buckets(env: &RustFSTestEnvironment, context: &str) -> TestResult {
|
||||
let user = "opaunavailable";
|
||||
let secret = "stsOpaUnavailableSecret123";
|
||||
create_user_with_policy(
|
||||
env,
|
||||
user,
|
||||
secret,
|
||||
"sts-opa-unavailable-local-policy",
|
||||
serde_json::json!([{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListAllMyBuckets"],
|
||||
"Resource": ["arn:aws:s3:::*"],
|
||||
}]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_access_denied(&sts_client(&env.url, user, secret, None), context).await?;
|
||||
assert_list_buckets_access_denied(env, user, secret, context).await
|
||||
}
|
||||
|
||||
async fn handle_opa_request(
|
||||
request: Request<Incoming>,
|
||||
requests: mpsc::UnboundedSender<Value>,
|
||||
@@ -186,12 +213,15 @@ async fn handle_opa_request(
|
||||
};
|
||||
if payload.is_none() {
|
||||
let _ = validation_started.send(());
|
||||
if let OpaValidationMode::DelayedUnavailable(release) = validation_mode {
|
||||
release.notified().await;
|
||||
return Ok(Response::builder()
|
||||
.status(503)
|
||||
.body(Full::new(Bytes::new()))
|
||||
.expect("static OPA unavailable response must be valid"));
|
||||
match validation_mode {
|
||||
OpaValidationMode::Blocked => std::future::pending::<()>().await,
|
||||
OpaValidationMode::Unavailable => {
|
||||
return Ok(Response::builder()
|
||||
.status(503)
|
||||
.body(Full::new(Bytes::new()))
|
||||
.expect("static OPA unavailable response must be valid"));
|
||||
}
|
||||
OpaValidationMode::Ready => {}
|
||||
}
|
||||
}
|
||||
let allow = match payload.as_ref().and_then(|value| value.pointer("/input/identity/account")) {
|
||||
@@ -201,6 +231,25 @@ async fn handle_opa_request(
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
Some(Value::String(account)) if account == "opadeny" => false,
|
||||
Some(Value::String(account))
|
||||
if account == "opaunavailable" && matches!(validation_mode, OpaValidationMode::Unavailable) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
Some(Value::String(account)) if account == "opalistbuckets" => {
|
||||
let action = payload
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/input/action"))
|
||||
.and_then(Value::as_str);
|
||||
let bucket = payload
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/input/resource/bucket"))
|
||||
.and_then(Value::as_str);
|
||||
matches!(
|
||||
(action, bucket),
|
||||
(Some("s3:ListBucket"), Some("opa-list-visible")) | (Some("s3:GetBucketLocation"), Some("opa-list-location"))
|
||||
)
|
||||
}
|
||||
None => true,
|
||||
_ => false,
|
||||
};
|
||||
@@ -215,17 +264,17 @@ async fn handle_opa_request(
|
||||
.expect("static OPA response must be valid"))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Copy)]
|
||||
enum OpaValidationMode {
|
||||
Ready,
|
||||
DelayedUnavailable(Arc<Notify>),
|
||||
Blocked,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
struct OpaMock {
|
||||
url: String,
|
||||
requests: mpsc::UnboundedReceiver<Value>,
|
||||
validation_started: mpsc::UnboundedReceiver<()>,
|
||||
validation_release: Option<Arc<Notify>>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -234,9 +283,8 @@ impl OpaMock {
|
||||
Self::start_with_mode(OpaValidationMode::Ready, Some(OPA_AUTH_TOKEN)).await
|
||||
}
|
||||
|
||||
async fn start_delayed_unavailable() -> Result<Self, BoxError> {
|
||||
let release = Arc::new(Notify::new());
|
||||
Self::start_with_mode(OpaValidationMode::DelayedUnavailable(release), None).await
|
||||
async fn start_blocked() -> Result<Self, BoxError> {
|
||||
Self::start_with_mode(OpaValidationMode::Blocked, None).await
|
||||
}
|
||||
|
||||
async fn start_with_mode(validation_mode: OpaValidationMode, auth_token: Option<&str>) -> Result<Self, BoxError> {
|
||||
@@ -245,10 +293,6 @@ impl OpaMock {
|
||||
let (requests_tx, requests) = mpsc::unbounded_channel();
|
||||
let (validation_started_tx, validation_started) = mpsc::unbounded_channel();
|
||||
let expected_authorization = auth_token.map(|token| format!("Bearer {token}"));
|
||||
let validation_release = match &validation_mode {
|
||||
OpaValidationMode::Ready => None,
|
||||
OpaValidationMode::DelayedUnavailable(release) => Some(Arc::clone(release)),
|
||||
};
|
||||
let task = tokio::spawn(async move {
|
||||
let mut connections = JoinSet::new();
|
||||
loop {
|
||||
@@ -257,7 +301,7 @@ impl OpaMock {
|
||||
let Ok((stream, _)) = accepted else { break };
|
||||
let requests = requests_tx.clone();
|
||||
let validation_started = validation_started_tx.clone();
|
||||
let validation_mode = validation_mode.clone();
|
||||
let validation_mode = validation_mode;
|
||||
let expected_authorization = expected_authorization.clone();
|
||||
connections.spawn(async move {
|
||||
let handler = service_fn(move |request| {
|
||||
@@ -265,7 +309,7 @@ impl OpaMock {
|
||||
request,
|
||||
requests.clone(),
|
||||
validation_started.clone(),
|
||||
validation_mode.clone(),
|
||||
validation_mode,
|
||||
expected_authorization.clone(),
|
||||
)
|
||||
});
|
||||
@@ -282,7 +326,6 @@ impl OpaMock {
|
||||
url,
|
||||
requests,
|
||||
validation_started,
|
||||
validation_release,
|
||||
task,
|
||||
})
|
||||
}
|
||||
@@ -298,12 +341,6 @@ impl OpaMock {
|
||||
.await?
|
||||
.ok_or_else(|| "OPA validation channel closed".into())
|
||||
}
|
||||
|
||||
fn release_validation(&self) {
|
||||
if let Some(release) = &self.validation_release {
|
||||
release.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OpaMock {
|
||||
@@ -523,35 +560,119 @@ async fn test_sts_assume_role_opa_contract() -> TestResult {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_sts_assume_role_fails_closed_while_opa_is_unavailable() -> TestResult {
|
||||
async fn test_list_buckets_opa_contract() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut opa = OpaMock::start_delayed_unavailable().await?;
|
||||
let mut opa = OpaMock::start().await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str()),
|
||||
("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", OPA_AUTH_TOKEN),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
for bucket in ["opa-list-hidden", "opa-list-location", "opa-list-visible"] {
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
}
|
||||
|
||||
let user = "opalistbuckets";
|
||||
let secret = "opaListBucketsSecret123";
|
||||
create_user(&env, user, secret).await?;
|
||||
|
||||
let output = aws_sdk_s3::Client::from_conf(build_test_s3_config(&env.url, user, secret, None, "e2e-list-buckets-opa"))
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await?;
|
||||
let mut names = output
|
||||
.buckets()
|
||||
.iter()
|
||||
.filter_map(|bucket| bucket.name().map(str::to_owned))
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
assert_eq!(names, ["opa-list-location", "opa-list-visible"]);
|
||||
|
||||
let mut evaluations = BTreeSet::new();
|
||||
for _ in 0..6 {
|
||||
let request = opa.next_request().await?;
|
||||
assert_eq!(request.pointer("/input/identity/account").and_then(Value::as_str), Some(user));
|
||||
assert_eq!(request.pointer("/input/context/deny_only").and_then(Value::as_bool), Some(false));
|
||||
|
||||
let action = request
|
||||
.pointer("/input/action")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("OPA ListBuckets input should include action")?;
|
||||
let bucket = request
|
||||
.pointer("/input/resource/bucket")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("OPA ListBuckets input should include resource.bucket")?;
|
||||
if bucket.is_empty() {
|
||||
assert_eq!(action, "s3:ListAllMyBuckets");
|
||||
assert!(request.pointer("/input/context/conditions/prefix").is_none());
|
||||
assert!(request.pointer("/input/context/conditions/delimiter").is_none());
|
||||
} else {
|
||||
let expected_arn = format!("arn:aws:s3:::{bucket}");
|
||||
assert_eq!(request.pointer("/input/context/conditions/prefix"), Some(&serde_json::json!([""])));
|
||||
assert_eq!(request.pointer("/input/context/conditions/delimiter"), Some(&serde_json::json!(["/"])));
|
||||
assert_eq!(
|
||||
request.pointer("/input/resource/arn").and_then(Value::as_str),
|
||||
Some(expected_arn.as_str())
|
||||
);
|
||||
}
|
||||
evaluations.insert((action.to_owned(), bucket.to_owned()));
|
||||
}
|
||||
assert_eq!(
|
||||
evaluations,
|
||||
BTreeSet::from([
|
||||
("s3:GetBucketLocation".to_owned(), "opa-list-hidden".to_owned()),
|
||||
("s3:GetBucketLocation".to_owned(), "opa-list-location".to_owned()),
|
||||
("s3:ListAllMyBuckets".to_owned(), String::new()),
|
||||
("s3:ListBucket".to_owned(), "opa-list-hidden".to_owned()),
|
||||
("s3:ListBucket".to_owned(), "opa-list-location".to_owned()),
|
||||
("s3:ListBucket".to_owned(), "opa-list-visible".to_owned()),
|
||||
])
|
||||
);
|
||||
assert!(
|
||||
matches!(opa.requests.try_recv(), Err(mpsc::error::TryRecvError::Empty)),
|
||||
"ListBuckets should not make redundant OPA evaluations"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_sts_and_list_buckets_fail_closed_while_opa_is_initializing() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut opa = OpaMock::start_blocked().await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str())])
|
||||
.await?;
|
||||
opa.wait_for_validation().await?;
|
||||
|
||||
let user = "opaunavailable";
|
||||
let secret = "stsOpaUnavailableSecret123";
|
||||
create_user_with_policy(
|
||||
&env,
|
||||
user,
|
||||
secret,
|
||||
"sts-opa-unavailable-local-policy",
|
||||
serde_json::json!([{
|
||||
"Effect": "Allow",
|
||||
"Action": ["s3:ListAllMyBuckets"],
|
||||
"Resource": ["arn:aws:s3:::*"],
|
||||
}]),
|
||||
)
|
||||
.await?;
|
||||
assert_opa_unavailable_denies_sts_and_list_buckets(&env, "configured OPA initialization").await?;
|
||||
|
||||
assert_access_denied(&sts_client(&env.url, user, secret, None), "configured OPA initialization").await?;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
opa.release_validation();
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_access_denied(&sts_client(&env.url, user, secret, None), "configured OPA validation failure").await?;
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_sts_and_list_buckets_fail_closed_after_opa_validation_failure() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut opa = OpaMock::start_with_mode(OpaValidationMode::Unavailable, None).await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str())])
|
||||
.await?;
|
||||
opa.wait_for_validation().await?;
|
||||
|
||||
assert_opa_unavailable_denies_sts_and_list_buckets(&env, "configured OPA validation failure").await?;
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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.
|
||||
|
||||
//! Regression tests for Tier/ILM transition operations.
|
||||
//!
|
||||
//! Covers the recurring pattern where tier transition fails silently, the
|
||||
//! free-version recovery task loops forever, or transitioned objects cannot
|
||||
//! be read back. This has regressed 6+ times.
|
||||
//!
|
||||
//! ## Regression Issues
|
||||
//!
|
||||
//! - rustfs#5218: Remote tier mutation commit failed
|
||||
//! - rustfs#5130: tier_free_version_recovery task loops forever
|
||||
//! - rustfs#5011: Idle tier free-version recovery rescans every 60 seconds
|
||||
//! - rustfs#4826: Full GET of multipart transitioned object fails
|
||||
//! - rustfs#5024: Some files succeeded in tier offloading, others failed
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, admin_ok, init_logging};
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// RT-13: Verify lifecycle rule with transition persists and is retrievable.
|
||||
///
|
||||
/// Note: Actual transition requires a configured remote tier. This test
|
||||
/// validates that an expiration-only rule (the persistence path) survives
|
||||
/// a server restart.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_lifecycle_rule_persists_after_restart() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-13: lifecycle rule persists after restart");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "rt13-tier-persist";
|
||||
|
||||
client.create_bucket().bucket(bucket).send().await.expect("create bucket");
|
||||
|
||||
// Apply a lifecycle rule with expiration (transition needs a real tier)
|
||||
let rule = aws_sdk_s3::types::LifecycleRule::builder()
|
||||
.id("expire-after-90d")
|
||||
.status(aws_sdk_s3::types::ExpirationStatus::Enabled)
|
||||
.filter(aws_sdk_s3::types::LifecycleRuleFilter::builder().prefix("archive/").build())
|
||||
.expiration(aws_sdk_s3::types::LifecycleExpiration::builder().days(90).build())
|
||||
.build()
|
||||
.expect("build rule");
|
||||
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.lifecycle_configuration(
|
||||
aws_sdk_s3::types::BucketLifecycleConfiguration::builder()
|
||||
.rules(rule)
|
||||
.build()
|
||||
.expect("build config"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("put lifecycle");
|
||||
|
||||
// Restart server
|
||||
env.restart_server_preserving_data(vec![], &[]).await.expect("restart RustFS");
|
||||
|
||||
// Verify the rule survived restart
|
||||
let resp = client
|
||||
.get_bucket_lifecycle_configuration()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("get lifecycle after restart");
|
||||
|
||||
let rules = resp.rules();
|
||||
assert_eq!(rules.len(), 1, "RT-13 FAIL: expected 1 rule after restart");
|
||||
|
||||
let exp = rules[0].expiration().expect("expiration should be set");
|
||||
assert_eq!(exp.days(), Some(90), "RT-13 FAIL: expiration days corrupted after restart");
|
||||
|
||||
info!("RT-13 PASS: lifecycle rule persists after restart");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-13b: Verify admin tier configuration API is functional.
|
||||
///
|
||||
/// Regression pattern: tier add/verify/delete API fails or the tier
|
||||
/// configuration is not persisted (rustfs#5218).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_admin_tier_list_endpoint_returns_json() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-13b: admin tier list endpoint returns JSON");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
// Query the tier list endpoint
|
||||
let body = admin_ok(&env, http::Method::GET, "/rustfs/admin/v3/tier", None)
|
||||
.await
|
||||
.expect("list remote tiers");
|
||||
|
||||
let json: Value = serde_json::from_str(&body).expect("tier list response should be valid JSON");
|
||||
|
||||
// Should return an array (possibly empty)
|
||||
assert!(json.is_array(), "RT-13b FAIL: tier list response is not an array: {json}");
|
||||
|
||||
info!("RT-13b PASS: admin tier list endpoint returns valid JSON array");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-13c: Verify scanner configuration persistence.
|
||||
///
|
||||
/// Regression pattern: scanner admin config update reports success but
|
||||
/// is not persisted (rustfs#5013), causing the scanner to not run or
|
||||
/// use stale settings.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_scanner_config_persists_after_restart() -> TestResult {
|
||||
init_logging();
|
||||
info!("RT-13c: scanner config persists after restart");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_CONSOLE_ENABLE", "false")])
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
// Get current scanner status
|
||||
let body = admin_ok(&env, http::Method::GET, "/rustfs/admin/v3/scanner/status", None)
|
||||
.await
|
||||
.expect("get scanner status");
|
||||
|
||||
let json: Value = serde_json::from_str(&body).expect("scanner status should be valid JSON");
|
||||
|
||||
info!(" scanner status: {:?}", json.as_object().map(|o| o.keys().collect::<Vec<_>>()));
|
||||
|
||||
// Restart and verify config is still accessible
|
||||
env.restart_server_preserving_data(vec![], &[]).await.expect("restart RustFS");
|
||||
|
||||
let body2 = admin_ok(&env, http::Method::GET, "/rustfs/admin/v3/scanner/status", None)
|
||||
.await
|
||||
.expect("get scanner status after restart");
|
||||
|
||||
let json2: Value = serde_json::from_str(&body2).expect("scanner status after restart should be valid JSON");
|
||||
|
||||
// Both should be valid JSON objects
|
||||
assert!(json2.is_object(), "RT-13c FAIL: scanner status after restart is not a valid JSON object");
|
||||
|
||||
info!("RT-13c PASS: scanner/config persists across restart");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -144,11 +144,13 @@ rustfs-lifecycle.workspace = true
|
||||
rustfs-s3-types = { workspace = true }
|
||||
rustfs-data-usage.workspace = true
|
||||
rustfs-object-capacity.workspace = true
|
||||
rustfs-object-data-cache = { workspace = true, features = ["runtime-memory"] }
|
||||
arc-swap.workspace = true
|
||||
async-trait.workspace = true
|
||||
bytes = { workspace = true, features = ["serde"] }
|
||||
byteorder = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
jiff = { workspace = true, features = ["serde"] }
|
||||
glob = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
flatbuffers.workspace = true
|
||||
|
||||
@@ -130,13 +130,15 @@ pub mod bucket {
|
||||
|
||||
pub mod metadata_sys {
|
||||
pub use crate::bucket::metadata_sys::{
|
||||
BucketMetadataSys, acquire_bucket_metadata_transaction_lock, delete, get, get_accelerate_config, get_bucket_policy,
|
||||
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, get, get_accelerate_config, get_bucket_policy,
|
||||
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
||||
get_object_lock_config, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
||||
update, update_bucket_targets_under_transaction_lock, update_config_with, update_under_transaction_lock,
|
||||
get_object_lock_config, get_object_lock_config_state, get_public_access_block_config, get_quota_config,
|
||||
get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config,
|
||||
get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
|
||||
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||
update_under_transaction_lock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,19 +185,19 @@ pub mod bucket {
|
||||
MustReplicateOptions, ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS,
|
||||
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATE_INCOMING_DELETE, REPLICATION_CAPABILITY_CONTRACT_VERSION,
|
||||
REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, ReplicateDecision, ReplicateObjectInfo,
|
||||
ReplicationBatchAdmission, ReplicationConfig, ReplicationConfigurationExt, ReplicationDeleteScheduleInput,
|
||||
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
||||
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
||||
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
||||
ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, TargetReplicationResyncStatus,
|
||||
VersionPurgeStatusType, commit_force_delete_intent, complete_force_delete_intent,
|
||||
ReplicationBatchAdmission, ReplicationConfig, ReplicationConfigStructureError, ReplicationConfigurationExt,
|
||||
ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge,
|
||||
ReplicationObjectIO, ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission,
|
||||
ReplicationScannerBridge, ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage,
|
||||
ReplicationTargetValidationError, ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog,
|
||||
TargetReplicationResyncStatus, VersionPurgeStatusType, commit_force_delete_intent, complete_force_delete_intent,
|
||||
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
|
||||
get_global_replication_stats, init_background_replication, invalid_replication_config_status_field,
|
||||
persist_force_delete_intent, read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta,
|
||||
replication_statuses_map, replication_target_arns, resync_start_conflict_id, should_remove_replication_target,
|
||||
should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
||||
should_use_existing_delete_replication_source, unsupported_replication_config_field,
|
||||
validate_replication_config_target_arns, version_purge_status_to_filemeta,
|
||||
validate_replication_config_structure, validate_replication_config_target_arns, version_purge_status_to_filemeta,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,7 +206,9 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod target {
|
||||
pub use crate::bucket::target::{ARN, BucketTarget, BucketTargetType, BucketTargets, Credentials, LatencyStat};
|
||||
pub use crate::bucket::target::{
|
||||
ARN, BucketTarget, BucketTargetType, BucketTargets, Credentials, LatencyStat, duration_from_secs_or_nanos,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod utils {
|
||||
@@ -306,7 +310,8 @@ pub mod config {
|
||||
pub mod data_usage {
|
||||
pub use crate::data_usage::{
|
||||
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
||||
init_compression_total_memory_from_backend, invalidate_data_usage_snapshot_cache, live_bucket_usage_computations,
|
||||
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
||||
invalidate_data_usage_snapshot_cache, live_bucket_usage_computations, load_admin_data_usage_from_backend_cached,
|
||||
load_compression_total_from_memory, load_data_usage_from_backend, load_data_usage_from_backend_cached,
|
||||
record_bucket_delete_marker_memory, record_bucket_object_delete_memory, record_bucket_object_version_write_memory,
|
||||
record_bucket_object_write_memory, record_bucket_object_write_unknown_previous_memory, record_compression_total_memory,
|
||||
@@ -403,11 +408,11 @@ pub mod notification {
|
||||
pub mod object {
|
||||
pub use crate::object_api::{
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, ObjectEncryptionResolver, ObjectInfo,
|
||||
ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode,
|
||||
ReadEncryptionRequest, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
|
||||
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
|
||||
unregister_object_mutation_hook,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, NamespaceLockFence, ObjectEncryptionResolver,
|
||||
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader,
|
||||
ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest, StreamConsumer, get_object_body_cache_plaintext_len,
|
||||
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
|
||||
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
};
|
||||
pub use crate::store::PreparedGetObjectReader;
|
||||
}
|
||||
@@ -435,7 +440,7 @@ pub mod rpc {
|
||||
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, gen_tonic_signature_interceptor,
|
||||
node_service_time_out_client, node_service_time_out_client_no_auth, normalize_tonic_rpc_audience,
|
||||
set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge,
|
||||
tonic_boot_epoch_response_headers, verify_rpc_signature, verify_tonic_boot_epoch_response,
|
||||
tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason, verify_rpc_signature, verify_tonic_boot_epoch_response,
|
||||
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof,
|
||||
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
};
|
||||
|
||||
@@ -1424,6 +1424,37 @@ fn resolve_delete_api_version_id(version_id: Option<String>, opts: &RemoveObject
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the S3 `versionId` query parameter for a replication PUT /
|
||||
/// CreateMultipartUpload against a remote target.
|
||||
///
|
||||
/// MinIO reads the replicated version only from the query string
|
||||
/// (`putOptsFromReq`); the internal `x-*-source-version-id` headers do not
|
||||
/// exist there, so without the query a MinIO target mints fresh version ids
|
||||
/// and the deployments drift apart. RustFS represents the null version
|
||||
/// internally as the nil UUID while the S3 API addresses it as the literal
|
||||
/// "null" (the delete path already maps it via `target_delete_version_id`),
|
||||
/// and an empty id means the source object carries no version: send no query
|
||||
/// so an unversioned target stays valid.
|
||||
fn resolve_put_api_version_id(source_version_id: &str) -> Option<&str> {
|
||||
if source_version_id.is_empty() {
|
||||
None
|
||||
} else if Uuid::parse_str(source_version_id).is_ok_and(|uuid| uuid.is_nil()) {
|
||||
Some(rustfs_filemeta::NULL_VERSION_ID)
|
||||
} else {
|
||||
Some(source_version_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `versionId=<id>` to an already-built request URI. aws-sdk-s3's
|
||||
/// `PutObjectInput` / `CreateMultipartUploadInput` expose no version id
|
||||
/// member, so the query is spliced in via `map_request`, which runs at
|
||||
/// `modify_before_signing`: the parameter becomes part of the SigV4 canonical
|
||||
/// request.
|
||||
fn append_version_id_query(uri: &str, version_id: &str) -> String {
|
||||
let separator = if uri.contains('?') { '&' } else { '?' };
|
||||
format!("{uri}{separator}versionId={}", urlencoding::encode(version_id))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdvancedPutOptions {
|
||||
pub source_version_id: String,
|
||||
@@ -1831,6 +1862,7 @@ impl TargetClient {
|
||||
if !version_id.is_empty() {
|
||||
insert_header(&mut headers, SUFFIX_SOURCE_VERSION_ID, &version_id);
|
||||
}
|
||||
let api_version_id = resolve_put_api_version_id(&version_id).map(ToOwned::to_owned);
|
||||
|
||||
match builder
|
||||
.bucket(bucket)
|
||||
@@ -1845,6 +1877,11 @@ impl TargetClient {
|
||||
req.headers_mut().insert(key_str, value_str);
|
||||
}
|
||||
}
|
||||
if let Some(version_id) = &api_version_id {
|
||||
let uri = append_version_id_query(req.uri(), version_id);
|
||||
req.set_uri(uri)
|
||||
.map_err(aws_smithy_types::error::operation::BuildError::other)?;
|
||||
}
|
||||
|
||||
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
|
||||
})
|
||||
@@ -1893,6 +1930,9 @@ impl TargetClient {
|
||||
if opts.internal.replication_request {
|
||||
insert_header(&mut headers, SUFFIX_SOURCE_REPLICATION_REQUEST, "true");
|
||||
}
|
||||
// The remote version of a multipart replication is decided at initiate
|
||||
// time; CompleteMultipartUpload does not read a versionId.
|
||||
let api_version_id = resolve_put_api_version_id(&version_id).map(ToOwned::to_owned);
|
||||
|
||||
match self
|
||||
.client
|
||||
@@ -1907,6 +1947,11 @@ impl TargetClient {
|
||||
req.headers_mut().insert(key_str, value_str);
|
||||
}
|
||||
}
|
||||
if let Some(version_id) = &api_version_id {
|
||||
let uri = append_version_id_query(req.uri(), version_id);
|
||||
req.set_uri(uri)
|
||||
.map_err(aws_smithy_types::error::operation::BuildError::other)?;
|
||||
}
|
||||
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
|
||||
})
|
||||
.send()
|
||||
@@ -2000,7 +2045,7 @@ impl TargetClient {
|
||||
object: &str,
|
||||
version_id: Option<String>,
|
||||
opts: RemoveObjectOptions,
|
||||
) -> Result<(), S3ClientError> {
|
||||
) -> Result<Option<String>, S3ClientError> {
|
||||
let headers = build_remove_object_headers(version_id.as_deref(), &opts);
|
||||
let api_version_id = resolve_delete_api_version_id(version_id, &opts);
|
||||
|
||||
@@ -2023,7 +2068,11 @@ impl TargetClient {
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_res) => Ok(()),
|
||||
// A DELETE without a version id on a versioned target creates a delete
|
||||
// marker and reports the version it assigned. That id is the only
|
||||
// reliable handle for purging the marker later: a generic S3 target
|
||||
// does not mirror source version ids.
|
||||
Ok(res) => Ok(res.version_id().map(ToOwned::to_owned)),
|
||||
Err(e) => match e {
|
||||
SdkError::ServiceError(service_err) => {
|
||||
let err = service_err.into_err();
|
||||
@@ -2675,6 +2724,91 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_object_sends_source_version_id_query_to_target() {
|
||||
// MinIO reads the replicated version only from the `versionId` query
|
||||
// parameter (its receive path ignores the x-*-source-version-id
|
||||
// headers), so the query must carry the source version: a real UUID
|
||||
// as-is, the internal nil-UUID null-version representation as the
|
||||
// literal "null", and no query at all when the source object has no
|
||||
// version (P0-5 RustFS->MinIO version drift).
|
||||
let (client, request_uris) = recording_target_client();
|
||||
let version_id = Uuid::new_v4().to_string();
|
||||
let nil_version = Uuid::nil().to_string();
|
||||
for source_version in [version_id.as_str(), nil_version.as_str(), ""] {
|
||||
let mut opts = PutObjectOptions::default();
|
||||
opts.internal.source_version_id = source_version.to_string();
|
||||
opts.internal.replication_request = true;
|
||||
client
|
||||
.put_object("target-bucket", "object", 4, ByteStream::from_static(b"data"), &opts)
|
||||
.await
|
||||
.expect("recorded put_object should succeed");
|
||||
}
|
||||
|
||||
let request_uris = request_uris.lock().expect("recorded request lock should not be poisoned");
|
||||
assert_eq!(request_uris.len(), 3);
|
||||
assert!(
|
||||
request_uris[0].contains(&format!("versionId={version_id}")),
|
||||
"replication put_object must carry the source version as a versionId query: {}",
|
||||
request_uris[0]
|
||||
);
|
||||
assert!(
|
||||
request_uris[1].contains("versionId=null"),
|
||||
"a nil-UUID (null) source version must be sent as the literal null: {}",
|
||||
request_uris[1]
|
||||
);
|
||||
assert!(
|
||||
!request_uris[2].contains("versionId="),
|
||||
"put_object without a source version must omit the versionId query: {}",
|
||||
request_uris[2]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_multipart_upload_sends_source_version_id_query_to_target() {
|
||||
// The remote version of a multipart replication is decided at initiate
|
||||
// time: CreateMultipartUpload must carry the source version in the
|
||||
// `versionId` query (CompleteMultipartUpload does not read one).
|
||||
let (client, request_uris) = recording_target_client();
|
||||
let version_id = Uuid::new_v4().to_string();
|
||||
let nil_version = Uuid::nil().to_string();
|
||||
for source_version in [version_id.as_str(), nil_version.as_str()] {
|
||||
let mut opts = PutObjectOptions::default();
|
||||
opts.internal.source_version_id = source_version.to_string();
|
||||
opts.internal.replication_request = true;
|
||||
let _ = client.create_multipart_upload("target-bucket", "object", &opts).await;
|
||||
}
|
||||
|
||||
let request_uris = request_uris.lock().expect("recorded request lock should not be poisoned");
|
||||
assert_eq!(request_uris.len(), 2);
|
||||
assert!(
|
||||
request_uris[0].contains(&format!("versionId={version_id}")),
|
||||
"replication create_multipart_upload must carry the source version as a versionId query: {}",
|
||||
request_uris[0]
|
||||
);
|
||||
assert!(
|
||||
request_uris[1].contains("versionId=null"),
|
||||
"a nil-UUID (null) source version must be sent as the literal null: {}",
|
||||
request_uris[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_object_headers_keep_source_version_id_for_legacy_receivers() {
|
||||
// Older RustFS receivers have no versionId query support and fall back
|
||||
// to the internal source-version-id headers (rolling-upgrade path);
|
||||
// the query addition must never remove them.
|
||||
let mut opts = PutObjectOptions::default();
|
||||
let version_id = Uuid::new_v4().to_string();
|
||||
opts.internal.source_version_id = version_id.clone();
|
||||
|
||||
assert_eq!(
|
||||
rustfs_utils::http::get_header(&opts.header(), SUFFIX_SOURCE_VERSION_ID).as_deref(),
|
||||
Some(version_id.as_str()),
|
||||
"replication put requests must keep the internal source-version-id headers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_object_headers_include_non_empty_source_etag_only() {
|
||||
let mut opts = PutObjectOptions::default();
|
||||
|
||||
@@ -42,6 +42,7 @@ use crate::bucket::lifecycle::tier_free_version_recovery::{
|
||||
use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
|
||||
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent_with_manager_and_identity};
|
||||
use crate::bucket::lifecycle::transition_transaction::run_transition_transaction_recovery_loop;
|
||||
use crate::bucket::object_lock::ObjectLockApi;
|
||||
use crate::bucket::versioning::VersioningApi as _;
|
||||
use crate::bucket::versioning_sys::BucketVersioningSys;
|
||||
use crate::client::object_api_utils::new_getobjectreader;
|
||||
@@ -90,8 +91,8 @@ use rustfs_utils::{
|
||||
string::{parse_bool, strings_has_prefix_fold},
|
||||
};
|
||||
use s3s::dto::{
|
||||
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, ObjectLockConfiguration, RestoreRequest,
|
||||
RestoreRequestType, RestoreStatus, Timestamp,
|
||||
BucketLifecycleConfiguration, ExpirationStatus, ObjectLockConfiguration, RestoreRequest, RestoreRequestType, RestoreStatus,
|
||||
Timestamp,
|
||||
};
|
||||
use s3s::header::{X_AMZ_RESTORE, X_AMZ_SERVER_SIDE_ENCRYPTION};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -309,6 +310,7 @@ struct ExpiryTask {
|
||||
obj_info: ObjectInfo,
|
||||
event: lifecycle::Event,
|
||||
src: LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
}
|
||||
|
||||
impl ExpiryOp for ExpiryTask {
|
||||
@@ -587,6 +589,7 @@ struct NewerNoncurrentTask {
|
||||
versions: Vec<ObjectToDelete>,
|
||||
event: lifecycle::Event,
|
||||
src: LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
}
|
||||
|
||||
impl ExpiryOp for NewerNoncurrentTask {
|
||||
@@ -697,13 +700,20 @@ impl ExpiryState {
|
||||
queued
|
||||
}
|
||||
|
||||
pub fn enqueue_by_days(&mut self, oi: &ObjectInfo, event: &lifecycle::Event, src: &LcEventSrc) -> bool {
|
||||
pub fn enqueue_by_days(
|
||||
&mut self,
|
||||
oi: &ObjectInfo,
|
||||
event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
let trace = LifecycleExpiryTrace::for_object(oi, event, src, 1);
|
||||
trace.emit(EVENT_LIFECYCLE_EXPIRED_DETECTED, "detected", None);
|
||||
let task = ExpiryTask {
|
||||
obj_info: oi.clone(),
|
||||
event: event.clone(),
|
||||
src: src.clone(),
|
||||
bucket_incarnation_id,
|
||||
};
|
||||
let wrkr = self.get_worker_ch(task.op_hash());
|
||||
if wrkr.is_none() {
|
||||
@@ -730,6 +740,7 @@ impl ExpiryState {
|
||||
versions: Vec<ObjectToDelete>,
|
||||
lc_event: lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
if versions.is_empty() {
|
||||
return true;
|
||||
@@ -743,6 +754,7 @@ impl ExpiryState {
|
||||
versions,
|
||||
event: lc_event.clone(),
|
||||
src: src.clone(),
|
||||
bucket_incarnation_id,
|
||||
};
|
||||
let wrkr = self.get_worker_ch(task.op_hash());
|
||||
if wrkr.is_none() {
|
||||
@@ -855,9 +867,23 @@ impl ExpiryState {
|
||||
let trace = LifecycleExpiryTrace::for_object(&v.obj_info, &v.event, &v.src, 1);
|
||||
trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None);
|
||||
let deleted = if !v.obj_info.transitioned_object.status.is_empty() {
|
||||
apply_expiry_on_transitioned_object(api.clone(), &v.obj_info, &v.event, &v.src).await
|
||||
apply_expiry_on_transitioned_object(
|
||||
api.clone(),
|
||||
&v.obj_info,
|
||||
&v.event,
|
||||
&v.src,
|
||||
v.bucket_incarnation_id,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
apply_expiry_on_non_transitioned_objects(api.clone(), &v.obj_info, &v.event, &v.src).await
|
||||
apply_expiry_on_non_transitioned_objects(
|
||||
api.clone(),
|
||||
&v.obj_info,
|
||||
&v.event,
|
||||
&v.src,
|
||||
v.bucket_incarnation_id,
|
||||
)
|
||||
.await
|
||||
};
|
||||
if deleted {
|
||||
trace.emit(EVENT_LIFECYCLE_DELETE_COMPLETED, "delete_completed", None);
|
||||
@@ -875,7 +901,14 @@ impl ExpiryState {
|
||||
let version_count = u64::try_from(v.versions.len()).unwrap_or(u64::MAX);
|
||||
let trace = LifecycleExpiryTrace::for_batch(&v.bucket, &v.event, &v.src, version_count);
|
||||
trace.emit(EVENT_LIFECYCLE_DELETE_DISPATCHED, "delete_dispatched", None);
|
||||
crate::client::object_handlers_common::delete_object_versions(&api, &v.bucket, &v.versions, v.event.clone()).await;
|
||||
crate::client::object_handlers_common::delete_object_versions(
|
||||
&api,
|
||||
&v.bucket,
|
||||
&v.versions,
|
||||
v.event.clone(),
|
||||
v.bucket_incarnation_id,
|
||||
)
|
||||
.await;
|
||||
trace.emit(EVENT_LIFECYCLE_DELETE_COMPLETED, "delete_completed", None);
|
||||
}
|
||||
else if v.as_any().is::<Jentry>() {
|
||||
@@ -2886,7 +2919,7 @@ async fn read_stale_multipart_candidate(
|
||||
) {
|
||||
Ok(file_info) => (Some(file_info.metadata), file_info.mod_time),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_STALE_MULTIPART_CLEANUP,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
@@ -3265,10 +3298,27 @@ pub async fn enqueue_transition_immediate(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
}
|
||||
|
||||
pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
let Some(lifecycle) = runtime_sources::bucket_lifecycle_config(&oi.bucket).await else {
|
||||
let Some(api) = runtime_sources::object_store_handle() else {
|
||||
return;
|
||||
};
|
||||
let Some(api) = runtime_sources::object_store_handle() else {
|
||||
let configs = match metadata_boundary::get_expiry_configs(&api, &oi.bucket).await {
|
||||
Ok(configs) => configs,
|
||||
Err(err) => {
|
||||
observe_lifecycle_observability_event(EVENT_LIFECYCLE_EVALUATION_FAILED, "failed", Some("metadata_unavailable"));
|
||||
warn!(
|
||||
event = EVENT_LIFECYCLE_EVALUATION_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
error = %err,
|
||||
reason = "metadata_unavailable",
|
||||
"Failed to load authoritative lifecycle metadata"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(lifecycle) = configs.lifecycle else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -3312,16 +3362,13 @@ pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
object_infos.push(oi.clone());
|
||||
}
|
||||
|
||||
let lock_config = match metadata_boundary::get_object_lock_config(&oi.bucket).await {
|
||||
Ok((cfg, _)) => Some(Arc::new(cfg)),
|
||||
Err(_) => None,
|
||||
};
|
||||
let object_opts = object_infos
|
||||
.iter()
|
||||
.map(lifecycle::object_opts_from_object_info)
|
||||
.collect::<Vec<ObjectOpts>>();
|
||||
let events = match Evaluator::new(Arc::new(lifecycle))
|
||||
.with_lock_retention(lock_config)
|
||||
let lock_config = configs.object_lock;
|
||||
let events = match Evaluator::new(lifecycle)
|
||||
.with_lock_retention(lock_config.clone())
|
||||
.eval(&object_opts)
|
||||
.await
|
||||
{
|
||||
@@ -3350,6 +3397,19 @@ pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
if event.due != Some(OffsetDateTime::UNIX_EPOCH) {
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
event.action,
|
||||
IlmAction::DeleteAction
|
||||
| IlmAction::DeleteVersionAction
|
||||
| IlmAction::DeleteAllVersionsAction
|
||||
| IlmAction::DelMarkerDeleteAllVersionsAction
|
||||
) && !matches!(
|
||||
object_lock_boundary::check_object_lock_for_deletion_with_config(lock_config.as_deref(), object, false),
|
||||
Ok(None)
|
||||
) {
|
||||
record_scanner_lifecycle_expiry_blocked(&src, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
match event.action {
|
||||
IlmAction::DeleteAction
|
||||
@@ -3357,7 +3417,7 @@ pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
| IlmAction::DeleteRestoredVersionAction
|
||||
| IlmAction::DeleteAllVersionsAction
|
||||
| IlmAction::DelMarkerDeleteAllVersionsAction => {
|
||||
apply_expiry_rule(event, &src, object).await;
|
||||
enqueue_expiry_rule_with_incarnation(event, &src, object, configs.bucket_incarnation_id).await;
|
||||
}
|
||||
IlmAction::DeleteVersionAction => {
|
||||
to_delete_objs.push(ObjectToDelete {
|
||||
@@ -3377,10 +3437,13 @@ pub async fn enqueue_immediate_expiry(oi: &ObjectInfo, src: LcEventSrc) {
|
||||
&& let Some(event) = noncurrent_event
|
||||
{
|
||||
let expiry_state = runtime_sources::expiry_state_handle();
|
||||
expiry_state
|
||||
.write()
|
||||
.await
|
||||
.enqueue_by_newer_noncurrent(&oi.bucket, to_delete_objs, event, &src);
|
||||
expiry_state.write().await.enqueue_by_newer_noncurrent(
|
||||
&oi.bucket,
|
||||
to_delete_objs,
|
||||
event,
|
||||
&src,
|
||||
configs.bucket_incarnation_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3757,11 +3820,17 @@ fn should_defer_date_expiry_for_recent_config_update(lc: &BucketLifecycleConfigu
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_existing_object_expiry(api: Arc<ECStore>, object: &ObjectInfo, event: &lifecycle::Event, src: &LcEventSrc) {
|
||||
async fn apply_existing_object_expiry(
|
||||
api: Arc<ECStore>,
|
||||
object: &ObjectInfo,
|
||||
event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) {
|
||||
if object.is_remote() {
|
||||
apply_expiry_on_transitioned_object(api, object, event, src).await;
|
||||
apply_expiry_on_transitioned_object(api, object, event, src, bucket_incarnation_id).await;
|
||||
} else {
|
||||
apply_expiry_on_non_transitioned_objects(api, object, event, src).await;
|
||||
apply_expiry_on_non_transitioned_objects(api, object, event, src, bucket_incarnation_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3770,6 +3839,7 @@ struct ExistingObjectExpiryContext<'a> {
|
||||
bucket: &'a str,
|
||||
lc: Arc<BucketLifecycleConfiguration>,
|
||||
lock_config: Option<Arc<ObjectLockConfiguration>>,
|
||||
bucket_incarnation_id: Uuid,
|
||||
src: &'a LcEventSrc,
|
||||
defer_date_expiry_once: bool,
|
||||
}
|
||||
@@ -3821,6 +3891,17 @@ async fn enqueue_expiry_for_existing_object_group(
|
||||
| IlmAction::DeleteRestoredVersionAction
|
||||
| IlmAction::DeleteAllVersionsAction
|
||||
| IlmAction::DelMarkerDeleteAllVersionsAction => {
|
||||
if !event.action.delete_restored() {
|
||||
let object_lock_result = object_lock_boundary::check_object_lock_for_deletion_with_config(
|
||||
context.lock_config.as_deref(),
|
||||
object,
|
||||
false,
|
||||
);
|
||||
if !matches!(object_lock_result, Ok(None)) {
|
||||
record_scanner_lifecycle_expiry_blocked(context.src, 1);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let now = OffsetDateTime::now_utc();
|
||||
if event.due.is_some_and(|due| due.unix_timestamp() <= now.unix_timestamp()) {
|
||||
if context.defer_date_expiry_once
|
||||
@@ -3864,10 +3945,17 @@ async fn enqueue_expiry_for_existing_object_group(
|
||||
record_scanner_lifecycle_expiry_blocked(context.src, 1);
|
||||
continue;
|
||||
}
|
||||
apply_existing_object_expiry(context.api.clone(), object, event, context.src).await;
|
||||
apply_existing_object_expiry(
|
||||
context.api.clone(),
|
||||
object,
|
||||
event,
|
||||
context.src,
|
||||
context.bucket_incarnation_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
apply_expiry_rule(event, context.src, object).await;
|
||||
enqueue_expiry_rule_with_incarnation(event, context.src, object, context.bucket_incarnation_id).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -3878,22 +3966,22 @@ async fn enqueue_expiry_for_existing_object_group(
|
||||
&& let Some(event) = noncurrent_event
|
||||
{
|
||||
let expiry_state = runtime_sources::expiry_state_handle();
|
||||
expiry_state
|
||||
.write()
|
||||
.await
|
||||
.enqueue_by_newer_noncurrent(context.bucket, to_delete_objs, event, context.src);
|
||||
expiry_state.write().await.enqueue_by_newer_noncurrent(
|
||||
context.bucket,
|
||||
to_delete_objs,
|
||||
event,
|
||||
context.src,
|
||||
context.bucket_incarnation_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enqueue_expiry_for_existing_objects(api: Arc<ECStore>, bucket: &str) -> Result<(), Error> {
|
||||
let Ok((lc, _)) = metadata_boundary::get_lifecycle_config(bucket).await else {
|
||||
let configs = metadata_boundary::get_expiry_configs(&api, bucket).await?;
|
||||
let Some(lc) = configs.lifecycle else {
|
||||
return Ok(());
|
||||
};
|
||||
let lc = Arc::new(lc);
|
||||
let lock_config = metadata_boundary::get_object_lock_config(bucket)
|
||||
.await
|
||||
.ok()
|
||||
.map(|(cfg, _)| Arc::new(cfg));
|
||||
let lock_config = configs.object_lock;
|
||||
let mut marker = None;
|
||||
let mut version_marker = None;
|
||||
let src = LcEventSrc::Scanner;
|
||||
@@ -3903,6 +3991,7 @@ pub async fn enqueue_expiry_for_existing_objects(api: Arc<ECStore>, bucket: &str
|
||||
bucket,
|
||||
lc: lc.clone(),
|
||||
lock_config: lock_config.clone(),
|
||||
bucket_incarnation_id: configs.bucket_incarnation_id,
|
||||
src: &src,
|
||||
defer_date_expiry_once,
|
||||
};
|
||||
@@ -4057,20 +4146,45 @@ fn transitioned_object_delete_opts(
|
||||
action: IlmAction,
|
||||
versioned: bool,
|
||||
version_suspended: bool,
|
||||
) -> ObjectOptions {
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> crate::error::Result<ObjectOptions> {
|
||||
let mut opts = ObjectOptions {
|
||||
versioned,
|
||||
version_suspended,
|
||||
expiration: ExpirationOptions { expire: true },
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
if action.delete_versioned() {
|
||||
opts.version_id = oi.version_id.map(|id| id.to_string());
|
||||
}
|
||||
if action.delete_restored() {
|
||||
let etag = oi
|
||||
.etag
|
||||
.as_deref()
|
||||
.filter(|etag| !etag.is_empty())
|
||||
.ok_or_else(|| Error::other("restored-copy expiry requires an object etag"))?;
|
||||
let data_dir = oi
|
||||
.data_dir
|
||||
.ok_or_else(|| Error::other("restored-copy expiry requires a local data directory"))?;
|
||||
let restore_expiry = oi
|
||||
.restore_expires
|
||||
.ok_or_else(|| Error::other("restored-copy expiry requires a restore expiry"))?;
|
||||
opts.transition.expire_restored = true;
|
||||
opts.transition.status.clone_from(&oi.transitioned_object.status);
|
||||
opts.transition.tier.clone_from(&oi.transitioned_object.tier);
|
||||
opts.transition.etag = etag.to_string();
|
||||
opts.transition.expected_data_dir = Some(data_dir);
|
||||
opts.transition.expected_remote_name.clone_from(&oi.transitioned_object.name);
|
||||
opts.transition
|
||||
.expected_remote_version_id
|
||||
.clone_from(&oi.transitioned_object.version_id);
|
||||
opts.transition.restore_expiry = restore_expiry;
|
||||
if let Some(version_id) = oi.version_id {
|
||||
opts.version_id = Some(version_id.to_string());
|
||||
}
|
||||
}
|
||||
opts
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
pub async fn expire_transitioned_object(
|
||||
@@ -4078,12 +4192,14 @@ pub async fn expire_transitioned_object(
|
||||
oi: &ObjectInfo,
|
||||
lc_event: &lifecycle::Event,
|
||||
_src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> Result<ObjectInfo, std::io::Error> {
|
||||
let snapshot = lifecycle_delete_config_snapshot(&api, oi)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let (versioned, version_suspended) = snapshot.versioning_config().delete_state(&oi.name);
|
||||
let mut opts = transitioned_object_delete_opts(oi, lc_event.action, versioned, version_suspended);
|
||||
let mut opts = transitioned_object_delete_opts(oi, lc_event.action, versioned, version_suspended, bucket_incarnation_id)
|
||||
.map_err(std::io::Error::other)?;
|
||||
opts.delete_replication_config_snapshot = Some(Arc::new(snapshot));
|
||||
//let tags = LcAuditEvent::new(src, lcEvent).Tags();
|
||||
if lc_event.action.delete_restored() {
|
||||
@@ -4509,7 +4625,7 @@ const _MAX_RESTORE_OBJECT_REQUEST_SIZE: i64 = 2 << 20;
|
||||
|
||||
pub async fn eval_action_from_lifecycle(
|
||||
lc: &BucketLifecycleConfiguration,
|
||||
lr: Option<DefaultRetention>,
|
||||
lock_config: Option<&ObjectLockConfiguration>,
|
||||
oi: &ObjectInfo,
|
||||
) -> lifecycle::Event {
|
||||
let event = lc.eval(&oi.to_lifecycle_opts()).await;
|
||||
@@ -4522,7 +4638,7 @@ pub async fn eval_action_from_lifecycle(
|
||||
"Evaluated lifecycle action during secondary scan"
|
||||
);
|
||||
|
||||
let lock_enabled = if let Some(lr) = lr { lr.mode.is_some() } else { false };
|
||||
let lock_enabled = lock_config.is_some_and(ObjectLockApi::enabled);
|
||||
let object_locked = object_lock_boundary::is_object_locked_by_metadata(&oi.user_defined, oi.delete_marker);
|
||||
|
||||
match event.action {
|
||||
@@ -4538,12 +4654,14 @@ pub async fn eval_action_from_lifecycle(
|
||||
{
|
||||
return lifecycle::Event::default();
|
||||
}
|
||||
// Lifecycle operations should never bypass governance retention
|
||||
if object_locked
|
||||
|| (lock_enabled
|
||||
&& object_lock_boundary::check_object_lock_for_deletion(&oi.bucket, oi, false)
|
||||
.await
|
||||
.is_some())
|
||||
// Destructive expiry never bypasses retention. Restore expiry only
|
||||
// removes the local copy; the retained logical version remains.
|
||||
if !event.action.delete_restored()
|
||||
&& (object_locked
|
||||
|| !matches!(
|
||||
object_lock_boundary::check_object_lock_for_deletion_with_config(lock_config, oi, false),
|
||||
Ok(None)
|
||||
))
|
||||
{
|
||||
//if serverDebugLog {
|
||||
if oi.version_id.is_some() {
|
||||
@@ -4676,9 +4794,10 @@ pub async fn apply_expiry_on_transitioned_object(
|
||||
oi: &ObjectInfo,
|
||||
lc_event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
let time_ilm = Metrics::time_ilm(lc_event.action);
|
||||
if let Err(_err) = expire_transitioned_object(api, oi, lc_event, src).await {
|
||||
if let Err(_err) = expire_transitioned_object(api, oi, lc_event, src, bucket_incarnation_id).await {
|
||||
return false;
|
||||
}
|
||||
time_ilm(1)();
|
||||
@@ -4691,6 +4810,7 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
oi: &ObjectInfo,
|
||||
lc_event: &lifecycle::Event,
|
||||
_src: &LcEventSrc,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
let snapshot = match lifecycle_delete_config_snapshot(&api, oi).await {
|
||||
Ok(snapshot) => snapshot,
|
||||
@@ -4714,6 +4834,7 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
version_suspended,
|
||||
expiration: ExpirationOptions { expire: true },
|
||||
delete_replication_config_snapshot: Some(Arc::new(snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -4775,10 +4896,61 @@ pub async fn apply_expiry_on_non_transitioned_objects(
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn apply_expiry_rule(event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
||||
async fn enqueue_expiry_rule_with_incarnation(
|
||||
event: &lifecycle::Event,
|
||||
src: &LcEventSrc,
|
||||
oi: &ObjectInfo,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> bool {
|
||||
let expiry_state = runtime_sources::expiry_state_handle();
|
||||
let mut expiry_state = expiry_state.write().await;
|
||||
expiry_state.enqueue_by_days(oi, event, src)
|
||||
expiry_state.enqueue_by_days(oi, event, src, bucket_incarnation_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_expiry_rule_in(api: Arc<ECStore>, event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
||||
let Ok(_lifecycle_guard) = api.acquire_bucket_lifecycle_read_lock(&oi.bucket).await else {
|
||||
return false;
|
||||
};
|
||||
let Ok(bucket_incarnation_id) = api.bucket_incarnation_id_from_disk(&oi.bucket).await else {
|
||||
return false;
|
||||
};
|
||||
let current = match api
|
||||
.get_object_info(
|
||||
&oi.bucket,
|
||||
&oi.name,
|
||||
&ObjectOptions {
|
||||
version_id: oi.version_id.map(|version_id| version_id.to_string()),
|
||||
versioned: oi.version_id.is_some(),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(current) => current,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if current.version_id != oi.version_id
|
||||
|| current.data_dir != oi.data_dir
|
||||
|| current.mod_time != oi.mod_time
|
||||
|| current.etag != oi.etag
|
||||
|| current.delete_marker != oi.delete_marker
|
||||
|| current.transitioned_object.name != oi.transitioned_object.name
|
||||
|| current.transitioned_object.version_id != oi.transitioned_object.version_id
|
||||
|| current.transitioned_object.tier != oi.transitioned_object.tier
|
||||
|| current.transitioned_object.status != oi.transitioned_object.status
|
||||
|| current.restore_expires != oi.restore_expires
|
||||
{
|
||||
return false;
|
||||
}
|
||||
enqueue_expiry_rule_with_incarnation(event, src, oi, bucket_incarnation_id).await
|
||||
}
|
||||
|
||||
pub async fn apply_expiry_rule(event: &lifecycle::Event, src: &LcEventSrc, oi: &ObjectInfo) -> bool {
|
||||
let Some(api) = runtime_sources::object_store_handle() else {
|
||||
return false;
|
||||
};
|
||||
apply_expiry_rule_in(api, event, src, oi).await
|
||||
}
|
||||
|
||||
fn lifecycle_deleted_object(oi: &ObjectInfo, dobj: &ObjectInfo) -> DeletedObject {
|
||||
@@ -4853,11 +5025,11 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
|
||||
mod tests {
|
||||
use super::{
|
||||
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
|
||||
DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EXPIRED_DETECTED, EVENT_LIFECYCLE_NOT_ENQUEUED, ExpiryState,
|
||||
FreeVersionTask, ManualTransitionJobRecoveryOutcome, ManualTransitionQueueSnapshot, ManualTransitionRunOptions,
|
||||
ManualTransitionRunReport, StaleMultipartUploadCandidate, TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL,
|
||||
TIER_FREE_VERSION_RECOVERY_MAX_IDLE_INTERVAL, TRANSITION_COMPLETE, TierFreeVersionRecoverySchedule,
|
||||
TransitionEnqueueOutcome, TransitionState, TransitionedObject, VersionReplicationScan,
|
||||
DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EVALUATION_FAILED, EVENT_LIFECYCLE_EXPIRED_DETECTED,
|
||||
EVENT_LIFECYCLE_NOT_ENQUEUED, ExpiryState, ExpiryTask, FreeVersionTask, ManualTransitionJobRecoveryOutcome,
|
||||
ManualTransitionQueueSnapshot, ManualTransitionRunOptions, ManualTransitionRunReport, StaleMultipartUploadCandidate,
|
||||
TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL, TIER_FREE_VERSION_RECOVERY_MAX_IDLE_INTERVAL, TRANSITION_COMPLETE,
|
||||
TierFreeVersionRecoverySchedule, TransitionEnqueueOutcome, TransitionState, TransitionedObject, VersionReplicationScan,
|
||||
cleanup_empty_multipart_sha_dirs_on_local_disks, cleanup_stale_multipart_uploads_once_at,
|
||||
enqueue_recovered_free_version_with_state, enqueue_transition_for_existing_objects_scoped,
|
||||
enqueue_transition_with_lifecycle, enqueue_transition_with_lifecycle_report, eval_action_from_lifecycle,
|
||||
@@ -4937,8 +5109,9 @@ mod tests {
|
||||
use rustfs_data_usage::TierStats;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta};
|
||||
use s3s::dto::{
|
||||
BucketLifecycleConfiguration, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry, OutputLocation,
|
||||
RestoreRequest, RestoreRequestType, S3Location, Timestamp, Transition, TransitionStorageClass,
|
||||
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry,
|
||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, OutputLocation, RestoreRequest,
|
||||
RestoreRequestType, S3Location, Timestamp, Transition, TransitionStorageClass,
|
||||
};
|
||||
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
|
||||
use serial_test::serial;
|
||||
@@ -5854,29 +6027,44 @@ mod tests {
|
||||
bucket: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(vid),
|
||||
data_dir: Some(Uuid::new_v4()),
|
||||
etag: Some("etag".to_string()),
|
||||
restore_expires: Some(OffsetDateTime::now_utc() - StdDuration::from_secs(1)),
|
||||
transitioned_object: TransitionedObject {
|
||||
name: "remote-object".to_string(),
|
||||
tier: "tier".to_string(),
|
||||
status: TRANSITION_COMPLETE.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Plain version expiry: exact version, real delete.
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteVersionAction, true, false);
|
||||
let incarnation = Uuid::new_v4();
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteVersionAction, true, false, incarnation)
|
||||
.expect("build version expiry options");
|
||||
assert_eq!(opts.version_id.as_deref(), Some(vid_str.as_str()));
|
||||
assert_eq!(opts.expected_bucket_incarnation_id, Some(incarnation));
|
||||
assert!(!opts.transition.expire_restored);
|
||||
assert!(opts.expiration.expire);
|
||||
|
||||
// Restore-expiry of the latest version: restored-copy cleanup only.
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteRestoredAction, true, false);
|
||||
assert!(opts.version_id.is_none());
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteRestoredAction, true, false, incarnation)
|
||||
.expect("build restored expiry options");
|
||||
assert_eq!(opts.version_id.as_deref(), Some(vid_str.as_str()));
|
||||
assert!(opts.transition.expire_restored);
|
||||
|
||||
// Restore-expiry of a noncurrent version: restored-copy cleanup of the
|
||||
// exact version. Routing this through the full transitioned-object
|
||||
// delete instead would remove the remote tier data.
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteRestoredVersionAction, true, false);
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteRestoredVersionAction, true, false, incarnation)
|
||||
.expect("build restored-version expiry options");
|
||||
assert_eq!(opts.version_id.as_deref(), Some(vid_str.as_str()));
|
||||
assert!(opts.transition.expire_restored);
|
||||
|
||||
// Whole-object expiry stays a real delete.
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteAction, false, false);
|
||||
let opts = transitioned_object_delete_opts(&oi, IlmAction::DeleteAction, false, false, incarnation)
|
||||
.expect("build object expiry options");
|
||||
assert!(opts.version_id.is_none());
|
||||
assert!(!opts.transition.expire_restored);
|
||||
}
|
||||
@@ -5905,7 +6093,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let queued = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner);
|
||||
let queued = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner, Uuid::new_v4());
|
||||
|
||||
assert!(!queued);
|
||||
assert_eq!(state.stats.missed_tasks(), 1);
|
||||
@@ -6011,8 +6199,9 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let first = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner);
|
||||
let second = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner);
|
||||
let incarnation = Uuid::new_v4();
|
||||
let first = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner, incarnation);
|
||||
let second = state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner, incarnation);
|
||||
|
||||
assert!(first);
|
||||
assert!(!second);
|
||||
@@ -6031,6 +6220,39 @@ mod tests {
|
||||
assert!(observed.contains(&(EVENT_LIFECYCLE_NOT_ENQUEUED, "not_enqueued", Some("queue_full"))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expiry_task_retains_enqueue_time_bucket_incarnation() {
|
||||
let state = ExpiryState::new_with_unconsumed_worker_channel(1);
|
||||
let incarnation = Uuid::new_v4();
|
||||
let object = ObjectInfo {
|
||||
bucket: "bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let event = crate::bucket::lifecycle::lifecycle::Event {
|
||||
action: IlmAction::DeleteAction,
|
||||
..Default::default()
|
||||
};
|
||||
{
|
||||
let mut state = state.write().await;
|
||||
assert!(state.enqueue_by_days(&object, &event, &LcEventSrc::Scanner, incarnation));
|
||||
}
|
||||
|
||||
let receiver = state.read().await.tasks_rx[0].clone();
|
||||
let task = receiver
|
||||
.lock()
|
||||
.await
|
||||
.recv()
|
||||
.await
|
||||
.expect("expiry task should be queued")
|
||||
.expect("expiry task payload should be present");
|
||||
let task = task
|
||||
.as_any()
|
||||
.downcast_ref::<ExpiryTask>()
|
||||
.expect("queued payload should be an expiry task");
|
||||
assert_eq!(task.bucket_incarnation_id, incarnation);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_tier_journal_entry_reports_error_when_worker_queue_full() {
|
||||
let state = ExpiryState::new_with_unconsumed_worker_channel(1);
|
||||
@@ -7741,6 +7963,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn all_versions_expiration_lifecycle() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(1),
|
||||
expired_object_all_versions: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("delete-all".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_enabled_without_default_retention() -> ObjectLockConfiguration {
|
||||
ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_enabled_with_default_retention() -> ObjectLockConfiguration {
|
||||
ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
days: Some(30),
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_transition_lifecycle() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
@@ -9937,6 +10201,124 @@ mod tests {
|
||||
assert_eq!(event.action, IlmAction::DeleteAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_object_lifecycle_skips_current_expiration_for_bucket_default_retention() {
|
||||
let lc = latest_expiration_lifecycle();
|
||||
let mut object = current_object(ReplicationStatusType::Completed);
|
||||
object.mod_time = Some(OffsetDateTime::now_utc());
|
||||
let lock_config = lock_enabled_with_default_retention();
|
||||
|
||||
let event = eval_action_from_lifecycle(&lc, Some(&lock_config), &object).await;
|
||||
|
||||
assert_eq!(event.action, IlmAction::NoneAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_object_lifecycle_skips_delete_all_when_lock_enabled_without_default_retention() {
|
||||
let lc = all_versions_expiration_lifecycle();
|
||||
let object = current_object(ReplicationStatusType::Completed);
|
||||
let lock_config = lock_enabled_without_default_retention();
|
||||
|
||||
let event = eval_action_from_lifecycle(&lc, Some(&lock_config), &object).await;
|
||||
|
||||
assert_eq!(event.action, IlmAction::NoneAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("lifecycle-lock-metadata-error-{}", Uuid::new_v4().simple());
|
||||
let object = "due/object";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
|
||||
let mut reader = PutObjReader::from_vec(b"must survive lifecycle metadata failure".to_vec());
|
||||
let object_info = ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(2)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("due object should be created");
|
||||
|
||||
let lifecycle = latest_expiration_lifecycle();
|
||||
let sys = metadata_sys::bucket_metadata_sys_of(&ecstore.ctx).expect("metadata system should be initialized");
|
||||
let sys = sys.read().await.clone();
|
||||
let mut metadata = (*sys.get(&bucket).await.expect("bucket metadata should exist")).clone();
|
||||
metadata.lifecycle_config_xml = crate::bucket::utils::serialize(&lifecycle).unwrap();
|
||||
metadata.lifecycle_config = Some(lifecycle);
|
||||
metadata.object_lock_config_xml = b"<ObjectLockConfiguration>".to_vec();
|
||||
metadata.object_lock_config = None;
|
||||
sys.persist_and_set(metadata)
|
||||
.await
|
||||
.expect("corrupt Object Lock payload should be persisted for the read-boundary test");
|
||||
sys.reload_from_store(&bucket)
|
||||
.await
|
||||
.expect("peer-style reload should publish the malformed persisted snapshot");
|
||||
|
||||
let exact_error = super::metadata_boundary::get_expiry_configs(&ecstore, &bucket)
|
||||
.await
|
||||
.expect_err("malformed Object Lock metadata must reject lifecycle config resolution");
|
||||
assert!(
|
||||
exact_error
|
||||
.to_string()
|
||||
.contains("persisted bucket Object Lock configuration is invalid")
|
||||
);
|
||||
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
let observed = Arc::new(StdMutex::new(Vec::new()));
|
||||
let observed_events = Arc::clone(&observed);
|
||||
let _observer = set_lifecycle_observability_observer(move |event, state, reason| {
|
||||
observed_events
|
||||
.lock()
|
||||
.expect("lifecycle metadata error observer should not poison")
|
||||
.push((event, state, reason));
|
||||
});
|
||||
|
||||
super::enqueue_immediate_expiry(&object_info, LcEventSrc::S3PutObject).await;
|
||||
|
||||
assert!(
|
||||
observed.lock().expect("observed events should not poison").contains(&(
|
||||
EVENT_LIFECYCLE_EVALUATION_FAILED,
|
||||
"failed",
|
||||
Some("metadata_unavailable")
|
||||
)),
|
||||
"immediate expiry must expose the authoritative metadata failure"
|
||||
);
|
||||
{
|
||||
let state = runtime_state.read().await;
|
||||
assert_eq!(state.stats.pending_tasks(), 0, "immediate expiry must not enqueue a delete");
|
||||
}
|
||||
assert!(
|
||||
ecstore
|
||||
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.is_ok(),
|
||||
"immediate expiry must leave the due object intact"
|
||||
);
|
||||
|
||||
let scanner_error = super::enqueue_expiry_for_existing_objects(ecstore.clone(), &bucket)
|
||||
.await
|
||||
.expect_err("scanner must propagate the authoritative Object Lock metadata error");
|
||||
assert_eq!(scanner_error.to_string(), exact_error.to_string());
|
||||
{
|
||||
let state = runtime_state.read().await;
|
||||
assert_eq!(state.stats.pending_tasks(), 0, "scanner must not enqueue a delete");
|
||||
}
|
||||
assert!(
|
||||
ecstore
|
||||
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.is_ok(),
|
||||
"scanner must leave the due object intact"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_object_lifecycle_skips_current_expiration_for_explicit_legal_hold() {
|
||||
let lc = latest_expiration_lifecycle();
|
||||
@@ -9972,6 +10354,37 @@ mod tests {
|
||||
assert_eq!(event.action, IlmAction::NoneAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_copy_expiry_is_not_blocked_by_retention() {
|
||||
let lifecycle = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: Vec::new(),
|
||||
};
|
||||
let retain_until = (OffsetDateTime::now_utc() + time::Duration::days(30))
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap();
|
||||
let mut object = current_object_with_metadata(
|
||||
ReplicationStatusType::Completed,
|
||||
HashMap::from([
|
||||
(
|
||||
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
|
||||
ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
||||
),
|
||||
(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(), retain_until),
|
||||
]),
|
||||
);
|
||||
object.transitioned_object.status = TRANSITION_COMPLETE.to_string();
|
||||
object.restore_expires = Some(OffsetDateTime::now_utc() - time::Duration::hours(1));
|
||||
|
||||
let current = eval_action_from_lifecycle(&lifecycle, None, &object).await;
|
||||
assert_eq!(current.action, IlmAction::DeleteRestoredAction);
|
||||
|
||||
object.is_latest = false;
|
||||
object.successor_mod_time = Some(OffsetDateTime::now_utc());
|
||||
let noncurrent = eval_action_from_lifecycle(&lifecycle, None, &object).await;
|
||||
assert_eq!(noncurrent.action, IlmAction::DeleteRestoredVersionAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_object_lifecycle_skips_transition_while_replication_pending() {
|
||||
let lc = latest_transition_lifecycle();
|
||||
@@ -10361,7 +10774,18 @@ mod tests {
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_real_enqueue_failure_retries_same_object() {
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("recovery-enqueue-failure-{}", Uuid::new_v4());
|
||||
let object = "free-version-b";
|
||||
let start_marker = "free-version-a0";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
|
||||
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
let recovery_rx = {
|
||||
let state = runtime_state.read().await;
|
||||
Arc::clone(&state.tasks_rx[0])
|
||||
};
|
||||
let mut recovery_rx = recovery_rx.lock().await;
|
||||
assert!(
|
||||
super::enqueue_recovered_free_version(ObjectInfo {
|
||||
bucket: "prefill".to_string(),
|
||||
@@ -10371,20 +10795,29 @@ mod tests {
|
||||
.await,
|
||||
"the production recovery queue should accept its first task"
|
||||
);
|
||||
let bucket = format!("recovery-enqueue-failure-{}", Uuid::new_v4());
|
||||
let object = "free-version";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
|
||||
|
||||
let first = recover_tier_free_versions_with_cancel(Arc::clone(&ecstore), 1, None, None, CancellationToken::new())
|
||||
.await
|
||||
.expect("queue failure should return retry markers");
|
||||
let first = recover_tier_free_versions_with_cancel(
|
||||
Arc::clone(&ecstore),
|
||||
1,
|
||||
Some(bucket.clone()),
|
||||
Some(start_marker.to_string()),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.expect("queue failure should return retry markers");
|
||||
assert_eq!(first.scanned, 1);
|
||||
assert_eq!(first.enqueued, 0);
|
||||
assert_eq!(first.failed, 1);
|
||||
assert!(first.truncated);
|
||||
assert_eq!(first.next_bucket_marker.as_deref(), Some(bucket.as_str()));
|
||||
assert!(first.next_object_marker.is_none());
|
||||
assert_eq!(first.next_object_marker.as_deref(), Some(start_marker));
|
||||
|
||||
drop(
|
||||
recovery_rx
|
||||
.try_recv()
|
||||
.expect("the failed recovery attempt must leave the prefilled task queued")
|
||||
.expect("the prefilled recovery queue entry should contain a task"),
|
||||
);
|
||||
|
||||
let retried = recover_tier_free_versions_with_cancel(
|
||||
Arc::clone(&ecstore),
|
||||
@@ -10396,7 +10829,19 @@ mod tests {
|
||||
.await
|
||||
.expect("retry markers should revisit the failed free version");
|
||||
assert_eq!(retried.scanned, 1);
|
||||
assert_eq!(retried.failed, 1);
|
||||
assert_eq!(retried.enqueued, 1);
|
||||
assert_eq!(retried.failed, 0);
|
||||
|
||||
let retried_task = recovery_rx
|
||||
.try_recv()
|
||||
.expect("the retry should enqueue the recovered free-version task")
|
||||
.expect("the recovered queue entry should contain a task");
|
||||
let retried_task = retried_task
|
||||
.as_any()
|
||||
.downcast_ref::<FreeVersionTask>()
|
||||
.expect("the recovered queue entry should be a free-version task");
|
||||
assert_eq!(retried_task.0.bucket, bucket);
|
||||
assert_eq!(retried_task.0.name, object);
|
||||
|
||||
remove_seeded_free_version(&disk_paths, &bucket, object).await;
|
||||
ecstore
|
||||
|
||||
@@ -12,20 +12,127 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration};
|
||||
use time::OffsetDateTime;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::bucket::metadata_sys;
|
||||
use crate::error::Result;
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::bucket::metadata_sys::{self, ObjectLockConfigState};
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LifecycleExpiryConfigs {
|
||||
pub(crate) lifecycle: Option<Arc<BucketLifecycleConfiguration>>,
|
||||
pub(crate) object_lock: Option<Arc<ObjectLockConfiguration>>,
|
||||
pub(crate) bucket_incarnation_id: Uuid,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_expiry_configs(api: &crate::store::ECStore, bucket: &str) -> Result<LifecycleExpiryConfigs> {
|
||||
let bucket_incarnation_id = api.bucket_incarnation_id_from_disk(bucket).await?;
|
||||
let sys = metadata_sys::bucket_metadata_sys_of(&api.ctx)?;
|
||||
let sys = sys.read().await.clone();
|
||||
let metadata = sys.get_authoritative_metadata(bucket).await?;
|
||||
if !metadata.bucket_incarnation_sidecar || metadata.bucket_incarnation_id != bucket_incarnation_id {
|
||||
return Err(Error::other(format!("bucket lifecycle metadata is not authoritative: {bucket}")));
|
||||
}
|
||||
|
||||
let lifecycle = if metadata.lifecycle_config.is_none() && !metadata.lifecycle_config_xml.is_empty() {
|
||||
return Err(Error::other("persisted bucket lifecycle configuration is invalid"));
|
||||
} else {
|
||||
metadata
|
||||
.lifecycle_config
|
||||
.clone()
|
||||
.filter(|config| !config.rules.is_empty())
|
||||
.map(Arc::new)
|
||||
};
|
||||
if lifecycle.is_none() {
|
||||
return Ok(LifecycleExpiryConfigs {
|
||||
lifecycle: None,
|
||||
object_lock: None,
|
||||
bucket_incarnation_id,
|
||||
});
|
||||
}
|
||||
let object_lock = match metadata_sys::object_lock_config_state_from_authoritative_metadata(&metadata)? {
|
||||
ObjectLockConfigState::Configured { config, .. } => Some(Arc::new(config)),
|
||||
ObjectLockConfigState::ConfirmedAbsent => None,
|
||||
ObjectLockConfigState::Fabricated => {
|
||||
return Err(Error::other(format!("bucket Object Lock metadata is not authoritative: {bucket}")));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(LifecycleExpiryConfigs {
|
||||
lifecycle,
|
||||
object_lock,
|
||||
bucket_incarnation_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn get_lifecycle_config(bucket: &str) -> Result<(BucketLifecycleConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_lifecycle_config(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_object_lock_config(bucket: &str) -> Result<(ObjectLockConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_object_lock_config(bucket).await
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bucket::metadata::BucketMetadata;
|
||||
use crate::bucket::metadata_sys::{self, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::MakeBucketOptions;
|
||||
use s3s::dto::{ExpirationStatus, LifecycleExpiration, LifecycleRule};
|
||||
use serial_test::serial;
|
||||
|
||||
pub(crate) async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_replication_config(bucket).await
|
||||
fn lifecycle_config() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("expire".to_string()),
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn expiry_configs_are_resolved_from_the_owning_store() {
|
||||
let (_dirs_a, store_a) = isolated_store_over_temp_disks().await;
|
||||
let (_dirs_b, store_b) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "same-name-expiry-config";
|
||||
store_a
|
||||
.peer_sys
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
store_b
|
||||
.peer_sys
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
metadata_sys::init_bucket_metadata_sys(store_a.clone(), vec![bucket.to_string()]).await;
|
||||
metadata_sys::init_bucket_metadata_sys(store_b.clone(), vec![bucket.to_string()]).await;
|
||||
|
||||
let mut metadata = BucketMetadata::new(bucket);
|
||||
let lifecycle = lifecycle_config();
|
||||
metadata.lifecycle_config_xml = crate::bucket::utils::serialize(&lifecycle).unwrap();
|
||||
metadata.lifecycle_config = Some(lifecycle);
|
||||
metadata_sys::set_new_bucket_metadata_in(&store_a.ctx, metadata)
|
||||
.await
|
||||
.unwrap();
|
||||
metadata_sys::set_new_bucket_metadata_in(&store_b.ctx, BucketMetadata::new(bucket))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(get_expiry_configs(&store_a, bucket).await.unwrap().lifecycle.is_some());
|
||||
assert!(get_expiry_configs(&store_b, bucket).await.unwrap().lifecycle.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod core;
|
||||
pub mod evaluator;
|
||||
pub mod manual_transition_job;
|
||||
mod metadata_boundary;
|
||||
pub(crate) use metadata_boundary::get_expiry_configs;
|
||||
mod object_lock_boundary;
|
||||
pub use self::core as lifecycle;
|
||||
mod replication_sink;
|
||||
|
||||
@@ -21,12 +21,12 @@ pub(crate) fn is_object_locked_by_metadata(user_defined: &HashMap<String, String
|
||||
rustfs_lifecycle::object_lock::is_object_locked_by_metadata(user_defined, is_delete_marker)
|
||||
}
|
||||
|
||||
pub(crate) async fn check_object_lock_for_deletion(
|
||||
bucket: &str,
|
||||
pub(crate) fn check_object_lock_for_deletion_with_config(
|
||||
config: Option<&s3s::dto::ObjectLockConfiguration>,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Option<ObjectLockBlockReason> {
|
||||
objectlock_sys::check_object_lock_for_deletion(bucket, obj_info, bypass_governance).await
|
||||
) -> crate::error::Result<Option<ObjectLockBlockReason>> {
|
||||
objectlock_sys::check_object_lock_for_deletion_with_config(config, obj_info, bypass_governance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -18,7 +18,7 @@ use super::versioning::VersioningApi;
|
||||
use super::{quota::BucketQuota, target::BucketTargets};
|
||||
use crate::bucket::replication::invalid_replication_config_status_field;
|
||||
use crate::bucket::utils::deserialize;
|
||||
use crate::config::com::{read_config, save_config};
|
||||
use crate::config::com::{read_config, read_config_preserve_empty, save_config};
|
||||
use crate::disk::BUCKET_META_PREFIX;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
@@ -37,6 +37,7 @@ use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use time::{Date, OffsetDateTime, PrimitiveDateTime, Time as CivilTime, UtcOffset};
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn read_msgp_str<R: Read>(rd: &mut R) -> Result<String> {
|
||||
let len = rmp::decode::read_str_len(rd)? as usize;
|
||||
@@ -226,6 +227,7 @@ fn write_bin_field<W: Write>(wr: &mut W, key: &str, val: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
pub const BUCKET_METADATA_FILE: &str = ".metadata.bin";
|
||||
pub const BUCKET_INCARNATION_FILE: &str = ".bucket-incarnation";
|
||||
pub const BUCKET_METADATA_FORMAT: u16 = 1;
|
||||
pub const BUCKET_METADATA_VERSION: u16 = 1;
|
||||
|
||||
@@ -277,6 +279,8 @@ pub struct BucketMetadata {
|
||||
pub name: String,
|
||||
pub created: OffsetDateTime,
|
||||
pub lock_enabled: bool, // While marked as unused, it may need to be retained
|
||||
pub bucket_incarnation_id: Uuid,
|
||||
pub(crate) bucket_incarnation_sidecar: bool,
|
||||
pub policy_config_json: Vec<u8>,
|
||||
pub notification_config_xml: Vec<u8>,
|
||||
pub lifecycle_config_xml: Vec<u8>,
|
||||
@@ -347,6 +351,8 @@ impl Default for BucketMetadata {
|
||||
name: Default::default(),
|
||||
created: OffsetDateTime::UNIX_EPOCH,
|
||||
lock_enabled: Default::default(),
|
||||
bucket_incarnation_id: Uuid::nil(),
|
||||
bucket_incarnation_sidecar: false,
|
||||
policy_config_json: Default::default(),
|
||||
notification_config_xml: Default::default(),
|
||||
lifecycle_config_xml: Default::default(),
|
||||
@@ -414,6 +420,7 @@ impl BucketMetadata {
|
||||
pub fn new(name: &str) -> Self {
|
||||
BucketMetadata {
|
||||
name: name.to_string(),
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -479,6 +486,11 @@ impl BucketMetadata {
|
||||
"Name" => self.name = read_msgp_str(rd)?,
|
||||
"Created" => self.created = read_msgp_time_value(rd)?,
|
||||
"LockEnabled" => self.lock_enabled = read_msgp_bool(rd)?,
|
||||
"BucketIncarnationID" => {
|
||||
let bytes = read_msgp_bin(rd)?;
|
||||
self.bucket_incarnation_id =
|
||||
Uuid::from_slice(&bytes).map_err(|err| Error::other(format!("invalid BucketIncarnationID: {err}")))?;
|
||||
}
|
||||
"PolicyConfigJSON" | "PolicyConfigJson" => self.policy_config_json = read_msgp_bin(rd)?,
|
||||
"NotificationConfigXML" | "NotificationConfigXml" => self.notification_config_xml = read_msgp_bin(rd)?,
|
||||
"LifecycleConfigXML" | "LifecycleConfigXml" => self.lifecycle_config_xml = read_msgp_bin(rd)?,
|
||||
@@ -535,8 +547,8 @@ impl BucketMetadata {
|
||||
|
||||
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
|
||||
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
|
||||
// Map size: MinIO fields (25) + RustFS extensions (18)
|
||||
let map_len: u32 = 43;
|
||||
// Map size: MinIO fields (25) + RustFS extensions (19)
|
||||
let map_len: u32 = 44;
|
||||
rmp::encode::write_map_len(wr, map_len)?;
|
||||
|
||||
// MinIO field order (same as Go struct)
|
||||
@@ -549,6 +561,8 @@ impl BucketMetadata {
|
||||
rmp::encode::write_str(wr, "LockEnabled")?;
|
||||
rmp::encode::write_bool(wr, self.lock_enabled)?;
|
||||
|
||||
write_bin_field(wr, "BucketIncarnationID", self.bucket_incarnation_id.as_bytes())?;
|
||||
|
||||
write_bin_field(wr, "PolicyConfigJSON", &self.policy_config_json)?;
|
||||
write_bin_field(wr, "NotificationConfigXML", &self.notification_config_xml)?;
|
||||
write_bin_field(wr, "LifecycleConfigXML", &self.lifecycle_config_xml)?;
|
||||
@@ -748,6 +762,10 @@ impl BucketMetadata {
|
||||
self.quota_config_updated_at = updated;
|
||||
}
|
||||
OBJECT_LOCK_CONFIG => {
|
||||
self.object_lock_config = None;
|
||||
if !data.is_empty() {
|
||||
self.lock_enabled = true;
|
||||
}
|
||||
self.object_lock_config_xml = data;
|
||||
self.object_lock_config_updated_at = updated;
|
||||
}
|
||||
@@ -1115,6 +1133,29 @@ impl BucketMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_bucket_incarnation(api: Arc<ECStore>, bucket: &str) -> Result<Option<Uuid>> {
|
||||
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}");
|
||||
let data = match read_config_preserve_empty(api, &path).await {
|
||||
Ok(data) => data,
|
||||
Err(Error::ConfigNotFound) => return Ok(None),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let incarnation =
|
||||
Uuid::from_slice(&data).map_err(|err| Error::other(format!("persisted bucket incarnation is invalid: {err}")))?;
|
||||
if incarnation.is_nil() {
|
||||
return Err(Error::other("persisted bucket incarnation is nil"));
|
||||
}
|
||||
Ok(Some(incarnation))
|
||||
}
|
||||
|
||||
pub(crate) async fn save_bucket_incarnation(api: Arc<ECStore>, bucket: &str, incarnation: Uuid) -> Result<()> {
|
||||
if incarnation.is_nil() {
|
||||
return Err(Error::other("cannot persist a nil bucket incarnation"));
|
||||
}
|
||||
let path = format!("{BUCKET_META_PREFIX}/{bucket}/{BUCKET_INCARNATION_FILE}");
|
||||
save_config(api, &path, incarnation.as_bytes().to_vec()).await
|
||||
}
|
||||
|
||||
pub async fn load_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<BucketMetadata> {
|
||||
load_bucket_metadata_parse(api, bucket, true).await
|
||||
}
|
||||
@@ -1142,6 +1183,23 @@ pub(crate) async fn load_bucket_metadata_parse_with_presence(
|
||||
}
|
||||
};
|
||||
|
||||
let incarnation = load_bucket_incarnation(api, bucket).await?;
|
||||
if persisted {
|
||||
if let Some(incarnation) = incarnation {
|
||||
if !bm.bucket_incarnation_id.is_nil() && bm.bucket_incarnation_id != incarnation {
|
||||
return Err(Error::other("bucket incarnation sidecar does not match bucket metadata"));
|
||||
}
|
||||
bm.bucket_incarnation_id = incarnation;
|
||||
bm.bucket_incarnation_sidecar = true;
|
||||
} else if !bm.bucket_incarnation_id.is_nil() {
|
||||
return Err(Error::other(format!(
|
||||
"bucket incarnation sidecar is missing for new-format metadata: {bucket}"
|
||||
)));
|
||||
}
|
||||
} else if incarnation.is_some() {
|
||||
return Err(Error::other("bucket incarnation sidecar exists without bucket metadata"));
|
||||
}
|
||||
|
||||
bm.default_timestamps();
|
||||
|
||||
if parse {
|
||||
@@ -1209,6 +1267,10 @@ mod test {
|
||||
// Same 4-byte format|version header (1|1) and msgpack layout as MinIO.
|
||||
BucketMetadata::check_header(&blob).expect("valid .metadata.bin header");
|
||||
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
|
||||
assert!(
|
||||
bm.bucket_incarnation_id.is_nil(),
|
||||
"legacy MinIO metadata has no RustFS bucket incarnation field"
|
||||
);
|
||||
|
||||
// Raw config fields survive the msgpack decode (PascalCase MinIO field names).
|
||||
assert_eq!(bm.name, "interop");
|
||||
@@ -1291,6 +1353,42 @@ mod test {
|
||||
let new = BucketMetadata::unmarshal(&buf).unwrap();
|
||||
|
||||
assert_eq!(bm.name, new.name);
|
||||
assert!(!bm.bucket_incarnation_id.is_nil());
|
||||
assert_eq!(bm.bucket_incarnation_id, new.bucket_incarnation_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_incarnation_msgpack_rejects_invalid_binary_length() {
|
||||
let mut fixture = Vec::new();
|
||||
rmp::encode::write_map_len(&mut fixture, 1).unwrap();
|
||||
rmp::encode::write_str(&mut fixture, "BucketIncarnationID").unwrap();
|
||||
rmp::encode::write_bin(&mut fixture, &[0_u8; 15]).unwrap();
|
||||
|
||||
let err = BucketMetadata::unmarshal(&fixture).expect_err("non-UUID incarnation bytes must fail closed");
|
||||
assert!(err.to_string().contains("invalid BucketIncarnationID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_name_bucket_metadata_gets_a_new_incarnation() {
|
||||
let old = BucketMetadata::new("recreated");
|
||||
let new = BucketMetadata::new("recreated");
|
||||
|
||||
assert!(!old.bucket_incarnation_id.is_nil());
|
||||
assert!(!new.bucket_incarnation_id.is_nil());
|
||||
assert_ne!(old.bucket_incarnation_id, new.bucket_incarnation_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_replication_config_updates_cannot_replace_bucket_incarnation() {
|
||||
let mut metadata = BucketMetadata::new("site-replication-update");
|
||||
let incarnation = metadata.bucket_incarnation_id;
|
||||
|
||||
metadata
|
||||
.update_config(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec())
|
||||
.unwrap();
|
||||
metadata.update_config(OBJECT_LOCK_CONFIG, Vec::new()).unwrap();
|
||||
|
||||
assert_eq!(metadata.bucket_incarnation_id, incarnation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,12 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::bucket::metadata_sys::get_object_lock_config;
|
||||
use crate::bucket::metadata_sys::{ObjectLockConfigState, get_object_lock_config, get_object_lock_config_state};
|
||||
use crate::bucket::object_lock::objectlock;
|
||||
use crate::error::{Error, Result, StorageError};
|
||||
use crate::object_api::ObjectInfo;
|
||||
use s3s::dto::{DefaultRetention, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
||||
use s3s::dto::{Date, DefaultRetention, ObjectLockConfiguration, ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
||||
use s3s::header::{X_AMZ_OBJECT_LOCK_LEGAL_HOLD, X_AMZ_OBJECT_LOCK_MODE, X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -37,6 +39,20 @@ impl BucketObjectLockSys {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_recursive_force_delete_allowed_for_state(bucket: &str, state: &ObjectLockConfigState) -> Result<()> {
|
||||
match state {
|
||||
ObjectLockConfigState::ConfirmedAbsent => Ok(()),
|
||||
ObjectLockConfigState::Configured { .. } => Err(StorageError::InvalidArgument(
|
||||
bucket.to_string(),
|
||||
String::new(),
|
||||
"force-delete is forbidden on Object Locking enabled buckets".to_string(),
|
||||
)),
|
||||
ObjectLockConfigState::Fabricated => {
|
||||
Err(Error::other(format!("bucket Object Lock metadata is not authoritative: {bucket}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a retention period is still active based on mode and retain_until_date
|
||||
pub fn is_retention_active(mode: &str, retain_until_date: Option<&s3s::dto::Date>) -> bool {
|
||||
if mode != ObjectLockRetentionMode::COMPLIANCE && mode != ObjectLockRetentionMode::GOVERNANCE {
|
||||
@@ -205,71 +221,122 @@ fn check_retention_blocks_deletion(
|
||||
None
|
||||
}
|
||||
|
||||
/// Check an object's lock metadata using an already resolved bucket Object
|
||||
/// Lock configuration. `None` means the configuration is confirmed absent.
|
||||
///
|
||||
/// # S3 Standard Behavior
|
||||
/// - COMPLIANCE mode: Cannot be deleted even with bypass header
|
||||
/// - GOVERNANCE mode: Can be deleted if bypass_governance is true (caller must verify s3:BypassGovernanceRetention permission)
|
||||
/// - Legal Hold: Cannot be bypassed regardless of mode
|
||||
pub async fn check_object_lock_for_deletion(
|
||||
bucket: &str,
|
||||
pub(crate) fn check_object_lock_for_deletion_with_config(
|
||||
config: Option<&ObjectLockConfiguration>,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Option<ObjectLockBlockReason> {
|
||||
) -> Result<Option<ObjectLockBlockReason>> {
|
||||
if obj_info.delete_marker {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 1. Check legal hold - cannot be bypassed (reuse has_legal_hold)
|
||||
if has_legal_hold(&obj_info.user_defined) {
|
||||
return Some(ObjectLockBlockReason::LegalHold);
|
||||
}
|
||||
|
||||
// 2. Check explicit retention
|
||||
let explicit_ret = objectlock::get_object_retention_meta(&obj_info.user_defined);
|
||||
if let Some(mode) = &explicit_ret.mode {
|
||||
let mode_str = mode.as_str();
|
||||
if is_retention_active(mode_str, explicit_ret.retain_until_date.as_ref())
|
||||
&& let Some(reason) = check_retention_blocks_deletion(
|
||||
mode_str,
|
||||
explicit_ret.retain_until_date.map(OffsetDateTime::from),
|
||||
bypass_governance,
|
||||
)
|
||||
{
|
||||
return Some(reason);
|
||||
if let Some(status) = obj_info.user_defined.get(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str()) {
|
||||
if status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::ON) {
|
||||
return Ok(Some(ObjectLockBlockReason::LegalHold));
|
||||
}
|
||||
if !status.eq_ignore_ascii_case(ObjectLockLegalHoldStatus::OFF) {
|
||||
return Err(Error::other("persisted object legal-hold metadata is invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check default retention only if no explicit retention is set
|
||||
if explicit_ret.mode.is_none()
|
||||
&& let Some(default_retention) = BucketObjectLockSys::get(bucket).await
|
||||
let mode = obj_info.user_defined.get(X_AMZ_OBJECT_LOCK_MODE.as_str());
|
||||
let retain_until = obj_info.user_defined.get(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str());
|
||||
let explicit_ret = match (mode, retain_until) {
|
||||
(None, None) => None,
|
||||
(Some(mode), Some(retain_until)) => {
|
||||
let mode =
|
||||
objectlock::parse_ret_mode(mode).ok_or_else(|| Error::other("persisted object retention mode is invalid"))?;
|
||||
let retain_until = OffsetDateTime::parse(retain_until, &time::format_description::well_known::Iso8601::DEFAULT)
|
||||
.map(Date::from)
|
||||
.map_err(|_| Error::other("persisted object retention date is invalid"))?;
|
||||
Some((mode, retain_until))
|
||||
}
|
||||
_ => return Err(Error::other("persisted object retention metadata is incomplete")),
|
||||
};
|
||||
|
||||
if let Some((mode, retain_until)) = &explicit_ret {
|
||||
let mode_str = mode.as_str();
|
||||
if is_retention_active(mode_str, Some(retain_until))
|
||||
&& let Some(reason) =
|
||||
check_retention_blocks_deletion(mode_str, Some(OffsetDateTime::from(retain_until.clone())), bypass_governance)
|
||||
{
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
}
|
||||
|
||||
if explicit_ret.is_none()
|
||||
&& let Some(default_retention) = config.and_then(|config| config.rule.as_ref()?.default_retention.as_ref())
|
||||
&& let Some(mode) = &default_retention.mode
|
||||
{
|
||||
let mode_str = mode.as_str();
|
||||
if mode_str == ObjectLockRetentionMode::COMPLIANCE || mode_str == ObjectLockRetentionMode::GOVERNANCE {
|
||||
// Calculate retention expiration date from object modification time
|
||||
if let Some(mod_time) = obj_info.mod_time {
|
||||
let now = objectlock::utc_now_ntp();
|
||||
let retain_until = if let Some(days) = default_retention.days {
|
||||
mod_time.saturating_add(time::Duration::days(days as i64))
|
||||
} else {
|
||||
let years = default_retention.years?;
|
||||
add_years(mod_time, years)
|
||||
};
|
||||
let mod_time = obj_info
|
||||
.mod_time
|
||||
.ok_or_else(|| Error::other("persisted object modification time is missing"))?;
|
||||
let now = objectlock::utc_now_ntp();
|
||||
let retain_until = if let Some(days) = default_retention.days {
|
||||
mod_time.saturating_add(time::Duration::days(i64::from(days)))
|
||||
} else {
|
||||
let years = default_retention
|
||||
.years
|
||||
.ok_or_else(|| Error::other("persisted bucket Object Lock retention period is invalid"))?;
|
||||
add_years(mod_time, years)
|
||||
};
|
||||
|
||||
if retain_until.unix_timestamp() > now.unix_timestamp()
|
||||
&& let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance)
|
||||
{
|
||||
return Some(reason);
|
||||
}
|
||||
if retain_until.unix_timestamp() > now.unix_timestamp()
|
||||
&& let Some(reason) = check_retention_blocks_deletion(mode_str, Some(retain_until), bypass_governance)
|
||||
{
|
||||
return Ok(Some(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn check_object_lock_for_deletion_with_state(
|
||||
state: &ObjectLockConfigState,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Result<Option<ObjectLockBlockReason>> {
|
||||
match state {
|
||||
ObjectLockConfigState::Configured { config, .. } => {
|
||||
check_object_lock_for_deletion_with_config(Some(config), obj_info, bypass_governance)
|
||||
}
|
||||
ObjectLockConfigState::ConfirmedAbsent => check_object_lock_for_deletion_with_config(None, obj_info, bypass_governance),
|
||||
ObjectLockConfigState::Fabricated => Err(Error::other("bucket Object Lock metadata is not authoritative")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility wrapper for callers that predate fallible metadata lookup.
|
||||
/// An authority/read/parse failure is represented as a blocking reason rather
|
||||
/// than the old fail-open `None` result.
|
||||
pub async fn check_object_lock_for_deletion(
|
||||
bucket: &str,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> Option<ObjectLockBlockReason> {
|
||||
match get_object_lock_config_state(bucket)
|
||||
.await
|
||||
.and_then(|state| check_object_lock_for_deletion_with_state(&state, obj_info, bypass_governance))
|
||||
{
|
||||
Ok(reason) => reason,
|
||||
Err(_) => Some(ObjectLockBlockReason::LegalHold),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use s3s::dto::{ObjectLockEnabled, ObjectLockRule};
|
||||
use time::{Date, Month, PrimitiveDateTime, Time};
|
||||
|
||||
fn make_datetime(year: i32, month: u8, day: u8) -> OffsetDateTime {
|
||||
@@ -278,6 +345,160 @@ mod tests {
|
||||
PrimitiveDateTime::new(date, time).assume_utc()
|
||||
}
|
||||
|
||||
fn default_retention_config(mode: &'static str) -> ObjectLockConfiguration {
|
||||
ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from_static(mode)),
|
||||
days: Some(30),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_config_blocks_active_default_compliance_even_with_bypass() {
|
||||
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
||||
let obj_info = ObjectInfo {
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true);
|
||||
|
||||
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_config_allows_active_default_governance_with_bypass() {
|
||||
let config = default_retention_config(ObjectLockRetentionMode::GOVERNANCE);
|
||||
let obj_info = ObjectInfo {
|
||||
mod_time: Some(OffsetDateTime::now_utc()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
check_object_lock_for_deletion_with_config(Some(&config), &obj_info, true),
|
||||
Ok(None)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_default_retention_rejects_missing_object_mod_time() {
|
||||
let config = default_retention_config(ObjectLockRetentionMode::COMPLIANCE);
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(Some(&config), &ObjectInfo::default(), false)
|
||||
.expect_err("default retention needs an authoritative object modification time");
|
||||
|
||||
assert!(err.to_string().contains("modification time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_confirmed_absence_still_blocks_explicit_compliance() {
|
||||
let retain_until = OffsetDateTime::now_utc() + time::Duration::days(30);
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
user_defined.insert("x-amz-object-lock-mode".to_string(), ObjectLockRetentionMode::COMPLIANCE.to_string());
|
||||
user_defined.insert(
|
||||
"x-amz-object-lock-retain-until-date".to_string(),
|
||||
retain_until
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.expect("retain-until date should format"),
|
||||
);
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = check_object_lock_for_deletion_with_config(None, &obj_info, true);
|
||||
|
||||
assert!(matches!(result, Ok(Some(ObjectLockBlockReason::Retention { .. }))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_with_fabricated_bucket_metadata_fails_closed() {
|
||||
let err = check_object_lock_for_deletion_with_state(&ObjectLockConfigState::Fabricated, &ObjectInfo::default(), false)
|
||||
.expect_err("non-authoritative Object Lock metadata must block deletion");
|
||||
|
||||
assert!(err.to_string().contains("not authoritative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_force_delete_with_fabricated_bucket_metadata_fails_closed() {
|
||||
let err = ensure_recursive_force_delete_allowed_for_state("bucket", &ObjectLockConfigState::Fabricated)
|
||||
.expect_err("non-authoritative Object Lock metadata must block recursive deletion");
|
||||
|
||||
assert!(err.to_string().contains("not authoritative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_rejects_incomplete_persisted_retention_metadata() {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
user_defined.insert(
|
||||
X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(),
|
||||
ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
||||
);
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
||||
.expect_err("mode without retain-until date must fail closed");
|
||||
|
||||
assert!(err.to_string().contains("incomplete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_rejects_each_malformed_persisted_retention_shape() {
|
||||
let valid_date = (OffsetDateTime::now_utc() + time::Duration::days(30))
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.expect("retain-until date should format");
|
||||
let cases = [
|
||||
("invalid mode", Some("INVALID"), Some(valid_date.as_str()), "retention mode"),
|
||||
(
|
||||
"invalid date",
|
||||
Some(ObjectLockRetentionMode::COMPLIANCE),
|
||||
Some("not-a-date"),
|
||||
"retention date",
|
||||
),
|
||||
("date only", None, Some(valid_date.as_str()), "incomplete"),
|
||||
];
|
||||
|
||||
for (case, mode, retain_until, expected) in cases {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
if let Some(mode) = mode {
|
||||
user_defined.insert(X_AMZ_OBJECT_LOCK_MODE.as_str().to_string(), mode.to_string());
|
||||
}
|
||||
if let Some(retain_until) = retain_until {
|
||||
user_defined.insert(X_AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE.as_str().to_string(), retain_until.to_string());
|
||||
}
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false).expect_err(case);
|
||||
assert!(err.to_string().contains(expected), "unexpected {case} error: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_rejects_invalid_persisted_legal_hold_metadata() {
|
||||
let mut user_defined = std::collections::HashMap::new();
|
||||
user_defined.insert(X_AMZ_OBJECT_LOCK_LEGAL_HOLD.as_str().to_string(), "INVALID".to_string());
|
||||
let obj_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = check_object_lock_for_deletion_with_config(None, &obj_info, false)
|
||||
.expect_err("invalid legal-hold value must fail closed");
|
||||
|
||||
assert!(err.to_string().contains("legal-hold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_years_normal() {
|
||||
// Normal case: add 1 year to a regular date
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
|
||||
use crate::bucket::metadata_sys::{BucketMetadataSys, update};
|
||||
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
|
||||
use crate::data_usage::get_bucket_usage_memory;
|
||||
use rustfs_common::metrics::Metric;
|
||||
use rustfs_config::QUOTA_CONFIG_FILE;
|
||||
@@ -145,14 +145,35 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
pub async fn set_quota_config(&mut self, bucket: &str, quota: BucketQuota) -> Result<OffsetDateTime, QuotaError> {
|
||||
self.set_quota_config_for_incarnation(bucket, quota, None).await
|
||||
}
|
||||
|
||||
pub async fn set_quota_config_if_incarnation(
|
||||
&mut self,
|
||||
bucket: &str,
|
||||
quota: BucketQuota,
|
||||
expected_incarnation_id: uuid::Uuid,
|
||||
) -> Result<OffsetDateTime, QuotaError> {
|
||||
self.set_quota_config_for_incarnation(bucket, quota, Some(expected_incarnation_id))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_quota_config_for_incarnation(
|
||||
&mut self,
|
||||
bucket: &str,
|
||||
quota: BucketQuota,
|
||||
expected_incarnation_id: Option<uuid::Uuid>,
|
||||
) -> Result<OffsetDateTime, QuotaError> {
|
||||
let json_data = serde_json::to_vec("a).map_err(|e| QuotaError::InvalidConfig {
|
||||
reason: format!("Failed to serialize quota config: {}", e),
|
||||
})?;
|
||||
let start_time = Instant::now();
|
||||
|
||||
let updated_at = update(bucket, QUOTA_CONFIG_FILE, json_data)
|
||||
.await
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
let updated_at = match expected_incarnation_id {
|
||||
Some(incarnation_id) => update_if_incarnation(bucket, QUOTA_CONFIG_FILE, json_data, incarnation_id).await,
|
||||
None => update(bucket, QUOTA_CONFIG_FILE, json_data).await,
|
||||
}
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
|
||||
rustfs_common::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
Ok(updated_at)
|
||||
@@ -177,11 +198,36 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
pub async fn get_real_time_usage(&self, bucket: &str) -> Result<u64, QuotaError> {
|
||||
get_bucket_usage_memory(bucket)
|
||||
.await
|
||||
.ok_or_else(|| QuotaError::UsageUnavailable {
|
||||
bucket: bucket.to_string(),
|
||||
})
|
||||
if let Some(usage) = get_bucket_usage_memory(bucket).await {
|
||||
return Ok(usage);
|
||||
}
|
||||
|
||||
// Degraded window (issue #5716): with no authoritative usage — most
|
||||
// prominently after upgrading from a pre-v2 release, whose legacy
|
||||
// `.usage.json` is demoted to non-authoritative until the scanner's
|
||||
// first complete cycle persists `.usage.v2.json` — failing closed
|
||||
// turned every write to a quota-enabled bucket into a retryable 503
|
||||
// for the whole window. Quota admission instead degrades to the last
|
||||
// persisted per-bucket size. That baseline is static between snapshot
|
||||
// loads (live writes do not advance it), so hard-quota enforcement is
|
||||
// advisory for the duration of the window: the overrun is bounded by
|
||||
// the writes issued before the next complete scanner cycle. Buckets
|
||||
// with no persisted baseline anywhere keep failing closed.
|
||||
let store = self.metadata_sys.read().await.object_store();
|
||||
// Box the fallback: it embeds the whole snapshot-load future, and every
|
||||
// object write nests a quota check several futures deep, so keeping it
|
||||
// inline would grow each write's state machine by the loader's full
|
||||
// size — the debug-build 2MiB worker-stack overflow class fixed for
|
||||
// bucket-config writes in #5648. The allocation only happens on the
|
||||
// degraded path; the authoritative fast path returns above.
|
||||
if let Some(baseline) = Box::pin(crate::data_usage::lookup_degraded_bucket_usage_baseline(store, bucket)).await {
|
||||
debug!(bucket, baseline, "Bucket quota admission using degraded persisted usage baseline");
|
||||
return Ok(baseline);
|
||||
}
|
||||
|
||||
Err(QuotaError::UsageUnavailable {
|
||||
bucket: bucket.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +257,76 @@ mod tests {
|
||||
assert_eq!(result.quota_limit, None);
|
||||
}
|
||||
|
||||
/// Regression (issue #5716): an upgrade from a pre-v2 release leaves only
|
||||
/// the legacy `.usage.json` snapshot, which has no completeness marker and
|
||||
/// is demoted to non-authoritative, and the scanner's first complete cycle
|
||||
/// can be a long way off. Quota admission must degrade to that persisted
|
||||
/// baseline instead of failing every write to a quota-enabled bucket with
|
||||
/// a retryable 503 for the whole window.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_admission_falls_back_to_legacy_snapshot_baseline() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore.clone())));
|
||||
let checker = QuotaChecker::new(sys);
|
||||
let bucket = format!("quota-legacy-{}", Uuid::new_v4().simple());
|
||||
|
||||
let mut legacy = rustfs_data_usage::DataUsageInfo {
|
||||
last_update: Some(std::time::SystemTime::now()),
|
||||
buckets_count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
legacy.buckets_usage.insert(
|
||||
bucket.clone(),
|
||||
rustfs_data_usage::BucketUsageInfo {
|
||||
size: 1_234,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
legacy.bucket_sizes.insert(bucket.clone(), 1_234);
|
||||
// usage_snapshot_complete stays false: pre-v2 snapshots do not carry
|
||||
// the field at all, so they always deserialize as incomplete.
|
||||
let legacy_path = format!("{}/{}", crate::disk::BUCKET_META_PREFIX, rustfs_data_usage::LEGACY_DATA_USAGE_OBJECT_NAME);
|
||||
crate::config::com::save_config(
|
||||
ecstore.clone(),
|
||||
&legacy_path,
|
||||
serde_json::to_vec(&legacy).expect("legacy snapshot should encode"),
|
||||
)
|
||||
.await
|
||||
.expect("legacy snapshot fixture should be stored");
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
|
||||
let usage = checker
|
||||
.get_real_time_usage(&bucket)
|
||||
.await
|
||||
.expect("quota admission must degrade to the persisted legacy baseline");
|
||||
assert_eq!(usage, 1_234);
|
||||
|
||||
// A bucket absent from every persisted snapshot still has no grounded
|
||||
// baseline and must keep failing closed.
|
||||
let unknown = format!("quota-unknown-{}", Uuid::new_v4().simple());
|
||||
assert!(matches!(
|
||||
checker.get_real_time_usage(&unknown).await,
|
||||
Err(QuotaError::UsageUnavailable { .. })
|
||||
));
|
||||
|
||||
// Deleting the bucket's usage from the backend must purge the
|
||||
// baseline: a recreated bucket may not inherit the dead incarnation's
|
||||
// size, so with no persisted trace left it fails closed again.
|
||||
crate::data_usage::remove_bucket_usage_from_backend(ecstore.clone(), &bucket)
|
||||
.await
|
||||
.expect("bucket usage removal should succeed");
|
||||
assert!(matches!(
|
||||
checker.get_real_time_usage(&bucket).await,
|
||||
Err(QuotaError::UsageUnavailable { .. })
|
||||
));
|
||||
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
.expect("test usage cache cleanup should succeed");
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_usage_rejects_an_unknown_mutation_baseline() {
|
||||
|
||||
@@ -100,11 +100,41 @@ paths.
|
||||
behind the ECStore replication facade; only `rustfs/src/app/storage_api.rs`
|
||||
may retain direct object/delete replication helper calls.
|
||||
|
||||
## First Code-Bearing Step
|
||||
## Completion Criteria
|
||||
|
||||
Start with `ReplicationRuntime` or `ReplicationEventSink`. Both can be added as
|
||||
narrow internal contracts while keeping current queue, MRF, resync, and target
|
||||
behavior unchanged. Do not start with a crate move.
|
||||
The split is complete when the "Current dependency to remove" column in the
|
||||
Required Contracts table above is empty: every row is either deleted because
|
||||
the dependency is gone, or reduced to "none". No other signal — file count,
|
||||
boundary count, line count — measures completion.
|
||||
|
||||
Target end state:
|
||||
|
||||
- `replication_pool.rs`, `replication_resyncer.rs`, and `replication_state.rs`
|
||||
move into `crates/replication` behind the contracts above;
|
||||
- the `*_boundary.rs` and `*_bridge.rs` micro-files dissolve naturally as the
|
||||
code they fence moves across the crate boundary. They are the mechanical
|
||||
seams of the migration ratchet — the architecture guard scripts anchor on
|
||||
their file names — so batch-merging them beforehand is explicitly rejected:
|
||||
it forces synchronized guard-script/mod/import churn with zero functional
|
||||
gain;
|
||||
- the only module that can retire early is `datatypes.rs`: delete it once its
|
||||
facade consumers import the resync status enums through `rustfs-replication`
|
||||
directly.
|
||||
|
||||
## Milestones
|
||||
|
||||
| Milestone | Scope | Status |
|
||||
|---|---|---|
|
||||
| M0 | Record the completion criteria and end state (this section). | Done |
|
||||
| M1 | Contract extraction: resync/queue/stats/object-decision/filemeta/storage wire contracts owned by `crates/replication`; ECStore imports concentrated in `*_boundary.rs`; event sink and runtime access behind local contracts. | Done — see Required Contracts |
|
||||
| M2 | Move resyncer pure decision logic (no IO) into `crates/replication`. | Pending; sequence after splitting the oversized resyncer/pool functions (`resync_bucket`, `replicate_all`, `start_mrf_processor`) so moves stay mechanical |
|
||||
| M3 | Move the worker runtime (`replication_pool.rs`, the IO paths of `replication_resyncer.rs`, `replication_state.rs`) once the contract traits are stable. Highest-risk step of the whole plan; do it last. | Pending |
|
||||
| M4 | Retire the boundary modules together with their guard-script entries; delete `datatypes.rs`. | Pending |
|
||||
|
||||
The original first code-bearing step (narrow `ReplicationEventSink` /
|
||||
`ReplicationRuntime` contracts) has landed — `replication_event_sink.rs`
|
||||
exists and runtime access goes through local boundary aliases — so new work
|
||||
starts from M2.
|
||||
|
||||
Current compatibility guard: `crates/ecstore/tests/replication_facade_compat_test.rs`
|
||||
keeps the ECStore replication facade types covered while architecture rules
|
||||
|
||||
@@ -47,9 +47,9 @@ pub use datatypes::ResyncStatusType;
|
||||
pub use replication_config_boundary::{
|
||||
ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS,
|
||||
REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS,
|
||||
ReplicationConfigurationExt, ReplicationTargetValidationError, invalid_replication_config_status_field,
|
||||
replication_target_arns, should_remove_replication_target, unsupported_replication_config_field,
|
||||
validate_replication_config_target_arns,
|
||||
ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationTargetValidationError,
|
||||
invalid_replication_config_status_field, replication_target_arns, should_remove_replication_target,
|
||||
unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns,
|
||||
};
|
||||
pub(crate) use replication_filemeta_boundary::version_purge_statuses_map;
|
||||
pub use replication_filemeta_boundary::{
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
pub use rustfs_replication::{
|
||||
ObjectOpts, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS,
|
||||
REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS,
|
||||
ReplicationConfigurationExt, ReplicationRuleExt, ReplicationTargetValidationError, invalid_replication_config_status_field,
|
||||
replication_target_arns, should_remove_replication_target, unsupported_replication_config_field,
|
||||
validate_replication_config_target_arns,
|
||||
ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationRuleExt, ReplicationTargetValidationError,
|
||||
invalid_replication_config_status_field, replication_target_arns, should_remove_replication_target,
|
||||
unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::replication_error_boundary::Result;
|
||||
use super::replication_storage_boundary::ReplicationObjectIO;
|
||||
use super::replication_storage_boundary::{HTTPPreconditions, ObjectInfo, ObjectOptions, ReplicationObjectIO};
|
||||
use crate::config::{com, storageclass};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -30,6 +30,13 @@ impl ReplicationConfigStore {
|
||||
com::read_config(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_limited<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::read_config_limited(api, file, max_bytes).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_no_lock<S>(api: Arc<S>, file: &str) -> Result<Vec<u8>>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
@@ -37,6 +44,28 @@ impl ReplicationConfigStore {
|
||||
com::read_config_no_lock(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_no_lock_with_metadata<S>(api: Arc<S>, file: &str) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::read_config_with_metadata(
|
||||
api,
|
||||
file,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_no_lock_with_metadata_preserve_empty<S>(api: Arc<S>, file: &str) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::read_config_no_lock_preserve_empty_with_metadata(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
@@ -50,4 +79,49 @@ impl ReplicationConfigStore {
|
||||
{
|
||||
com::save_config_no_lock(api, file, data).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_conditional<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
data: Vec<u8>,
|
||||
http_preconditions: HTTPPreconditions,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::save_config_with_opts_quiet(
|
||||
api,
|
||||
file,
|
||||
data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
http_preconditions: Some(http_preconditions),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_conditional_no_lock<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
data: Vec<u8>,
|
||||
http_preconditions: HTTPPreconditions,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::save_config_with_opts_quiet(
|
||||
api,
|
||||
file,
|
||||
data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
no_lock: true,
|
||||
http_preconditions: Some(http_preconditions),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ pub(crate) fn replication_state_from_filemeta(state: &rustfs_filemeta::Replicati
|
||||
.map(|(arn, status)| (arn.clone(), version_purge_status_from_filemeta(status.clone())))
|
||||
.collect(),
|
||||
reset_statuses_map: state.reset_statuses_map.clone(),
|
||||
target_delete_marker_version_ids: state.target_delete_marker_version_ids.clone(),
|
||||
target_delete_marker_version_ids_corrupt: state.target_delete_marker_version_ids_corrupt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,5 +85,120 @@ pub fn replication_state_to_filemeta(state: &ReplicationState) -> rustfs_filemet
|
||||
.map(|(arn, status)| (arn.clone(), version_purge_status_to_filemeta(status.clone())))
|
||||
.collect(),
|
||||
reset_statuses_map: state.reset_statuses_map.clone(),
|
||||
target_delete_marker_version_ids: state.target_delete_marker_version_ids.clone(),
|
||||
target_delete_marker_version_ids_corrupt: state.target_delete_marker_version_ids_corrupt,
|
||||
}
|
||||
}
|
||||
|
||||
// Reconciliation tests for the deliberately duplicated wire types.
|
||||
//
|
||||
// `rustfs-filemeta` (xl.meta disk format) and `rustfs-replication` (MRF/resync
|
||||
// persistence format) each own a copy of `ReplicationStatusType`,
|
||||
// `VersionPurgeStatusType` and `ReplicationState`; the conversions above hop
|
||||
// between them via `as_str()`, whose `From<&str>` impls fall back to `Empty`
|
||||
// on any unknown token. That fallback silently degrades data the moment one
|
||||
// side gains a variant the other lacks, so these tests pin the two sides
|
||||
// together:
|
||||
//
|
||||
// - the `match` statements are exhaustive with no `_` arm — adding a variant
|
||||
// on either side fails compilation here until the mapping is reconsidered;
|
||||
// - the round-trips assert the string token survives both directions — a
|
||||
// variant whose token the other side does not recognize fails the assert
|
||||
// instead of quietly becoming `Empty`.
|
||||
//
|
||||
// Struct-shaped drift on `ReplicationState` is already compile-guarded by the
|
||||
// exhaustive struct literals in the two conversion functions above; the
|
||||
// round-trip test below additionally pins value fidelity for every field.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn replication_status_variants_round_trip_across_boundary() {
|
||||
use rustfs_replication::ReplicationStatusType as Repl;
|
||||
|
||||
let all = [
|
||||
Repl::Pending,
|
||||
Repl::Completed,
|
||||
Repl::CompletedLegacy,
|
||||
Repl::Failed,
|
||||
Repl::Replica,
|
||||
Repl::Empty,
|
||||
];
|
||||
for status in all {
|
||||
// Exhaustive on the replication side: a new variant breaks this match.
|
||||
match status {
|
||||
Repl::Pending | Repl::Completed | Repl::CompletedLegacy | Repl::Failed | Repl::Replica | Repl::Empty => {}
|
||||
}
|
||||
let filemeta = replication_status_to_filemeta(status.clone());
|
||||
assert_eq!(
|
||||
filemeta.as_str(),
|
||||
status.as_str(),
|
||||
"replication->filemeta conversion must not degrade {status:?} (unknown tokens fall back to Empty)"
|
||||
);
|
||||
assert_eq!(replication_status_from_filemeta(filemeta), status);
|
||||
}
|
||||
|
||||
// Exhaustive on the filemeta side: a new variant breaks this match.
|
||||
fn _filemeta_side_is_covered(status: rustfs_filemeta::ReplicationStatusType) {
|
||||
use rustfs_filemeta::ReplicationStatusType as Meta;
|
||||
match status {
|
||||
Meta::Pending | Meta::Completed | Meta::CompletedLegacy | Meta::Failed | Meta::Replica | Meta::Empty => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_purge_status_variants_round_trip_across_boundary() {
|
||||
use rustfs_replication::VersionPurgeStatusType as Repl;
|
||||
|
||||
let all = [Repl::Pending, Repl::Complete, Repl::Failed, Repl::Empty];
|
||||
for status in all {
|
||||
// Exhaustive on the replication side: a new variant breaks this match.
|
||||
match status {
|
||||
Repl::Pending | Repl::Complete | Repl::Failed | Repl::Empty => {}
|
||||
}
|
||||
let filemeta = version_purge_status_to_filemeta(status.clone());
|
||||
assert_eq!(
|
||||
filemeta.as_str(),
|
||||
status.as_str(),
|
||||
"replication->filemeta conversion must not degrade {status:?} (unknown tokens fall back to Empty)"
|
||||
);
|
||||
assert_eq!(version_purge_status_from_filemeta(filemeta), status);
|
||||
}
|
||||
|
||||
// Exhaustive on the filemeta side: a new variant breaks this match.
|
||||
fn _filemeta_side_is_covered(status: rustfs_filemeta::VersionPurgeStatusType) {
|
||||
use rustfs_filemeta::VersionPurgeStatusType as Meta;
|
||||
match status {
|
||||
Meta::Pending | Meta::Complete | Meta::Failed | Meta::Empty => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_state_round_trips_every_field_across_boundary() {
|
||||
let timestamp = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp");
|
||||
let state = ReplicationState {
|
||||
replica_timestamp: Some(timestamp),
|
||||
replica_status: ReplicationStatusType::Replica,
|
||||
delete_marker: true,
|
||||
replication_timestamp: Some(timestamp),
|
||||
replication_status_internal: Some("arn:a=PENDING;".to_string()),
|
||||
version_purge_status_internal: Some("arn:a=FAILED;".to_string()),
|
||||
replicate_decision_str: "arn:a=true;false;;".to_string(),
|
||||
targets: HashMap::from([
|
||||
("arn:a".to_string(), ReplicationStatusType::Completed),
|
||||
("arn:b".to_string(), ReplicationStatusType::Failed),
|
||||
]),
|
||||
purge_targets: HashMap::from([("arn:a".to_string(), VersionPurgeStatusType::Pending)]),
|
||||
reset_statuses_map: HashMap::from([("reset-arn:a".to_string(), "reset-id;ts".to_string())]),
|
||||
target_delete_marker_version_ids: HashMap::from([("arn:a".to_string(), "version-1".to_string())]),
|
||||
target_delete_marker_version_ids_corrupt: true,
|
||||
};
|
||||
|
||||
let round_tripped = replication_state_from_filemeta(&replication_state_to_filemeta(&state));
|
||||
assert_eq!(round_tripped, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ pub(crate) struct ReplicationMetadataStore;
|
||||
|
||||
impl ReplicationMetadataStore {
|
||||
pub(crate) const MRF_REPLICATION_FILE: &'static str = "config/replication/mrf.bin";
|
||||
pub(crate) const MRF_REPLICATION_RECOVERY_LOCK: &'static str = "config/replication/mrf.bin.recovery";
|
||||
pub(crate) const FORCE_DELETE_REPLICATION_FILE: &'static str = "config/replication/force-delete.bin";
|
||||
pub(crate) const FORCE_DELETE_REPLICATION_TRANSACTION_LOCK: &'static str = "config/replication/force-delete.bin.transaction";
|
||||
|
||||
pub(crate) async fn replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_replication_config(bucket).await
|
||||
@@ -110,9 +112,17 @@ mod tests {
|
||||
"buckets/bucket-a/.replication/resync.bin"
|
||||
);
|
||||
assert_eq!(ReplicationMetadataStore::MRF_REPLICATION_FILE, "config/replication/mrf.bin");
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::MRF_REPLICATION_RECOVERY_LOCK,
|
||||
"config/replication/mrf.bin.recovery"
|
||||
);
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::FORCE_DELETE_REPLICATION_FILE,
|
||||
"config/replication/force-delete.bin"
|
||||
);
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::FORCE_DELETE_REPLICATION_TRANSACTION_LOCK,
|
||||
"config/replication/force-delete.bin.transaction"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +566,7 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
|
||||
let mut sopts = opts.clone();
|
||||
sopts.target_arn = arn.clone();
|
||||
|
||||
let replicate = cfg.replicate(&sopts);
|
||||
let replicate = cfg.replicate(&sopts) && mopts.metadata_target_is_eligible(&arn);
|
||||
let synchronous = if let Some(cli) = cli { cli.replicate_sync } else { false };
|
||||
|
||||
dsc.set(ReplicateTargetDecision::new(arn, replicate, synchronous));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ pub(crate) use rustfs_replication::{
|
||||
|
||||
pub(crate) const RESYNC_META_FORMAT: u16 = rustfs_replication::resync::RESYNC_META_FORMAT;
|
||||
pub(crate) const RESYNC_META_VERSION: u16 = rustfs_replication::resync::RESYNC_META_VERSION;
|
||||
pub(crate) const RESYNC_FILE_MAX_BYTES: usize = rustfs_replication::RESYNC_FILE_MAX_BYTES;
|
||||
pub(crate) const WIRE_ZERO_TIME_UNIX: i64 = rustfs_replication::resync::WIRE_ZERO_TIME_UNIX;
|
||||
pub(crate) const MRF_META_FORMAT: u16 = rustfs_replication::mrf::MRF_META_FORMAT;
|
||||
pub(crate) const MRF_META_VERSION: u16 = rustfs_replication::mrf::MRF_META_VERSION;
|
||||
|
||||
@@ -1203,12 +1203,19 @@ pub(crate) async fn save_resync_status<S: ReplicationObjectIO>(
|
||||
}
|
||||
|
||||
pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicationInfo, storage: Arc<S>) {
|
||||
let _ = replicate_delete_with_outcome(dobj, storage).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
|
||||
dobj: DeletedObjectReplicationInfo,
|
||||
storage: Arc<S>,
|
||||
) -> bool {
|
||||
if dobj.delete_object.force_delete {
|
||||
replicate_force_delete_to_targets(&dobj, storage).await;
|
||||
return;
|
||||
return replicate_force_delete_to_targets(&dobj, storage).await;
|
||||
}
|
||||
|
||||
let bucket = dobj.bucket.clone();
|
||||
let mut source_state_verified = true;
|
||||
let version_id = if let Some(version_id) = &dobj.delete_object.delete_marker_version_id {
|
||||
Some(version_id.to_owned())
|
||||
} else {
|
||||
@@ -1245,7 +1252,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
reason = "source_not_delete_marker",
|
||||
"Skipping stale delete-marker replication"
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {
|
||||
debug!(
|
||||
@@ -1258,9 +1265,10 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
reason = "source_version_missing",
|
||||
"Skipping stale delete-marker replication"
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
Err(err) => {
|
||||
source_state_verified = false;
|
||||
debug!(
|
||||
event = EVENT_REPLICATION_DELETE_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -1310,7 +1318,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let ns_lock = match storage
|
||||
@@ -1342,7 +1350,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1372,7 +1380,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1386,6 +1394,11 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
|
||||
// Process each target
|
||||
let target_arns = dobj.admitted_target_arns();
|
||||
let expected_targets = dsc
|
||||
.targets_map
|
||||
.values()
|
||||
.filter(|target| target.replicate && (target_arns.is_empty() || target_arns.iter().any(|arn| arn == &target.arn)))
|
||||
.count();
|
||||
for tgt_entry in dsc.targets_map.values() {
|
||||
// Skip targets that should not be replicated
|
||||
if !tgt_entry.replicate {
|
||||
@@ -1465,7 +1478,8 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
|
||||
let is_version_purge = is_version_delete_replication(&dobj.delete_object);
|
||||
|
||||
if should_retry_delete_marker_purge(&dobj.delete_object) {
|
||||
let requires_delayed_purge = should_retry_delete_marker_purge(&dobj.delete_object);
|
||||
if requires_delayed_purge {
|
||||
let bucket_clone = bucket.clone();
|
||||
let dobj_clone = dobj.clone();
|
||||
let dsc_clone = dsc.clone();
|
||||
@@ -1536,7 +1550,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
EventName::ObjectReplicationFailed.to_string()
|
||||
};
|
||||
|
||||
match storage
|
||||
let state_persisted = match storage
|
||||
.delete_object(
|
||||
&bucket,
|
||||
&dobj.delete_object.object_name,
|
||||
@@ -1558,6 +1572,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
object,
|
||||
..Default::default()
|
||||
});
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
@@ -1583,8 +1598,16 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expected_targets > 0
|
||||
&& rinfos.targets.len() == expected_targets
|
||||
&& state_persisted
|
||||
&& source_state_verified
|
||||
&& !requires_delayed_purge
|
||||
&& replication_status == ReplicationStatusType::Completed
|
||||
}
|
||||
|
||||
async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
|
||||
@@ -1611,6 +1634,29 @@ async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Which version a delete-marker purge should address on one target.
|
||||
///
|
||||
/// `None` means do not purge at all: the recorded mapping disagreed across the
|
||||
/// dual internal prefixes, and guessing an id could destroy a live version on
|
||||
/// the target. `Some(id)` is the exact version the target reported when it
|
||||
/// accepted the marker; falling back to a source-derived id is only correct
|
||||
/// when the target mirrors source version ids, which a generic S3 target does
|
||||
/// not.
|
||||
fn delete_marker_purge_version_id(
|
||||
state: Option<&ReplicationState>,
|
||||
arn: &str,
|
||||
delete_marker_version_id: Uuid,
|
||||
) -> Option<Option<String>> {
|
||||
if state.is_some_and(|state| state.target_delete_marker_version_ids_corrupt) {
|
||||
return None;
|
||||
}
|
||||
let recorded = state.and_then(|state| state.target_delete_marker_version_ids.get(arn).cloned());
|
||||
Some(match recorded {
|
||||
Some(version_id) => Some(version_id),
|
||||
None => target_delete_version_id(delete_marker_version_id, true),
|
||||
})
|
||||
}
|
||||
|
||||
async fn replicate_delete_marker_purge_to_targets(bucket: &str, dobj: &DeletedObjectReplicationInfo, dsc: &ReplicateDecision) {
|
||||
let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id else {
|
||||
return;
|
||||
@@ -1628,18 +1674,34 @@ async fn replicate_delete_marker_purge_to_targets(bucket: &str, dobj: &DeletedOb
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(purge_version_id) = delete_marker_purge_version_id(
|
||||
dobj.delete_object.replication_state.as_ref(),
|
||||
&tgt_entry.arn,
|
||||
delete_marker_version_id,
|
||||
) else {
|
||||
warn!(
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||
bucket,
|
||||
object = dobj.delete_object.object_name,
|
||||
arn = tgt_entry.arn,
|
||||
"Skipping delete-marker purge: recorded target version metadata is inconsistent"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let _ = tgt_client
|
||||
.remove_object(
|
||||
&tgt_client.bucket,
|
||||
&dobj.delete_object.object_name,
|
||||
target_delete_version_id(delete_marker_version_id, true),
|
||||
purge_version_id,
|
||||
replication_delete_marker_purge_remove_options(dobj.delete_object.delete_marker_mtime),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &DeletedObjectReplicationInfo, storage: Arc<S>) {
|
||||
async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &DeletedObjectReplicationInfo, storage: Arc<S>) -> bool {
|
||||
let bucket = &dobj.bucket;
|
||||
let object_name = &dobj.delete_object.object_name;
|
||||
let admitted_target_arns = dobj.admitted_target_arns();
|
||||
@@ -1727,7 +1789,7 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1755,7 +1817,7 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1764,6 +1826,9 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
} else {
|
||||
admitted_target_arns
|
||||
};
|
||||
if tgt_arns.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut join_set = JoinSet::new();
|
||||
let mut all_succeeded = true;
|
||||
@@ -1888,7 +1953,10 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
error = %error,
|
||||
"Force-delete replication completed but durable intent cleanup failed"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
all_succeeded
|
||||
}
|
||||
|
||||
fn target_delete_version_id(version_id: Uuid, version_purge: bool) -> Option<String> {
|
||||
@@ -1978,16 +2046,24 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
Ok(assigned_version_id) => {
|
||||
debug!(
|
||||
bucket = tgt_client.bucket,
|
||||
object = dobj.delete_object.object_name,
|
||||
version_id = ?version_id,
|
||||
assigned_version_id = ?assigned_version_id,
|
||||
delete_marker = dobj.delete_object.delete_marker,
|
||||
is_version_purge,
|
||||
"replicate_delete_to_target succeeded"
|
||||
);
|
||||
if !is_version_purge {
|
||||
// Record the version the target actually assigned to the marker it
|
||||
// just created. A later purge addresses that id directly instead of
|
||||
// deriving one from the source uuid, which only holds when the
|
||||
// target mirrors source version ids.
|
||||
if dobj.delete_object.delete_marker {
|
||||
rinfo.target_delete_marker_version_id = assigned_version_id.filter(|version_id| !version_id.is_empty());
|
||||
}
|
||||
rinfo.replication_status = ReplicationStatusType::Completed;
|
||||
} else {
|
||||
rinfo.version_purge_status = VersionPurgeStatusType::Complete;
|
||||
@@ -2034,6 +2110,13 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
||||
}
|
||||
|
||||
pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, storage: Arc<S>) -> ReplicationState {
|
||||
replicate_object_with_outcome(roi, storage).await.0
|
||||
}
|
||||
|
||||
pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
|
||||
roi: ReplicateObjectInfo,
|
||||
storage: Arc<S>,
|
||||
) -> (ReplicationState, bool) {
|
||||
let bucket = roi.bucket.clone();
|
||||
let object = roi.name.clone();
|
||||
|
||||
@@ -2062,7 +2145,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return roi.replication_state.unwrap_or_default();
|
||||
return (roi.replication_state.unwrap_or_default(), false);
|
||||
}
|
||||
};
|
||||
let _obj_lock_guard = match obj_ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
||||
@@ -2085,7 +2168,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
user_agent: "Internal: [Replication]".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return roi.replication_state.unwrap_or_default();
|
||||
return (roi.replication_state.unwrap_or_default(), false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2166,6 +2249,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
let replication_status = merged_state.composite_replication_status();
|
||||
let new_replication_internal = merged_state.replication_status_internal.clone();
|
||||
let mut object_info = roi.to_object_info();
|
||||
let mut state_persisted = true;
|
||||
|
||||
if roi.replication_status_internal != new_replication_internal || rinfos.replication_resynced() {
|
||||
let mut eval_metadata = HashMap::new();
|
||||
@@ -2181,6 +2265,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
match storage.put_object_metadata(&bucket, &object, &popts).await {
|
||||
Ok(u) => object_info = u,
|
||||
Err(e) => {
|
||||
state_persisted = false;
|
||||
// Persisting the resynced replication status failed. Don't swallow
|
||||
// it silently — the object's on-disk status now disagrees with the
|
||||
// resync result and needs operator visibility (backlog#799 B23).
|
||||
@@ -2234,7 +2319,7 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
}
|
||||
}
|
||||
|
||||
merged_state
|
||||
(merged_state, state_persisted)
|
||||
}
|
||||
|
||||
trait ReplicateObjectInfoExt {
|
||||
@@ -2465,6 +2550,12 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
let (put_opts, is_multipart) = match replication_put_object_options(&tgt_client.storage_class, &object_info) {
|
||||
Ok((put_opts, is_mp)) => (put_opts, is_mp),
|
||||
Err(e) => {
|
||||
// Unsupported source metadata (e.g. managed SSE) is a fail-closed
|
||||
// condition: report FAILED so the composite status and the
|
||||
// OperationFailedReplication event reflect that nothing reached
|
||||
// the target, instead of leaking the optimistic Completed above.
|
||||
rinfo.replication_status = ReplicationStatusType::Failed;
|
||||
rinfo.error = Some(e.to_string());
|
||||
warn!(
|
||||
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -2869,6 +2960,11 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
let (put_opts, is_multipart) = match replication_put_object_options(&tgt_client.storage_class, &object_info) {
|
||||
Ok((put_opts, is_mp)) => (put_opts, is_mp),
|
||||
Err(e) => {
|
||||
// Unsupported source metadata (e.g. managed SSE) is a fail-closed
|
||||
// condition: report FAILED so the composite status and the
|
||||
// OperationFailedReplication event reflect that nothing reached
|
||||
// the target, instead of leaking the optimistic Completed above.
|
||||
rinfo.replication_status = ReplicationStatusType::Failed;
|
||||
rinfo.error = Some(e.to_string());
|
||||
warn!(
|
||||
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
|
||||
@@ -3971,4 +4067,35 @@ mod tests {
|
||||
assert_eq!(target_delete_version_id(Uuid::nil(), true).as_deref(), Some(NULL_VERSION_ID));
|
||||
assert_eq!(target_delete_version_id(Uuid::nil(), false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_marker_purge_prefers_the_recorded_target_version() {
|
||||
let source = Uuid::new_v4();
|
||||
let arn = "arn:rustfs:replication::target:bucket";
|
||||
|
||||
// No recorded mapping: fall back to deriving from the source uuid.
|
||||
assert_eq!(delete_marker_purge_version_id(None, arn, source), Some(Some(source.to_string())));
|
||||
|
||||
// Recorded mapping wins — a generic S3 target assigns its own id, so the
|
||||
// derived one would purge the wrong version or nothing at all.
|
||||
let mut state = ReplicationState::default();
|
||||
state
|
||||
.target_delete_marker_version_ids
|
||||
.insert(arn.to_string(), "target-assigned-id".to_string());
|
||||
assert_eq!(
|
||||
delete_marker_purge_version_id(Some(&state), arn, source),
|
||||
Some(Some("target-assigned-id".to_string()))
|
||||
);
|
||||
|
||||
// A mapping recorded for a different ARN must not be reused.
|
||||
assert_eq!(
|
||||
delete_marker_purge_version_id(Some(&state), "arn:rustfs:replication::other:bucket", source),
|
||||
Some(Some(source.to_string()))
|
||||
);
|
||||
|
||||
// Inconsistent persisted metadata: refuse to purge rather than guess.
|
||||
let mut corrupt = state.clone();
|
||||
corrupt.target_delete_marker_version_ids_corrupt = true;
|
||||
assert_eq!(delete_marker_purge_version_id(Some(&corrupt), arn, source), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(crate) use crate::storage_api_contracts::list::{
|
||||
};
|
||||
pub(crate) use crate::storage_api_contracts::namespace::NamespaceLocking as StorageNamespaceLocking;
|
||||
pub(crate) use crate::storage_api_contracts::object::{
|
||||
DeletedObject, EcstoreObjectOperations, ObjectIO, ObjectOperations, ObjectToDelete,
|
||||
DeletedObject, EcstoreObjectOperations, HTTPPreconditions, ObjectIO, ObjectOperations, ObjectToDelete,
|
||||
};
|
||||
pub(crate) use crate::storage_api_contracts::range::HTTPRangeSpec;
|
||||
pub(crate) use rustfs_replication::{DeletedObject as ReplicationDeletedObject, ObjectToDelete as ReplicationObjectToDelete};
|
||||
|
||||
@@ -56,11 +56,59 @@ impl FromStr for ARN {
|
||||
if parts.len() != 6 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid ARN format"));
|
||||
}
|
||||
// Display emits `arn:rustfs:{type}:{region}:{id}:{bucket}`; read the
|
||||
// segments back in the same order so parse(display(a)) == a.
|
||||
Ok(ARN {
|
||||
arn_type: BucketTargetType::from_str(parts[2]).unwrap_or_default(),
|
||||
id: parts[3].to_string(),
|
||||
region: parts[4].to_string(),
|
||||
region: parts[3].to_string(),
|
||||
id: parts[4].to_string(),
|
||||
bucket: parts[5].to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Display emits `arn:rustfs:{type}:{region}:{id}:{bucket}` (madmin layout);
|
||||
/// FromStr must read the same positions back so parse(display(a)) == a.
|
||||
#[test]
|
||||
fn from_str_round_trips_display_with_region_and_id() {
|
||||
let arn = ARN::new(
|
||||
BucketTargetType::ReplicationService,
|
||||
"depl-123".to_string(),
|
||||
"us-east-1".to_string(),
|
||||
"bucket-a".to_string(),
|
||||
);
|
||||
|
||||
let parsed = ARN::from_str(&arn.to_string()).expect("display output must parse");
|
||||
|
||||
assert_eq!(parsed.arn_type, arn.arn_type);
|
||||
assert_eq!(parsed.region, arn.region, "region must survive display->parse round-trip");
|
||||
assert_eq!(parsed.id, arn.id, "id must survive display->parse round-trip");
|
||||
assert_eq!(parsed.bucket, arn.bucket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_reads_region_then_id_in_display_order() {
|
||||
let parsed = ARN::from_str("arn:rustfs:replication:us-east-1:depl-123:bucket-a").expect("valid ARN must parse");
|
||||
|
||||
assert_eq!(parsed.arn_type, BucketTargetType::ReplicationService);
|
||||
assert_eq!(parsed.region, "us-east-1");
|
||||
assert_eq!(parsed.id, "depl-123");
|
||||
assert_eq!(parsed.bucket, "bucket-a");
|
||||
}
|
||||
|
||||
/// RustFS commonly generates ARNs with an empty region:
|
||||
/// `arn:rustfs:replication::<deployment_id>:<bucket>`.
|
||||
#[test]
|
||||
fn from_str_handles_empty_region_segment() {
|
||||
let parsed = ARN::from_str("arn:rustfs:replication::depl-123:bucket-a").expect("valid ARN must parse");
|
||||
|
||||
assert_eq!(parsed.arn_type, BucketTargetType::ReplicationService);
|
||||
assert_eq!(parsed.region, "", "region segment is empty in this form");
|
||||
assert_eq!(parsed.id, "depl-123");
|
||||
assert_eq!(parsed.bucket, "bucket-a");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use jiff::Timestamp;
|
||||
use rmp_serde::Serializer as rmpSerializer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
@@ -32,7 +33,7 @@ pub struct Credentials {
|
||||
#[serde(rename = "secretKey")]
|
||||
pub secret_key: String,
|
||||
pub session_token: Option<String>,
|
||||
pub expiration: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub expiration: Option<Timestamp>,
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
@@ -93,6 +94,21 @@ mod duration_milliseconds {
|
||||
}
|
||||
}
|
||||
|
||||
/// Defensive decode for the two integer wire encodings of these duration
|
||||
/// fields: RustFS persists (and legacy RustFS clients sent) plain seconds,
|
||||
/// while Go `time.Duration` JSON — madmin/mc requests and MinIO-written
|
||||
/// bucket-targets metadata — is nanoseconds. No meaningful interval lies
|
||||
/// between 10^7 seconds (~115 days) and 10^7 nanoseconds (10ms), so the
|
||||
/// magnitude disambiguates the unit.
|
||||
pub fn duration_from_secs_or_nanos(value: u64) -> Duration {
|
||||
const NANOS_THRESHOLD: u64 = 10_000_000;
|
||||
if value < NANOS_THRESHOLD {
|
||||
Duration::from_secs(value)
|
||||
} else {
|
||||
Duration::from_nanos(value)
|
||||
}
|
||||
}
|
||||
|
||||
mod duration_seconds {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
use std::time::Duration;
|
||||
@@ -108,8 +124,8 @@ mod duration_seconds {
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let secs = u64::deserialize(deserializer)?;
|
||||
Ok(Duration::from_secs(secs))
|
||||
let value = u64::deserialize(deserializer)?;
|
||||
Ok(super::duration_from_secs_or_nanos(value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +424,11 @@ mod tests {
|
||||
assert_eq!(credentials.access_key, "test-access-key");
|
||||
assert_eq!(credentials.secret_key, "test-secret-key");
|
||||
assert_eq!(credentials.session_token, Some("test-session-token".to_string()));
|
||||
assert!(credentials.expiration.is_some());
|
||||
assert_eq!(
|
||||
serde_json::to_value(credentials.expiration.expect("expiration should parse"))
|
||||
.expect("expiration should serialize to JSON"),
|
||||
serde_json::json!("2024-12-31T23:59:59Z")
|
||||
);
|
||||
|
||||
// Verify latency statistics
|
||||
assert_eq!(target.latency.curr, Duration::from_millis(100));
|
||||
@@ -484,6 +504,29 @@ mod tests {
|
||||
assert_eq!(original.offline_count, deserialized.offline_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_target_reads_go_nanosecond_durations_defensively() {
|
||||
// MinIO-written bucket-targets metadata and madmin clients encode
|
||||
// these fields as Go `time.Duration` nanoseconds; RustFS has always
|
||||
// persisted seconds. Both encodings must decode to the same interval.
|
||||
let target: BucketTarget = serde_json::from_value(serde_json::json!({
|
||||
"endpoint": "localhost:9000",
|
||||
"targetbucket": "target",
|
||||
"type": "replication",
|
||||
"healthCheckDuration": 60_000_000_000u64,
|
||||
"totalDowntime": 90_000_000_000u64
|
||||
}))
|
||||
.expect("nanosecond durations should deserialize");
|
||||
|
||||
assert_eq!(target.health_check_duration, Duration::from_secs(60));
|
||||
assert_eq!(target.total_downtime, Duration::from_secs(90));
|
||||
|
||||
// The persisted wire format stays seconds for existing RustFS readers.
|
||||
let value = serde_json::to_value(&target).expect("target should serialize");
|
||||
assert_eq!(value["healthCheckDuration"], 60);
|
||||
assert_eq!(value["totalDowntime"], 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucket_target_debug_redacts_credentials() {
|
||||
let target = BucketTarget {
|
||||
@@ -562,12 +605,15 @@ mod tests {
|
||||
.and_then(|credentials| credentials.session_token.as_deref()),
|
||||
Some("legacy-session-token")
|
||||
);
|
||||
assert!(
|
||||
assert_eq!(
|
||||
target
|
||||
.credentials
|
||||
.as_ref()
|
||||
.and_then(|credentials| credentials.expiration)
|
||||
.is_some()
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
.expect("expiration should serialize to JSON"),
|
||||
Some(serde_json::json!("2024-12-31T23:59:59Z"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -609,7 +655,11 @@ mod tests {
|
||||
credentials.session_token,
|
||||
Some("AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT".to_string())
|
||||
);
|
||||
assert!(credentials.expiration.is_some());
|
||||
assert_eq!(
|
||||
serde_json::to_value(credentials.expiration.expect("expiration should parse"))
|
||||
.expect("expiration should serialize to JSON"),
|
||||
serde_json::json!("2024-12-31T23:59:59Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -269,6 +269,32 @@ pub fn check_del_obj_args(bucket: &str, object: &str) -> Result<()> {
|
||||
check_bucket_and_object_names(bucket, object)
|
||||
}
|
||||
|
||||
/// Filesystem `NAME_MAX`: every object-key path segment becomes one on-disk
|
||||
/// directory entry, so a longer segment can never be stored and previously
|
||||
/// escaped as an `ENAMETOOLONG` io error → `InternalError` 500 (rustfs#5785).
|
||||
const MAX_OBJECT_KEY_SEGMENT_BYTES: usize = 255;
|
||||
|
||||
/// Reject object keys whose on-disk directory names would exceed `NAME_MAX`.
|
||||
///
|
||||
/// Middle segments map to their raw bytes; the final segment of a
|
||||
/// directory-object key (trailing `/`) is stored with the `__XLDIR__` suffix
|
||||
/// appended, shrinking its budget accordingly.
|
||||
fn object_key_segments_fit_on_disk(object: &str) -> bool {
|
||||
let trailing_dir = object.ends_with('/');
|
||||
let segments: Vec<&str> = object.split('/').collect();
|
||||
let last_nonempty = segments.iter().rposition(|s| !s.is_empty());
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
let mut budget = MAX_OBJECT_KEY_SEGMENT_BYTES;
|
||||
if trailing_dir && Some(index) == last_nonempty {
|
||||
budget = budget.saturating_sub(rustfs_utils::path::GLOBAL_DIR_SUFFIX.len());
|
||||
}
|
||||
if segment.len() > budget {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn check_bucket_and_object_names(bucket: &str, object: &str) -> Result<()> {
|
||||
if !is_meta_bucketname(bucket) && check_valid_bucket_name_strict(bucket).is_err() {
|
||||
return Err(StorageError::BucketNameInvalid(bucket.to_string()));
|
||||
@@ -282,6 +308,10 @@ pub fn check_bucket_and_object_names(bucket: &str, object: &str) -> Result<()> {
|
||||
return Err(StorageError::ObjectNameInvalid(bucket.to_string(), object.to_string()));
|
||||
}
|
||||
|
||||
if !object_key_segments_fit_on_disk(object) {
|
||||
return Err(StorageError::ObjectNameInvalid(bucket.to_string(), object.to_string()));
|
||||
}
|
||||
|
||||
// if cfg!(target_os = "windows") && object.contains('\\') {
|
||||
// return Err(StorageError::ObjectNameInvalid(bucket.to_string(), object.to_string()));
|
||||
// }
|
||||
@@ -379,6 +409,14 @@ pub fn check_put_object_args(bucket: &str, object: &str) -> Result<()> {
|
||||
return Err(StorageError::ObjectNameInvalid(bucket.to_string(), object.to_string()));
|
||||
}
|
||||
|
||||
// The write path validates arguments here rather than through
|
||||
// check_bucket_and_object_names, so the on-disk segment budget has to be
|
||||
// enforced in both places or an over-NAME_MAX key still reaches the disk
|
||||
// layer and escapes as ENAMETOOLONG → InternalError 500 (rustfs#5785).
|
||||
if !object_key_segments_fit_on_disk(object) {
|
||||
return Err(StorageError::ObjectNameInvalid(bucket.to_string(), object.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -387,6 +425,62 @@ mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// rustfs#5785: keys whose path segments exceed the on-disk NAME_MAX
|
||||
/// budget must be rejected up front as ObjectNameInvalid (4xx), not leak
|
||||
/// ENAMETOOLONG as InternalError 500 from the disk layer.
|
||||
#[test]
|
||||
fn object_key_segment_name_max_budget() {
|
||||
// 255-byte single segment: exactly at the on-disk limit.
|
||||
assert!(check_bucket_and_object_names("bucket", &"a".repeat(255)).is_ok());
|
||||
// 256 bytes: one over.
|
||||
assert!(matches!(
|
||||
check_bucket_and_object_names("bucket", &"a".repeat(256)),
|
||||
Err(StorageError::ObjectNameInvalid(_, _))
|
||||
));
|
||||
// Long keys are fine as long as every segment fits.
|
||||
let segmented = ["b".repeat(200), "c".repeat(200), "d".repeat(200)].join("/");
|
||||
assert!(check_bucket_and_object_names("bucket", &segmented).is_ok());
|
||||
// The budget counts bytes, not characters (100 CJK chars = 300 bytes).
|
||||
assert!(matches!(
|
||||
check_bucket_and_object_names("bucket", &"中".repeat(100)),
|
||||
Err(StorageError::ObjectNameInvalid(_, _))
|
||||
));
|
||||
assert!(check_bucket_and_object_names("bucket", &"中".repeat(85)).is_ok());
|
||||
// Directory-object keys spend GLOBAL_DIR_SUFFIX bytes of the final
|
||||
// segment's budget on the on-disk __XLDIR__ encoding.
|
||||
let dir_budget = 255 - rustfs_utils::path::GLOBAL_DIR_SUFFIX.len();
|
||||
assert!(check_bucket_and_object_names("bucket", &format!("{}/", "e".repeat(dir_budget))).is_ok());
|
||||
assert!(matches!(
|
||||
check_bucket_and_object_names("bucket", &format!("{}/", "e".repeat(dir_budget + 1))),
|
||||
Err(StorageError::ObjectNameInvalid(_, _))
|
||||
));
|
||||
}
|
||||
|
||||
/// rustfs#5785 follow-up: the write path validates through
|
||||
/// check_put_object_args, not check_bucket_and_object_names, so the same
|
||||
/// budget has to hold there — otherwise an over-NAME_MAX PUT still reached
|
||||
/// the disk layer and came back as InternalError 500.
|
||||
#[test]
|
||||
fn put_object_args_enforce_the_same_segment_budget() {
|
||||
assert!(check_put_object_args("bucket", &"a".repeat(255)).is_ok());
|
||||
assert!(matches!(
|
||||
check_put_object_args("bucket", &"a".repeat(256)),
|
||||
Err(StorageError::ObjectNameInvalid(_, _))
|
||||
));
|
||||
assert!(matches!(
|
||||
check_put_object_args("bucket", &"\u{4e2d}".repeat(100)),
|
||||
Err(StorageError::ObjectNameInvalid(_, _))
|
||||
));
|
||||
let segmented = ["b".repeat(200), "c".repeat(200), "d".repeat(200)].join("/");
|
||||
assert!(check_put_object_args("bucket", &segmented).is_ok());
|
||||
let dir_budget = 255 - rustfs_utils::path::GLOBAL_DIR_SUFFIX.len();
|
||||
assert!(check_put_object_args("bucket", &format!("{}/", "e".repeat(dir_budget))).is_ok());
|
||||
assert!(matches!(
|
||||
check_put_object_args("bucket", &format!("{}/", "e".repeat(dir_budget + 1))),
|
||||
Err(StorageError::ObjectNameInvalid(_, _))
|
||||
));
|
||||
}
|
||||
|
||||
// Test validation functions
|
||||
#[test]
|
||||
fn test_is_valid_object_name() {
|
||||
|
||||
@@ -26,8 +26,15 @@ use crate::object_api::ObjectOptions;
|
||||
use crate::storage_api_contracts::object::{ObjectOperations as _, ObjectToDelete};
|
||||
use crate::store::ECStore;
|
||||
use rustfs_lock::MAX_DELETE_LIST;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[ObjectToDelete], _lc_event: lifecycle::Event) {
|
||||
pub async fn delete_object_versions(
|
||||
api: &Arc<ECStore>,
|
||||
bucket: &str,
|
||||
to_del: &[ObjectToDelete],
|
||||
_lc_event: lifecycle::Event,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) {
|
||||
let delete_config_snapshot = match ReplicationObjectBridge::delete_request_config(api, bucket).await {
|
||||
Ok(snapshot) => Arc::new(snapshot),
|
||||
Err(err) => {
|
||||
@@ -59,6 +66,7 @@ pub async fn delete_object_versions(api: &Arc<ECStore>, bucket: &str, to_del: &[
|
||||
to_del.to_vec(),
|
||||
ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -36,15 +36,20 @@ use http::{HeaderMap, HeaderValue, Method, Uri};
|
||||
#[cfg(test)]
|
||||
use rustfs_credentials::{DEFAULT_SECRET_KEY, RPC_SECRET_REQUIRED_MESSAGE};
|
||||
use rustfs_credentials::{RPC_SECRET_REQUIRED_OPERATOR_MESSAGE, try_get_rpc_token};
|
||||
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
||||
use rustfs_io_metrics::internode_metrics::{
|
||||
INTERNODE_OPERATION_GRPC_OTHER, INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
|
||||
INTERNODE_OPERATION_GRPC_WRITE_ALL, INTERNODE_TRANSPORT_BACKEND_GRPC, global_internode_metrics,
|
||||
};
|
||||
use rustfs_object_data_cache::{MemoryBasis, resolve_effective_memory};
|
||||
use rustfs_utils::get_env_bool;
|
||||
use sha2::Digest as _;
|
||||
use sha2::Sha256;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::sync::{LazyLock, Mutex, Once};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::error;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
@@ -70,6 +75,11 @@ const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||
const UNSIGNED_PAYLOAD_NONCE: &str = "unsigned";
|
||||
const SIGNATURE_VALID_DURATION: i64 = 300; // 5 minutes
|
||||
const REPLAY_CACHE_RETENTION: Duration = Duration::from_secs(601);
|
||||
const REPLAY_CACHE_RETENTION_SECS: usize = 601;
|
||||
const REPLAY_CACHE_ENTRY_BYTES_ESTIMATE: u64 = 128;
|
||||
const REPLAY_CACHE_AUTO_MEMORY_PERCENT: u64 = 8;
|
||||
const REPLAY_CACHE_AUTO_RPC_RPS_PER_CPU: usize = 2048;
|
||||
const REPLAY_CACHE_AUTO_MAX_CAPACITY: usize = 16_777_216;
|
||||
const NS_SCANNER_CAPABILITY_AUTH_DOMAIN: &[u8] = b"rustfs-ns-scanner-capability-v3";
|
||||
pub const TONIC_RPC_PREFIX: &str = "/node_service.NodeService";
|
||||
static INTERNODE_RPC_SIGNATURE_STRICT: LazyLock<bool> = LazyLock::new(|| {
|
||||
@@ -91,18 +101,211 @@ static INTERNODE_RPC_REPLAY_SCOPE_STRICT: LazyLock<bool> = LazyLock::new(|| {
|
||||
)
|
||||
});
|
||||
// Sized for peak legitimate authenticated RPC RPS x the retention window once replay scope is
|
||||
// active; overflow fails closed and increments the replay-cache overflow counter. Clamped to at
|
||||
// least 1 so a misconfigured zero cannot disable replay protection by rejecting every request.
|
||||
static REPLAY_CACHE_CAPACITY: LazyLock<usize> = LazyLock::new(|| {
|
||||
rustfs_utils::get_env_usize(
|
||||
rustfs_config::ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY,
|
||||
rustfs_config::DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY,
|
||||
)
|
||||
.max(1)
|
||||
});
|
||||
// active; overflow fails closed and increments the replay-cache overflow counter. Explicit operator
|
||||
// values and auto-sizing are both floored at the historical default so under-sizing cannot turn
|
||||
// legitimate high-throughput traffic into `No valid auth token` failures.
|
||||
static REPLAY_CACHE_CAPACITY: LazyLock<usize> = LazyLock::new(resolve_replay_cache_capacity);
|
||||
static RPC_SECRET_RESOLUTION_LOG_ONCE: Once = Once::new();
|
||||
static RPC_BOOT_EPOCH: LazyLock<Uuid> = LazyLock::new(Uuid::new_v4);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ReplayCacheCapacitySource {
|
||||
Env,
|
||||
EnvClampedToDefault,
|
||||
Auto,
|
||||
AutoClampedToDefault,
|
||||
AutoInvalidEnv,
|
||||
AutoInvalidEnvClampedToDefault,
|
||||
}
|
||||
|
||||
impl ReplayCacheCapacitySource {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Env => "env",
|
||||
Self::EnvClampedToDefault => "env_clamped_to_default",
|
||||
Self::Auto => "auto",
|
||||
Self::AutoClampedToDefault => "auto_clamped_to_default",
|
||||
Self::AutoInvalidEnv => "auto_invalid_env",
|
||||
Self::AutoInvalidEnvClampedToDefault => "auto_invalid_env_clamped_to_default",
|
||||
}
|
||||
}
|
||||
|
||||
fn is_env_clamped(self) -> bool {
|
||||
matches!(self, Self::EnvClampedToDefault)
|
||||
}
|
||||
|
||||
fn is_env(self) -> bool {
|
||||
matches!(self, Self::Env)
|
||||
}
|
||||
|
||||
fn is_invalid_env(self) -> bool {
|
||||
matches!(self, Self::AutoInvalidEnv | Self::AutoInvalidEnvClampedToDefault)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct ReplayCacheCapacityDecision {
|
||||
capacity: usize,
|
||||
source: ReplayCacheCapacitySource,
|
||||
cpu_count: usize,
|
||||
memory_limit_bytes: Option<u64>,
|
||||
memory_basis: Option<MemoryBasis>,
|
||||
memory_based_capacity: usize,
|
||||
cpu_based_capacity: usize,
|
||||
}
|
||||
|
||||
fn saturating_usize_from_u64(value: u64) -> usize {
|
||||
usize::try_from(value).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
fn replay_cache_capacity_from_resources(cpu_count: usize, memory_limit_bytes: Option<u64>) -> (usize, usize, usize) {
|
||||
let cpu_count = cpu_count.max(1);
|
||||
let cpu_based_capacity = cpu_count
|
||||
.saturating_mul(REPLAY_CACHE_AUTO_RPC_RPS_PER_CPU)
|
||||
.saturating_mul(REPLAY_CACHE_RETENTION_SECS);
|
||||
let memory_based_capacity = memory_limit_bytes
|
||||
.map(|bytes| {
|
||||
let budget = bytes.saturating_mul(REPLAY_CACHE_AUTO_MEMORY_PERCENT) / 100;
|
||||
saturating_usize_from_u64(budget / REPLAY_CACHE_ENTRY_BYTES_ESTIMATE)
|
||||
})
|
||||
.unwrap_or(REPLAY_CACHE_AUTO_MAX_CAPACITY);
|
||||
let capacity = memory_based_capacity
|
||||
.min(cpu_based_capacity)
|
||||
.clamp(rustfs_config::DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, REPLAY_CACHE_AUTO_MAX_CAPACITY);
|
||||
(capacity, memory_based_capacity, cpu_based_capacity)
|
||||
}
|
||||
|
||||
fn replay_cache_capacity_decision(
|
||||
env: rustfs_utils::EnvParseOutcome<usize>,
|
||||
cpu_count: usize,
|
||||
memory_limit_bytes: Option<u64>,
|
||||
memory_basis: Option<MemoryBasis>,
|
||||
) -> ReplayCacheCapacityDecision {
|
||||
let default = rustfs_config::DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY;
|
||||
match env {
|
||||
rustfs_utils::EnvParseOutcome::Parsed(configured) => {
|
||||
let capacity = configured.max(default);
|
||||
let source = if configured < default {
|
||||
ReplayCacheCapacitySource::EnvClampedToDefault
|
||||
} else {
|
||||
ReplayCacheCapacitySource::Env
|
||||
};
|
||||
ReplayCacheCapacityDecision {
|
||||
capacity,
|
||||
source,
|
||||
cpu_count: cpu_count.max(1),
|
||||
memory_limit_bytes,
|
||||
memory_basis,
|
||||
memory_based_capacity: 0,
|
||||
cpu_based_capacity: 0,
|
||||
}
|
||||
}
|
||||
rustfs_utils::EnvParseOutcome::Absent | rustfs_utils::EnvParseOutcome::Invalid => {
|
||||
let (capacity, memory_based_capacity, cpu_based_capacity) =
|
||||
replay_cache_capacity_from_resources(cpu_count, memory_limit_bytes);
|
||||
let clamped_to_default = capacity == default && memory_based_capacity.min(cpu_based_capacity) < default;
|
||||
let invalid_env = matches!(env, rustfs_utils::EnvParseOutcome::Invalid);
|
||||
let source = match (invalid_env, clamped_to_default) {
|
||||
(true, true) => ReplayCacheCapacitySource::AutoInvalidEnvClampedToDefault,
|
||||
(true, false) => ReplayCacheCapacitySource::AutoInvalidEnv,
|
||||
(false, true) => ReplayCacheCapacitySource::AutoClampedToDefault,
|
||||
(false, false) => ReplayCacheCapacitySource::Auto,
|
||||
};
|
||||
ReplayCacheCapacityDecision {
|
||||
capacity,
|
||||
source,
|
||||
cpu_count: cpu_count.max(1),
|
||||
memory_limit_bytes,
|
||||
memory_basis,
|
||||
memory_based_capacity,
|
||||
cpu_based_capacity,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detected_replay_cache_resources() -> (usize, Option<u64>, Option<MemoryBasis>) {
|
||||
let cpu_count = thread::available_parallelism().map(usize::from).unwrap_or(1).max(1);
|
||||
let memory = resolve_effective_memory();
|
||||
let memory_limit_bytes = (memory.total_bytes > 0).then_some(memory.total_bytes);
|
||||
(cpu_count, memory_limit_bytes, Some(memory.basis))
|
||||
}
|
||||
|
||||
fn log_replay_cache_capacity_decision(decision: ReplayCacheCapacityDecision) {
|
||||
let source = decision.source.as_str();
|
||||
if decision.source.is_env_clamped() {
|
||||
warn!(
|
||||
event = "internode_rpc_replay_cache_capacity_resolved",
|
||||
component = "ecstore",
|
||||
subsystem = "rpc_auth",
|
||||
capacity = decision.capacity,
|
||||
source,
|
||||
default_capacity = rustfs_config::DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY,
|
||||
env = rustfs_config::ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY,
|
||||
"internode rpc replay cache capacity clamped to default"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if decision.source.is_env() {
|
||||
info!(
|
||||
event = "internode_rpc_replay_cache_capacity_resolved",
|
||||
component = "ecstore",
|
||||
subsystem = "rpc_auth",
|
||||
capacity = decision.capacity,
|
||||
source,
|
||||
default_capacity = rustfs_config::DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY,
|
||||
env = rustfs_config::ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY,
|
||||
"internode rpc replay cache capacity resolved from env"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if decision.source.is_invalid_env() {
|
||||
warn!(
|
||||
event = "internode_rpc_replay_cache_capacity_resolved",
|
||||
component = "ecstore",
|
||||
subsystem = "rpc_auth",
|
||||
capacity = decision.capacity,
|
||||
source,
|
||||
cpu_count = decision.cpu_count,
|
||||
memory_limit_bytes = decision.memory_limit_bytes,
|
||||
memory_basis = decision.memory_basis.map(MemoryBasis::as_str),
|
||||
memory_based_capacity = decision.memory_based_capacity,
|
||||
cpu_based_capacity = decision.cpu_based_capacity,
|
||||
auto_max_capacity = REPLAY_CACHE_AUTO_MAX_CAPACITY,
|
||||
env = rustfs_config::ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY,
|
||||
"internode rpc replay cache capacity auto-sized after invalid env"
|
||||
);
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
event = "internode_rpc_replay_cache_capacity_resolved",
|
||||
component = "ecstore",
|
||||
subsystem = "rpc_auth",
|
||||
capacity = decision.capacity,
|
||||
source,
|
||||
cpu_count = decision.cpu_count,
|
||||
memory_limit_bytes = decision.memory_limit_bytes,
|
||||
memory_basis = decision.memory_basis.map(MemoryBasis::as_str),
|
||||
memory_based_capacity = decision.memory_based_capacity,
|
||||
cpu_based_capacity = decision.cpu_based_capacity,
|
||||
auto_max_capacity = REPLAY_CACHE_AUTO_MAX_CAPACITY,
|
||||
"internode rpc replay cache capacity resolved"
|
||||
);
|
||||
}
|
||||
|
||||
fn resolve_replay_cache_capacity() -> usize {
|
||||
let (cpu_count, memory_limit_bytes, memory_basis) = detected_replay_cache_resources();
|
||||
let decision = replay_cache_capacity_decision(
|
||||
rustfs_utils::get_env_parse_outcome(rustfs_config::ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY),
|
||||
cpu_count,
|
||||
memory_limit_bytes,
|
||||
memory_basis,
|
||||
);
|
||||
global_internode_metrics().record_replay_cache_state(0, decision.capacity);
|
||||
log_replay_cache_capacity_decision(decision);
|
||||
decision.capacity
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RpcNonceCache {
|
||||
nonces: HashSet<Uuid>,
|
||||
@@ -110,8 +313,50 @@ struct RpcNonceCache {
|
||||
max_wall_time: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RpcReplayCacheMetricScope<'a> {
|
||||
operation: &'static str,
|
||||
backend: &'static str,
|
||||
rpc_path: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RpcNonceRecord<'a> {
|
||||
nonce: Uuid,
|
||||
signed_at: i64,
|
||||
now: Instant,
|
||||
wall_time: i64,
|
||||
expires_at: Instant,
|
||||
capacity: usize,
|
||||
metric_scope: RpcReplayCacheMetricScope<'a>,
|
||||
}
|
||||
|
||||
struct RpcNonceCacheMetrics<'a> {
|
||||
expired: usize,
|
||||
entries: usize,
|
||||
capacity: usize,
|
||||
overflow_scope: Option<RpcReplayCacheMetricScope<'a>>,
|
||||
}
|
||||
|
||||
fn publish_nonce_cache_metrics(metrics: Option<RpcNonceCacheMetrics<'_>>) {
|
||||
let Some(metrics) = metrics else {
|
||||
return;
|
||||
};
|
||||
let internode_metrics = global_internode_metrics();
|
||||
internode_metrics.record_replay_cache_evictions("expired", metrics.expired);
|
||||
internode_metrics.record_replay_cache_state(metrics.entries, metrics.capacity);
|
||||
if let Some(scope) = metrics.overflow_scope {
|
||||
internode_metrics.record_replay_cache_overflow_for_operation_and_backend_path(
|
||||
scope.operation,
|
||||
scope.backend,
|
||||
scope.rpc_path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcNonceCache {
|
||||
fn remove_expired(&mut self, now: Instant, wall_time: i64) {
|
||||
fn remove_expired(&mut self, now: Instant, wall_time: i64) -> usize {
|
||||
let mut removed = 0;
|
||||
while matches!(
|
||||
self.expirations.front(),
|
||||
Some((expires_at, valid_until, _)) if *expires_at < now && *valid_until < wall_time
|
||||
@@ -120,37 +365,48 @@ impl RpcNonceCache {
|
||||
break;
|
||||
};
|
||||
self.nonces.remove(&nonce);
|
||||
removed += 1;
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn check_and_record(
|
||||
&mut self,
|
||||
nonce: Uuid,
|
||||
signed_at: i64,
|
||||
now: Instant,
|
||||
wall_time: i64,
|
||||
expires_at: Instant,
|
||||
capacity: usize,
|
||||
) -> std::io::Result<()> {
|
||||
self.max_wall_time = self.max_wall_time.max(wall_time);
|
||||
if self.max_wall_time.saturating_sub(signed_at) > SIGNATURE_VALID_DURATION {
|
||||
return Err(std::io::Error::other("RPC request timestamp expired after clock regression"));
|
||||
fn check_and_record<'a>(&mut self, record: RpcNonceRecord<'a>) -> (std::io::Result<()>, Option<RpcNonceCacheMetrics<'a>>) {
|
||||
self.max_wall_time = self.max_wall_time.max(record.wall_time);
|
||||
if self.max_wall_time.saturating_sub(record.signed_at) > SIGNATURE_VALID_DURATION {
|
||||
return (Err(std::io::Error::other("RPC request timestamp expired after clock regression")), None);
|
||||
}
|
||||
self.remove_expired(now, self.max_wall_time);
|
||||
if self.nonces.contains(&nonce) {
|
||||
return Err(std::io::Error::other("RPC request replay detected"));
|
||||
let expired = self.remove_expired(record.now, self.max_wall_time);
|
||||
let metrics = RpcNonceCacheMetrics {
|
||||
expired,
|
||||
entries: self.nonces.len(),
|
||||
capacity: record.capacity,
|
||||
overflow_scope: None,
|
||||
};
|
||||
if self.nonces.contains(&record.nonce) {
|
||||
return (Err(std::io::Error::other("RPC request replay detected")), Some(metrics));
|
||||
}
|
||||
if self.nonces.len() >= capacity {
|
||||
if self.nonces.len() >= record.capacity {
|
||||
// Fail closed and alert: only legitimately signed traffic can fill the cache, so a
|
||||
// sustained overflow means RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY is undersized
|
||||
// for this node's peak mutation rate and writes are being refused.
|
||||
global_internode_metrics().record_replay_cache_overflow();
|
||||
return Err(std::io::Error::other("RPC replay cache capacity exceeded"));
|
||||
return (
|
||||
Err(std::io::Error::other("RPC replay cache capacity exceeded")),
|
||||
Some(RpcNonceCacheMetrics {
|
||||
overflow_scope: Some(record.metric_scope),
|
||||
..metrics
|
||||
}),
|
||||
);
|
||||
}
|
||||
self.nonces.insert(nonce);
|
||||
self.nonces.insert(record.nonce);
|
||||
self.expirations
|
||||
.push_back((expires_at, signed_at.saturating_add(SIGNATURE_VALID_DURATION), nonce));
|
||||
Ok(())
|
||||
.push_back((record.expires_at, record.signed_at.saturating_add(SIGNATURE_VALID_DURATION), record.nonce));
|
||||
(
|
||||
Ok(()),
|
||||
Some(RpcNonceCacheMetrics {
|
||||
entries: self.nonces.len(),
|
||||
..metrics
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,18 +797,43 @@ fn check_timestamp(timestamp: i64) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_and_record_nonce(nonce: Uuid, signed_at: i64) -> std::io::Result<()> {
|
||||
fn tonic_rpc_metric_operation(path: &str) -> &'static str {
|
||||
match parse_tonic_rpc_path(path).ok().map(|(_, rpc_method)| rpc_method) {
|
||||
Some("ReadAll") => INTERNODE_OPERATION_GRPC_READ_ALL,
|
||||
Some("ReadMultiple") => INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
|
||||
Some("WriteAll") => INTERNODE_OPERATION_GRPC_WRITE_ALL,
|
||||
_ => INTERNODE_OPERATION_GRPC_OTHER,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_and_record_nonce(nonce: Uuid, signed_at: i64, rpc_path: &str) -> std::io::Result<()> {
|
||||
let wall_time = OffsetDateTime::now_utc().unix_timestamp();
|
||||
let mut cache = LOCAL_RPC_NONCE_CACHE
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("RPC replay cache unavailable"))?;
|
||||
// Take the monotonic timestamp after acquiring the lock so expiration
|
||||
// entries remain ordered by the same serialization point as insertion.
|
||||
let now = Instant::now();
|
||||
let expires_at = now
|
||||
.checked_add(REPLAY_CACHE_RETENTION)
|
||||
.ok_or_else(|| std::io::Error::other("RPC replay expiry overflow"))?;
|
||||
cache.check_and_record(nonce, signed_at, now, wall_time, expires_at, *REPLAY_CACHE_CAPACITY)
|
||||
let (result, metrics) = {
|
||||
let mut cache = LOCAL_RPC_NONCE_CACHE
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("RPC replay cache unavailable"))?;
|
||||
// Take the monotonic timestamp after acquiring the lock so expiration
|
||||
// entries remain ordered by the same serialization point as insertion.
|
||||
let now = Instant::now();
|
||||
let expires_at = now
|
||||
.checked_add(REPLAY_CACHE_RETENTION)
|
||||
.ok_or_else(|| std::io::Error::other("RPC replay expiry overflow"))?;
|
||||
cache.check_and_record(RpcNonceRecord {
|
||||
nonce,
|
||||
signed_at,
|
||||
now,
|
||||
wall_time,
|
||||
expires_at,
|
||||
capacity: *REPLAY_CACHE_CAPACITY,
|
||||
metric_scope: RpcReplayCacheMetricScope {
|
||||
operation: tonic_rpc_metric_operation(rpc_path),
|
||||
backend: INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
rpc_path,
|
||||
},
|
||||
})
|
||||
};
|
||||
publish_nonce_cache_metrics(metrics);
|
||||
result
|
||||
}
|
||||
|
||||
/// Build headers with authentication signature
|
||||
@@ -814,7 +1095,7 @@ fn verify_tonic_replay_scope_signature(audience: &str, path: &str, headers: &Hea
|
||||
if boot_epoch != tonic_rpc_boot_epoch() {
|
||||
return Err(std::io::Error::other("RPC boot epoch is stale"));
|
||||
}
|
||||
check_and_record_nonce(nonce, signed_at)
|
||||
check_and_record_nonce(nonce, signed_at, path)
|
||||
}
|
||||
|
||||
/// Verify gRPC authentication, preferring v2 without downgrade on malformed v2 metadata.
|
||||
@@ -847,6 +1128,46 @@ pub fn verify_tonic_rpc_signature_with_bootstrap(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn tonic_rpc_auth_failure_reason(error: &std::io::Error) -> &'static str {
|
||||
match error.to_string().as_str() {
|
||||
"Missing RPC audience" => "missing_audience",
|
||||
"Invalid RPC request path" => "invalid_request_path",
|
||||
"RPC replay-scoped authentication required" => "replay_scope_required",
|
||||
"Missing RPC replay scope version" => "missing_replay_scope_version",
|
||||
"Unsupported RPC replay scope version" => "unsupported_replay_scope_version",
|
||||
"Missing RPC replay scope signature" => "missing_replay_scope_signature",
|
||||
"Missing RPC replay scope nonce" => "missing_replay_scope_nonce",
|
||||
"Invalid RPC replay scope nonce" => "invalid_replay_scope_nonce",
|
||||
"Missing RPC boot epoch" => "missing_boot_epoch",
|
||||
"Invalid RPC boot epoch" => "invalid_boot_epoch",
|
||||
"Invalid RPC replay scope signature" => "invalid_replay_scope_signature",
|
||||
"RPC boot epoch is stale" => "stale_boot_epoch",
|
||||
"RPC request replay detected" => "replay_detected",
|
||||
"RPC replay cache capacity exceeded" => "replay_cache_capacity",
|
||||
"RPC replay cache unavailable" => "replay_cache_unavailable",
|
||||
"RPC replay expiry overflow" => "replay_expiry_overflow",
|
||||
"RPC request timestamp expired after clock regression" => "timestamp_expired_after_clock_regression",
|
||||
"RPC v2 authentication required" => "v2_required",
|
||||
"Missing RPC auth version" => "missing_v2_auth_version",
|
||||
"Unsupported RPC auth version" => "unsupported_v2_auth_version",
|
||||
"Missing RPC v2 signature" => "missing_v2_signature",
|
||||
"Invalid RPC v2 signature" => "invalid_v2_signature",
|
||||
"Missing timestamp header" => "missing_timestamp",
|
||||
"Invalid timestamp format" => "invalid_timestamp",
|
||||
"Request timestamp expired" => "timestamp_expired",
|
||||
"Missing RPC nonce" => "missing_v2_nonce",
|
||||
"Invalid RPC nonce" => "invalid_v2_nonce",
|
||||
"Invalid unsigned RPC nonce" => "invalid_unsigned_v2_nonce",
|
||||
"Missing RPC content SHA-256" => "missing_content_sha256",
|
||||
"Invalid RPC content SHA-256" => "invalid_content_sha256",
|
||||
"Missing signature header" => "missing_v1_signature",
|
||||
"Invalid signature" => "invalid_v1_signature",
|
||||
"Invalid RPC HMAC key" => "invalid_hmac_key",
|
||||
message if message.contains(RPC_SECRET_REQUIRED_OPERATOR_MESSAGE) => "missing_rpc_secret",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tonic_rpc_signature_with_policy(
|
||||
audience: &str,
|
||||
path: &str,
|
||||
@@ -965,7 +1286,7 @@ fn verify_tonic_rpc_signature_with_strictness(
|
||||
return Err(std::io::Error::other("Invalid RPC v2 signature"));
|
||||
}
|
||||
if let Some(nonce) = parsed_nonce {
|
||||
check_and_record_nonce(nonce, timestamp)?;
|
||||
check_and_record_nonce(nonce, timestamp, path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1699,6 +2020,31 @@ mod tests {
|
||||
assert!(verify_tonic_boot_epoch_response("node-a:9000", Uuid::new_v4(), &headers).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonic_rpc_auth_failure_reason_maps_security_relevant_errors() {
|
||||
for (message, reason) in [
|
||||
("Invalid RPC v2 signature", "invalid_v2_signature"),
|
||||
("RPC replay-scoped authentication required", "replay_scope_required"),
|
||||
("Missing RPC replay scope signature", "missing_replay_scope_signature"),
|
||||
("RPC boot epoch is stale", "stale_boot_epoch"),
|
||||
("RPC request replay detected", "replay_detected"),
|
||||
("Request timestamp expired", "timestamp_expired"),
|
||||
("Missing RPC content SHA-256", "missing_content_sha256"),
|
||||
("Invalid RPC content SHA-256", "invalid_content_sha256"),
|
||||
] {
|
||||
assert_eq!(
|
||||
tonic_rpc_auth_failure_reason(&std::io::Error::other(message)),
|
||||
reason,
|
||||
"message {message:?} should map to a stable low-cardinality reason"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonic_rpc_auth_failure_reason_falls_back_for_unclassified_errors() {
|
||||
assert_eq!(tonic_rpc_auth_failure_reason(&std::io::Error::other("opaque failure")), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_v2_auth_does_not_downgrade_to_valid_legacy_signature() {
|
||||
ensure_test_rpc_secret();
|
||||
@@ -1903,6 +2249,126 @@ mod tests {
|
||||
assert_eq!(error.to_string(), "RPC mutation requires v2 authentication");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonic_rpc_metric_operation_classifies_get_hot_path_methods() {
|
||||
assert_eq!(
|
||||
tonic_rpc_metric_operation("/node_service.NodeService/ReadAll"),
|
||||
INTERNODE_OPERATION_GRPC_READ_ALL
|
||||
);
|
||||
assert_eq!(
|
||||
tonic_rpc_metric_operation("/node_service.NodeService/ReadMultiple"),
|
||||
INTERNODE_OPERATION_GRPC_READ_MULTIPLE
|
||||
);
|
||||
assert_eq!(
|
||||
tonic_rpc_metric_operation("/node_service.NodeService/WriteAll"),
|
||||
INTERNODE_OPERATION_GRPC_WRITE_ALL
|
||||
);
|
||||
assert_eq!(
|
||||
tonic_rpc_metric_operation("/node_service.NodeService/SignalService"),
|
||||
INTERNODE_OPERATION_GRPC_OTHER
|
||||
);
|
||||
assert_eq!(tonic_rpc_metric_operation("not-a-grpc-path"), INTERNODE_OPERATION_GRPC_OTHER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_cache_capacity_uses_env_with_default_floor() {
|
||||
let default = rustfs_config::DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY;
|
||||
|
||||
let high = replay_cache_capacity_decision(
|
||||
rustfs_utils::EnvParseOutcome::Parsed(default * 16),
|
||||
2,
|
||||
Some(512 * 1024 * 1024),
|
||||
Some(MemoryBasis::Host),
|
||||
);
|
||||
assert_eq!(high.capacity, default * 16);
|
||||
assert_eq!(high.source, ReplayCacheCapacitySource::Env);
|
||||
|
||||
let low = replay_cache_capacity_decision(
|
||||
rustfs_utils::EnvParseOutcome::Parsed(1),
|
||||
64,
|
||||
Some(128 * 1024 * 1024 * 1024),
|
||||
Some(MemoryBasis::Host),
|
||||
);
|
||||
assert_eq!(low.capacity, default);
|
||||
assert_eq!(low.source, ReplayCacheCapacitySource::EnvClampedToDefault);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_cache_capacity_auto_sizes_from_cpu_and_memory() {
|
||||
let gib = 1024_u64 * 1024 * 1024;
|
||||
let decision =
|
||||
replay_cache_capacity_decision(rustfs_utils::EnvParseOutcome::Absent, 8, Some(16 * gib), Some(MemoryBasis::Host));
|
||||
|
||||
assert_eq!(decision.source, ReplayCacheCapacitySource::Auto);
|
||||
assert_eq!(decision.memory_basis, Some(MemoryBasis::Host));
|
||||
assert_eq!(decision.memory_based_capacity, 10_737_418);
|
||||
assert_eq!(decision.cpu_based_capacity, 9_846_784);
|
||||
assert_eq!(decision.capacity, 9_846_784);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_cache_capacity_auto_reaches_hotpath_verified_capacity_on_larger_nodes() {
|
||||
let gib = 1024_u64 * 1024 * 1024;
|
||||
let decision =
|
||||
replay_cache_capacity_decision(rustfs_utils::EnvParseOutcome::Absent, 16, Some(32 * gib), Some(MemoryBasis::Host));
|
||||
|
||||
assert_eq!(decision.source, ReplayCacheCapacitySource::Auto);
|
||||
assert_eq!(decision.memory_based_capacity, 21_474_836);
|
||||
assert_eq!(decision.cpu_based_capacity, 19_693_568);
|
||||
assert_eq!(decision.capacity, 16_777_216);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_cache_capacity_auto_keeps_default_floor_for_small_nodes() {
|
||||
let decision = replay_cache_capacity_decision(
|
||||
rustfs_utils::EnvParseOutcome::Absent,
|
||||
1,
|
||||
Some(512 * 1024 * 1024),
|
||||
Some(MemoryBasis::Host),
|
||||
);
|
||||
|
||||
assert_eq!(decision.capacity, rustfs_config::DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY);
|
||||
assert_eq!(decision.source, ReplayCacheCapacitySource::AutoClampedToDefault);
|
||||
assert!(decision.memory_based_capacity < rustfs_config::DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_cache_capacity_invalid_env_uses_auto_sizing() {
|
||||
let decision = replay_cache_capacity_decision(rustfs_utils::EnvParseOutcome::Invalid, 8, None, None);
|
||||
|
||||
assert_eq!(decision.source, ReplayCacheCapacitySource::AutoInvalidEnv);
|
||||
assert_eq!(decision.capacity, 9_846_784);
|
||||
}
|
||||
|
||||
fn check_test_nonce_record(cache: &mut RpcNonceCache, record: RpcNonceRecord<'_>) -> std::io::Result<()> {
|
||||
let (result, metrics) = cache.check_and_record(record);
|
||||
publish_nonce_cache_metrics(metrics);
|
||||
result
|
||||
}
|
||||
|
||||
fn test_nonce_record(
|
||||
nonce: Uuid,
|
||||
signed_at: i64,
|
||||
now: Instant,
|
||||
wall_time: i64,
|
||||
expires_at: Instant,
|
||||
capacity: usize,
|
||||
) -> RpcNonceRecord<'static> {
|
||||
RpcNonceRecord {
|
||||
nonce,
|
||||
signed_at,
|
||||
now,
|
||||
wall_time,
|
||||
expires_at,
|
||||
capacity,
|
||||
metric_scope: RpcReplayCacheMetricScope {
|
||||
operation: INTERNODE_OPERATION_GRPC_READ_ALL,
|
||||
backend: INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
rpc_path: "/node_service.NodeService/ReadAll",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_cache_expires_by_monotonic_deadline_and_fails_closed_at_capacity() {
|
||||
let now = Instant::now();
|
||||
@@ -1912,15 +2378,12 @@ mod tests {
|
||||
let nonce_b = Uuid::new_v4();
|
||||
let mut cache = RpcNonceCache::default();
|
||||
|
||||
cache
|
||||
.check_and_record(nonce_a, 100, now, 100, expiry, 1)
|
||||
check_test_nonce_record(&mut cache, test_nonce_record(nonce_a, 100, now, 100, expiry, 1))
|
||||
.expect("first nonce should be recorded");
|
||||
let capacity = cache
|
||||
.check_and_record(nonce_b, 100, now, 100, expiry, 1)
|
||||
let capacity = check_test_nonce_record(&mut cache, test_nonce_record(nonce_b, 100, now, 100, expiry, 1))
|
||||
.expect_err("a full replay cache must fail closed");
|
||||
assert_eq!(capacity.to_string(), "RPC replay cache capacity exceeded");
|
||||
cache
|
||||
.check_and_record(nonce_b, 702, after_expiry, 702, after_expiry, 1)
|
||||
check_test_nonce_record(&mut cache, test_nonce_record(nonce_b, 702, after_expiry, 702, after_expiry, 1))
|
||||
.expect("expired nonce should release capacity");
|
||||
assert!(!cache.nonces.contains(&nonce_a));
|
||||
assert!(cache.nonces.contains(&nonce_b));
|
||||
@@ -2123,17 +2586,15 @@ mod tests {
|
||||
let nonce = Uuid::new_v4();
|
||||
let mut cache = RpcNonceCache::default();
|
||||
|
||||
cache
|
||||
.check_and_record(nonce, 1_000, now, 1_000, expiry, 2)
|
||||
check_test_nonce_record(&mut cache, test_nonce_record(nonce, 1_000, now, 1_000, expiry, 2))
|
||||
.expect("first nonce should be recorded");
|
||||
let replay = cache
|
||||
.check_and_record(nonce, 1_000, after_expiry, 900, after_expiry, 2)
|
||||
let replay = check_test_nonce_record(&mut cache, test_nonce_record(nonce, 1_000, after_expiry, 900, after_expiry, 2))
|
||||
.expect_err("wall clock regression must not make an old signature reusable");
|
||||
assert_eq!(replay.to_string(), "RPC request replay detected");
|
||||
|
||||
let stale = cache
|
||||
.check_and_record(Uuid::new_v4(), 600, after_expiry, 900, after_expiry, 2)
|
||||
.expect_err("the monotonic wall-clock high-water mark must fail closed");
|
||||
let stale =
|
||||
check_test_nonce_record(&mut cache, test_nonce_record(Uuid::new_v4(), 600, after_expiry, 900, after_expiry, 2))
|
||||
.expect_err("the monotonic wall-clock high-water mark must fail closed");
|
||||
assert_eq!(stale.to_string(), "RPC request timestamp expired after clock regression");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ pub use client::{
|
||||
pub use http_auth::{
|
||||
TONIC_RPC_PREFIX, build_auth_headers, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
||||
normalize_tonic_rpc_audience, set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, sign_ns_scanner_capability,
|
||||
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, verify_ns_scanner_capability,
|
||||
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
||||
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
|
||||
verify_ns_scanner_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
||||
verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
|
||||
verify_tonic_rpc_signature_with_bootstrap,
|
||||
};
|
||||
|
||||
@@ -99,6 +99,69 @@ impl DeleteBucketEmptyScanBarrier {
|
||||
#[cfg(test)]
|
||||
static DELETE_BUCKET_EMPTY_SCAN_BARRIER: StdMutex<Option<Arc<DeleteBucketEmptyScanBarrier>>> = StdMutex::new(None);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum HealBucketOperation {
|
||||
Make,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct HealBucketOperationFailure {
|
||||
bucket: String,
|
||||
disk_index: usize,
|
||||
operation: HealBucketOperation,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
type HealBucketOperationFailureKey = (String, usize, HealBucketOperation);
|
||||
|
||||
#[cfg(test)]
|
||||
fn heal_bucket_operation_failures() -> &'static StdMutex<HashMap<HealBucketOperationFailureKey, Error>> {
|
||||
static FAILURES: std::sync::OnceLock<StdMutex<HashMap<HealBucketOperationFailureKey, Error>>> = std::sync::OnceLock::new();
|
||||
FAILURES.get_or_init(|| StdMutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl HealBucketOperationFailure {
|
||||
fn install(bucket: &str, disk_index: usize, operation: HealBucketOperation, error: Error) -> Self {
|
||||
let key = (bucket.to_string(), disk_index, operation);
|
||||
let previous = heal_bucket_operation_failures()
|
||||
.lock()
|
||||
.expect("heal bucket failure registry should not poison")
|
||||
.insert(key, error);
|
||||
assert!(previous.is_none(), "heal bucket operation failure already installed");
|
||||
Self {
|
||||
bucket: bucket.to_string(),
|
||||
disk_index,
|
||||
operation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for HealBucketOperationFailure {
|
||||
fn drop(&mut self) {
|
||||
heal_bucket_operation_failures()
|
||||
.lock()
|
||||
.expect("heal bucket failure registry should not poison")
|
||||
.remove(&(self.bucket.clone(), self.disk_index, self.operation));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn injected_heal_bucket_operation_error(bucket: &str, disk_index: usize, operation: HealBucketOperation) -> Option<Error> {
|
||||
heal_bucket_operation_failures()
|
||||
.lock()
|
||||
.expect("heal bucket failure registry should not poison")
|
||||
.get(&(bucket.to_string(), disk_index, operation))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn injected_heal_bucket_operation_error(_bucket: &str, _disk_index: usize, _operation: HealBucketOperation) -> Option<Error> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_delete_bucket_empty_scan_barrier() -> Arc<DeleteBucketEmptyScanBarrier> {
|
||||
let barrier = Arc::new(DeleteBucketEmptyScanBarrier::default());
|
||||
@@ -1207,10 +1270,6 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if opts.dry_run {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
for (disk, state) in disks.iter().zip(before_state.read().await.iter()) {
|
||||
res.before.drives.push(HealDriveInfo {
|
||||
uuid: "".to_string(),
|
||||
@@ -1219,35 +1278,68 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
});
|
||||
}
|
||||
|
||||
if opts.dry_run {
|
||||
for (disk, state) in disks.iter().zip(after_state.read().await.iter()) {
|
||||
res.after.drives.push(HealDriveInfo {
|
||||
uuid: "".to_string(),
|
||||
endpoint: disk.clone().map(|s| s.to_string()).unwrap_or_default(),
|
||||
state: state.to_string(),
|
||||
});
|
||||
}
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
let mut operation_error = errs
|
||||
.iter()
|
||||
.filter_map(|err| match err {
|
||||
Some(Error::VolumeNotFound) | None => None,
|
||||
Some(err) => Some(err.clone()),
|
||||
})
|
||||
.next();
|
||||
|
||||
if opts.remove && !bucket.starts_with(disk::RUSTFS_META_BUCKET) && !is_all_buckets_not_found(&errs) {
|
||||
let mut futures = Vec::new();
|
||||
for disk in disks.iter() {
|
||||
let disk = disk.clone();
|
||||
for (index, disk) in disks.iter().enumerate() {
|
||||
if matches!(errs[index].as_ref(), Some(Error::DiskNotFound | Error::VolumeNotFound)) {
|
||||
continue;
|
||||
}
|
||||
let Some(disk) = disk.clone() else {
|
||||
continue;
|
||||
};
|
||||
let bucket = bucket.to_string();
|
||||
info!("heal_bucket_local, errs: {:?}, opts: {:?}", errs, opts);
|
||||
futures.push(async move {
|
||||
match disk {
|
||||
Some(disk) => {
|
||||
// Non-force: a bucket that still holds object data refuses
|
||||
// deletion (VolumeNotEmpty) instead of being recursively
|
||||
// wiped, so a misclassified "dangling" bucket cannot lose
|
||||
// data (backlog#799 B1). Surface that refusal instead of
|
||||
// discarding it — it signals the bucket is not dangling.
|
||||
match disk.delete_volume(&bucket, false).await {
|
||||
Ok(()) => None,
|
||||
Err(Error::VolumeNotEmpty) => {
|
||||
warn!("heal declined to remove non-empty bucket {bucket} (not dangling)");
|
||||
None
|
||||
}
|
||||
Err(e) => Some(e),
|
||||
}
|
||||
}
|
||||
None => Some(Error::DiskNotFound),
|
||||
if let Some(err) = injected_heal_bucket_operation_error(&bucket, index, HealBucketOperation::Delete) {
|
||||
return (index, Err(err));
|
||||
}
|
||||
(index, disk.delete_volume(&bucket, false).await)
|
||||
});
|
||||
}
|
||||
|
||||
let _ = join_all(futures).await;
|
||||
for (index, result) in join_all(futures).await {
|
||||
match result {
|
||||
Ok(()) | Err(Error::VolumeNotFound) => {
|
||||
after_state.write().await[index] = DriveState::Missing.to_string();
|
||||
}
|
||||
Err(Error::VolumeNotEmpty) => {
|
||||
warn!(
|
||||
bucket,
|
||||
operation = "heal_bucket_delete_volume",
|
||||
result = "preserved_non_empty_bucket",
|
||||
"heal declined to remove non-empty bucket"
|
||||
);
|
||||
after_state.write().await[index] = DriveState::Ok.to_string();
|
||||
}
|
||||
Err(err) => {
|
||||
after_state.write().await[index] = match &err {
|
||||
Error::DiskNotFound => DriveState::Offline.to_string(),
|
||||
_ => DriveState::Corrupt.to_string(),
|
||||
};
|
||||
if operation_error.is_none() {
|
||||
operation_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.remove {
|
||||
@@ -1256,41 +1348,56 @@ pub(crate) async fn heal_bucket_local_on_disks(
|
||||
let disk = disk.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let bs_clone = before_state.clone();
|
||||
let as_clone = after_state.clone();
|
||||
let errs_clone = errs.to_vec();
|
||||
futures.push(async move {
|
||||
if bs_clone.read().await[idx] == DriveState::Missing.to_string() {
|
||||
let Some(disk) = disk.as_ref() else {
|
||||
return Some(Error::DiskNotFound);
|
||||
return (idx, Some(Error::DiskNotFound));
|
||||
};
|
||||
|
||||
info!("bucket not find, will recreate");
|
||||
if let Some(err) = injected_heal_bucket_operation_error(&bucket, idx, HealBucketOperation::Make) {
|
||||
return (idx, Some(err));
|
||||
}
|
||||
match disk.make_volume(&bucket).await {
|
||||
Ok(_) => {
|
||||
as_clone.write().await[idx] = DriveState::Ok.to_string();
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
return Some(err);
|
||||
}
|
||||
Ok(()) | Err(Error::VolumeExists) => return (idx, None),
|
||||
Err(err) => return (idx, Some(err)),
|
||||
}
|
||||
}
|
||||
errs_clone[idx].clone()
|
||||
(idx, None)
|
||||
});
|
||||
}
|
||||
|
||||
let _ = join_all(futures).await;
|
||||
for (index, result) in join_all(futures).await {
|
||||
match result {
|
||||
None => {
|
||||
if before_state.read().await[index] == DriveState::Missing.to_string() {
|
||||
after_state.write().await[index] = DriveState::Ok.to_string();
|
||||
}
|
||||
}
|
||||
Some(err) => {
|
||||
after_state.write().await[index] = match &err {
|
||||
Error::DiskNotFound => DriveState::Offline.to_string(),
|
||||
_ => DriveState::Corrupt.to_string(),
|
||||
};
|
||||
if operation_error.is_none() {
|
||||
operation_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (disk, state) in disks.iter().zip(after_state.read().await.iter()) {
|
||||
res.before.drives.push(HealDriveInfo {
|
||||
res.after.drives.push(HealDriveInfo {
|
||||
uuid: "".to_string(),
|
||||
endpoint: disk.clone().map(|s| s.to_string()).unwrap_or_default(),
|
||||
state: state.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
match operation_error {
|
||||
Some(err) => Err(err),
|
||||
None => Ok(res),
|
||||
}
|
||||
}
|
||||
|
||||
async fn clone_drives() -> Vec<Option<DiskStore>> {
|
||||
@@ -1756,7 +1863,7 @@ mod tests {
|
||||
.await
|
||||
.expect_err("second disk should start missing the bucket");
|
||||
|
||||
heal_bucket_local(
|
||||
let result = heal_bucket_local(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
recreate: true,
|
||||
@@ -1766,6 +1873,25 @@ mod tests {
|
||||
.await
|
||||
.expect("bucket heal should recreate missing volumes");
|
||||
|
||||
assert_eq!(result.before.drives.len(), 2);
|
||||
assert_eq!(result.after.drives.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.before
|
||||
.drives
|
||||
.iter()
|
||||
.any(|drive| drive.state == DriveState::Missing.to_string()),
|
||||
"one bucket volume must be reported missing before heal"
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.after
|
||||
.drives
|
||||
.iter()
|
||||
.all(|drive| drive.state == DriveState::Ok.to_string()),
|
||||
"all bucket volumes must be reported healthy after heal"
|
||||
);
|
||||
|
||||
for disk in disks {
|
||||
disk.stat_volume(bucket).await.expect("bucket should exist after heal");
|
||||
}
|
||||
@@ -1773,6 +1899,166 @@ mod tests {
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_dry_run_reports_discovered_drive_states() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for bucket heal dry-run regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 2, "heal-bucket-local-dry-run-reports-state").await;
|
||||
let bucket = "dry-run-healed-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the first disk");
|
||||
|
||||
let result = heal_bucket_local_on_disks(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
dry_run: true,
|
||||
..Default::default()
|
||||
},
|
||||
vec![Some(disks[0].clone()), Some(disks[1].clone()), None],
|
||||
)
|
||||
.await
|
||||
.expect("dry-run bucket heal should inspect disks");
|
||||
|
||||
assert_eq!(result.before.drives.len(), 3);
|
||||
assert_eq!(result.after.drives.len(), 3);
|
||||
assert_eq!(result.before.drives[0].state, DriveState::Ok.to_string());
|
||||
assert_eq!(result.before.drives[1].state, DriveState::Missing.to_string());
|
||||
assert_eq!(result.before.drives[2].state, DriveState::Offline.to_string());
|
||||
for (before, after) in result.before.drives.iter().zip(&result.after.drives) {
|
||||
assert_eq!(after.endpoint, before.endpoint);
|
||||
assert_eq!(after.state, before.state);
|
||||
}
|
||||
assert!(matches!(disks[1].stat_volume(bucket).await, Err(Error::VolumeNotFound)));
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_propagates_recreate_failure() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for bucket recreate failure regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 2, "heal-bucket-local-propagates-recreate-failure").await;
|
||||
let bucket = "recreate-failure-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the first disk");
|
||||
let _failure = HealBucketOperationFailure::install(bucket, 1, HealBucketOperation::Make, Error::DiskAccessDenied);
|
||||
|
||||
let error = heal_bucket_local_on_disks(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
recreate: true,
|
||||
..Default::default()
|
||||
},
|
||||
disks.iter().cloned().map(Some).collect(),
|
||||
)
|
||||
.await
|
||||
.expect_err("failed volume recreation must fail bucket heal");
|
||||
|
||||
assert_eq!(error, Error::DiskAccessDenied);
|
||||
assert!(matches!(disks[1].stat_volume(bucket).await, Err(Error::VolumeNotFound)));
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_propagates_delete_failure() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for bucket delete failure regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 2, "heal-bucket-local-propagates-delete-failure").await;
|
||||
let bucket = "delete-failure-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the first disk");
|
||||
let _failure = HealBucketOperationFailure::install(bucket, 0, HealBucketOperation::Delete, Error::DiskAccessDenied);
|
||||
|
||||
let error = heal_bucket_local_on_disks(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
remove: true,
|
||||
..Default::default()
|
||||
},
|
||||
disks.iter().cloned().map(Some).collect(),
|
||||
)
|
||||
.await
|
||||
.expect_err("failed volume deletion must fail bucket heal");
|
||||
|
||||
assert_eq!(error, Error::DiskAccessDenied);
|
||||
disks[0]
|
||||
.stat_volume(bucket)
|
||||
.await
|
||||
.expect("failed deletion must leave the bucket volume present");
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_preserves_non_empty_bucket() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for non-empty bucket heal regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 1, "heal-bucket-local-preserves-non-empty").await;
|
||||
let bucket = "non-empty-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the first disk");
|
||||
let _failure = HealBucketOperationFailure::install(bucket, 0, HealBucketOperation::Delete, Error::VolumeNotEmpty);
|
||||
|
||||
let result = heal_bucket_local_on_disks(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
remove: true,
|
||||
..Default::default()
|
||||
},
|
||||
disks.iter().cloned().map(Some).collect(),
|
||||
)
|
||||
.await
|
||||
.expect("a non-empty bucket refusal is an expected safety result");
|
||||
|
||||
assert_eq!(result.after.drives.len(), 1);
|
||||
assert_eq!(result.after.drives[0].state, DriveState::Ok.to_string());
|
||||
disks[0]
|
||||
.stat_volume(bucket)
|
||||
.await
|
||||
.expect("the non-empty bucket must remain present");
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_bucket_local_propagates_preexisting_offline_disk() {
|
||||
reset_local_disk_test_state().await;
|
||||
|
||||
let temp_dir = TempDir::new().expect("create temp dir for offline bucket heal regression");
|
||||
let disks = init_test_local_disks(&temp_dir, 1, "heal-bucket-local-preexisting-offline").await;
|
||||
let bucket = "offline-disk-bucket";
|
||||
disks[0]
|
||||
.make_volume(bucket)
|
||||
.await
|
||||
.expect("bucket should exist on the online disk");
|
||||
|
||||
let error = heal_bucket_local_on_disks(bucket, &HealOpts::default(), vec![Some(disks[0].clone()), None])
|
||||
.await
|
||||
.expect_err("a prepass offline disk must keep the bucket heal incomplete");
|
||||
|
||||
assert_eq!(error, Error::DiskNotFound);
|
||||
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reduce_pool_write_quorum_uses_only_pool_participants() {
|
||||
let clients = vec![
|
||||
|
||||
@@ -1117,15 +1117,31 @@ impl RemoteDisk {
|
||||
}
|
||||
|
||||
/// Initial capacity hint (bytes) for msgpack encode buffers, sized to cover a typical single-
|
||||
/// version `FileInfo` without repeated growth reallocations. Larger payloads still grow as needed.
|
||||
/// request without repeated growth reallocations. Larger payloads still grow as needed.
|
||||
const MSGPACK_ENCODE_CAPACITY_HINT: usize = 512;
|
||||
const FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT: usize = 1024;
|
||||
|
||||
fn encode_msgpack<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
let mut serializer = rmp_serde::Serializer::new(Vec::with_capacity(MSGPACK_ENCODE_CAPACITY_HINT));
|
||||
fn encode_msgpack_with_capacity<T: Serialize>(value: &T, capacity: usize) -> Result<Vec<u8>> {
|
||||
let mut serializer = rmp_serde::Serializer::new(Vec::with_capacity(capacity));
|
||||
value.serialize(&mut serializer)?;
|
||||
Ok(serializer.into_inner())
|
||||
}
|
||||
|
||||
fn encode_msgpack<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
encode_msgpack_with_capacity(value, MSGPACK_ENCODE_CAPACITY_HINT)
|
||||
}
|
||||
|
||||
fn encode_file_info_msgpack(value: &FileInfo) -> Result<Vec<u8>> {
|
||||
encode_msgpack_with_capacity(value, FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT)
|
||||
}
|
||||
|
||||
fn encode_file_info_versions_msgpack(value: &FileInfoVersions) -> Result<Vec<u8>> {
|
||||
let version_count = value.versions.len().saturating_add(value.free_versions.len());
|
||||
let capacity =
|
||||
MSGPACK_ENCODE_CAPACITY_HINT.saturating_add(FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT.saturating_mul(version_count));
|
||||
encode_msgpack_with_capacity(value, capacity)
|
||||
}
|
||||
|
||||
/// JSON compatibility string for a dual-encoded (`_bin` + text) request field. Returns an empty
|
||||
/// string only when msgpack-only mode and its explicit fleet confirmation guard are both enabled;
|
||||
/// otherwise the legacy JSON encoding is retained for old peers.
|
||||
@@ -1136,12 +1152,6 @@ fn compat_json<T: Serialize>(value: &T) -> Result<String> {
|
||||
Ok(serde_json::to_string(value)?)
|
||||
}
|
||||
|
||||
fn encode_msgpack_named<T: Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
let mut serializer = rmp_serde::Serializer::new(Vec::with_capacity(MSGPACK_ENCODE_CAPACITY_HINT)).with_struct_map();
|
||||
value.serialize(&mut serializer)?;
|
||||
Ok(serializer.into_inner())
|
||||
}
|
||||
|
||||
fn decode_msgpack_or_json<T: DeserializeOwned>(binary: &[u8], json: &str, value_name: &'static str) -> Result<T> {
|
||||
if !binary.is_empty() {
|
||||
let mut deserializer = rmp_serde::Deserializer::new(Cursor::new(binary));
|
||||
@@ -1580,7 +1590,7 @@ impl DiskAPI for RemoteDisk {
|
||||
|| async {
|
||||
// `_bin` support for DeleteVersion is new (grpc-optimization P2); always dual-write
|
||||
// JSON + msgpack until its fallback counter has read zero across a release window.
|
||||
let file_info_bin = encode_msgpack(&fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let opts_bin = encode_msgpack(&opts)?;
|
||||
let file_info = serde_json::to_string(&fi)?;
|
||||
let opts = serde_json::to_string(&opts)?;
|
||||
@@ -1670,7 +1680,7 @@ impl DiskAPI for RemoteDisk {
|
||||
return errors;
|
||||
}
|
||||
});
|
||||
versions_bin.push(match encode_msgpack(file_info_versions) {
|
||||
versions_bin.push(match encode_file_info_versions_msgpack(file_info_versions) {
|
||||
Ok(versions_bin) => Bytes::from(versions_bin),
|
||||
Err(err) => {
|
||||
let mut errors = Vec::with_capacity(versions.len());
|
||||
@@ -1886,7 +1896,7 @@ impl DiskAPI for RemoteDisk {
|
||||
"Remote disk RPC started"
|
||||
);
|
||||
let file_info = compat_json(&fi)?;
|
||||
let file_info_bin = encode_msgpack(&fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
|
||||
self.execute_with_timeout_for_op(
|
||||
"write_metadata",
|
||||
@@ -1965,7 +1975,7 @@ impl DiskAPI for RemoteDisk {
|
||||
);
|
||||
let file_info = compat_json(&fi)?;
|
||||
let opts_str = compat_json(&opts)?;
|
||||
let file_info_bin = encode_msgpack(&fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let opts_bin = encode_msgpack(opts)?;
|
||||
|
||||
self.execute_with_timeout_for_op(
|
||||
@@ -2229,7 +2239,7 @@ impl DiskAPI for RemoteDisk {
|
||||
"rename_data",
|
||||
|| async {
|
||||
let file_info = compat_json(&fi)?;
|
||||
let file_info_bin = encode_msgpack_named(&fi)?;
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await
|
||||
@@ -3371,6 +3381,8 @@ mod tests {
|
||||
crate::cluster::rpc::runtime_sources::reset_internode_metrics_for_test();
|
||||
let response = RenameDataResp {
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![0x14, 0x35]),
|
||||
old_current_size: Some(crate::disk::OldCurrentSize::Present(64 * 1024)),
|
||||
};
|
||||
@@ -3384,6 +3396,8 @@ mod tests {
|
||||
let decode_errors_after = crate::cluster::rpc::runtime_sources::internode_msgpack_json_decode_error_total_for_test();
|
||||
|
||||
assert_eq!(decoded.old_data_dir, response.old_data_dir);
|
||||
assert_eq!(decoded.rollback_data_dir, response.rollback_data_dir);
|
||||
assert_eq!(decoded.cleanup_data_dir, response.cleanup_data_dir);
|
||||
assert_eq!(decoded.sign, response.sign);
|
||||
assert_eq!(decoded.old_current_size, response.old_current_size);
|
||||
assert!(
|
||||
@@ -3733,8 +3747,13 @@ mod tests {
|
||||
fn rename_data_file_info_named_msgpack_is_smaller_than_json() {
|
||||
let file_info = sample_rename_data_file_info();
|
||||
let json = serde_json::to_vec(&file_info).expect("file info json should encode");
|
||||
let named_msgpack = encode_msgpack_named(&file_info).expect("file info named msgpack should encode");
|
||||
let named_msgpack = encode_file_info_msgpack(&file_info).expect("file info named msgpack should encode");
|
||||
|
||||
assert!(
|
||||
named_msgpack.len() <= FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT,
|
||||
"typical FileInfo should fit the msgpack capacity hint (msgpack={}, hint={FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT})",
|
||||
named_msgpack.len()
|
||||
);
|
||||
assert!(
|
||||
named_msgpack.len() < json.len(),
|
||||
"expected named msgpack payload to be smaller than json (msgpack={}, json={})",
|
||||
@@ -3747,11 +3766,13 @@ mod tests {
|
||||
fn rename_data_resp_named_msgpack_is_smaller_than_json() {
|
||||
let response = RenameDataResp {
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![1_u8; 32]),
|
||||
old_current_size: Some(crate::disk::OldCurrentSize::Present(4096)),
|
||||
};
|
||||
let json = serde_json::to_vec(&response).expect("rename data response json should encode");
|
||||
let named_msgpack = encode_msgpack_named(&response).expect("rename data response named msgpack should encode");
|
||||
let named_msgpack = rmp_serde::encode::to_vec_named(&response).expect("rename data response named msgpack should encode");
|
||||
|
||||
assert!(
|
||||
named_msgpack.len() < json.len(),
|
||||
|
||||
@@ -51,6 +51,7 @@ use serde_json::{Map, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{OwnedRwLockWriteGuard, RwLock as AsyncRwLock};
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -400,6 +401,14 @@ where
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_config_limited<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), false, Some(max_bytes)).await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Read an existing config object without treating an empty payload as absent.
|
||||
/// Callers that validate their own payload format need to distinguish corruption
|
||||
/// from `ConfigNotFound`.
|
||||
@@ -407,7 +416,7 @@ pub(crate) async fn read_config_preserve_empty<S>(api: Arc<S>, file: &str) -> Re
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true).await?;
|
||||
let (data, _obj) = read_config_with_metadata_inner(api, file, &ObjectOptions::default(), true, None).await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
@@ -435,6 +444,23 @@ where
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub(crate) async fn read_config_no_lock_preserve_empty_with_metadata<S>(api: Arc<S>, file: &str) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
read_config_with_metadata_inner(
|
||||
api,
|
||||
file,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn read_config_with_metadata<S>(api: Arc<S>, file: &str, opts: &ObjectOptions) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ObjectIO<
|
||||
@@ -447,7 +473,7 @@ where
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
read_config_with_metadata_inner(api, file, opts, false).await
|
||||
read_config_with_metadata_inner(api, file, opts, false, None).await
|
||||
}
|
||||
|
||||
async fn read_config_with_metadata_inner<S>(
|
||||
@@ -455,6 +481,7 @@ async fn read_config_with_metadata_inner<S>(
|
||||
file: &str,
|
||||
opts: &ObjectOptions,
|
||||
preserve_empty: bool,
|
||||
max_bytes: Option<usize>,
|
||||
) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ObjectIO<
|
||||
@@ -480,7 +507,25 @@ where
|
||||
}
|
||||
})?;
|
||||
|
||||
let data = rd.read_all().await?;
|
||||
let data = if let Some(max_bytes) = max_bytes {
|
||||
let object_size = usize::try_from(rd.object_info.size).map_err(|_| Error::CorruptedFormat)?;
|
||||
if object_size > max_bytes {
|
||||
return Err(Error::CorruptedFormat);
|
||||
}
|
||||
|
||||
let read_limit = max_bytes.checked_add(1).ok_or(Error::CorruptedFormat)?;
|
||||
let mut data = Vec::with_capacity(read_limit.min(64 * 1024));
|
||||
(&mut rd)
|
||||
.take(u64::try_from(read_limit).map_err(|_| Error::CorruptedFormat)?)
|
||||
.read_to_end(&mut data)
|
||||
.await?;
|
||||
if data.len() > max_bytes {
|
||||
return Err(Error::CorruptedFormat);
|
||||
}
|
||||
data
|
||||
} else {
|
||||
rd.read_all().await?
|
||||
};
|
||||
|
||||
if data.is_empty() && !preserve_empty {
|
||||
return Err(Error::ConfigNotFound);
|
||||
@@ -586,10 +631,47 @@ where
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
save_config_with_opts_and_metadata(api, file, data, opts).await.map(|_| ())
|
||||
save_config_with_opts_inner(api, file, data, opts, true).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Saves a configuration object without logging an error for a retryable caller-owned failure.
|
||||
pub async fn save_config_with_opts_quiet<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
save_config_with_opts_inner(api, file, data, opts, false).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn save_config_with_opts_and_metadata<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<ObjectInfo>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
save_config_with_opts_inner(api, file, data, opts, true).await
|
||||
}
|
||||
|
||||
async fn save_config_with_opts_inner<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
data: Vec<u8>,
|
||||
opts: &ObjectOptions,
|
||||
log_error: bool,
|
||||
) -> Result<ObjectInfo>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
@@ -605,7 +687,9 @@ where
|
||||
match api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts).await {
|
||||
Ok(object_info) => Ok(object_info),
|
||||
Err(err) => {
|
||||
error!("save_config_with_opts: err: {:?}, file: {}", err, file);
|
||||
if log_error {
|
||||
error!("save_config_with_opts: err: {:?}, file: {}", err, file);
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
@@ -2299,7 +2383,7 @@ where
|
||||
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, &transaction_lock).await?;
|
||||
let guard = lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||
let read_options = ObjectOptions::default();
|
||||
match read_config_with_metadata_inner(api, &config_file, &read_options, true).await {
|
||||
match read_config_with_metadata_inner(api, &config_file, &read_options, true, None).await {
|
||||
Ok((raw, object_info)) => {
|
||||
let (config, seed) = decode_persisted_server_config_with_seed(&raw)?;
|
||||
Ok(ServerConfigSnapshot {
|
||||
@@ -2555,9 +2639,10 @@ mod tests {
|
||||
use super::{
|
||||
SERVER_CONFIG_LOCK, ServerConfigSnapshot, apply_dynamic_config_for_sub_sys_with, config_task_join_error,
|
||||
configs_semantically_equal, decode_server_config_blob, encode_server_config_blob, is_standard_object_server_config,
|
||||
lookup_configs, new_and_save_server_config, read_config, read_config_preserve_empty, read_config_with_metadata,
|
||||
read_config_without_migrate, read_server_config_snapshot, save_server_config, save_server_config_snapshot,
|
||||
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, storage_class_kvs_mut,
|
||||
lookup_configs, new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata,
|
||||
read_config_preserve_empty, read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot,
|
||||
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
|
||||
server_config_transaction_lock_path, storage_class_kvs_mut,
|
||||
};
|
||||
use crate::config::{audit, heal, notify, oidc, scanner};
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
@@ -4988,10 +5073,15 @@ mod tests {
|
||||
.expect_err("the existing config contract treats empty objects as missing");
|
||||
assert!(matches!(err, Error::ConfigNotFound));
|
||||
|
||||
let data = read_config_preserve_empty(store, "config/empty.json")
|
||||
let data = read_config_preserve_empty(store.clone(), "config/empty.json")
|
||||
.await
|
||||
.expect("payload-validating callers must observe the empty object");
|
||||
assert!(data.is_empty());
|
||||
|
||||
let (data, _) = read_config_no_lock_preserve_empty_with_metadata(store, "config/empty.json")
|
||||
.await
|
||||
.expect("no-lock payload-validating callers must observe the empty object");
|
||||
assert!(data.is_empty());
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -18,13 +18,13 @@ use crate::bucket::{
|
||||
lifecycle::{
|
||||
bucket_lifecycle_audit::LcEventSrc,
|
||||
bucket_lifecycle_ops::{
|
||||
LifecycleOps, apply_expiry_on_transitioned_object, apply_expiry_rule, eval_action_from_lifecycle,
|
||||
LifecycleOps, apply_expiry_on_transitioned_object, apply_expiry_rule_in, eval_action_from_lifecycle,
|
||||
lifecycle_delete_all_versions_blocked_by_replication,
|
||||
},
|
||||
get_expiry_configs,
|
||||
lifecycle::IlmAction,
|
||||
},
|
||||
metadata_sys,
|
||||
object_lock::objectlock_sys::BucketObjectLockSys,
|
||||
};
|
||||
use crate::cache_value::metacache_set::{ListPathRawOptions, list_path_raw};
|
||||
use crate::config::com::{CONFIG_PREFIX, read_config, read_config_no_lock, save_config, save_config_with_opts};
|
||||
@@ -60,7 +60,7 @@ use rustfs_common::defer;
|
||||
use rustfs_common::heal_channel::HealOpts;
|
||||
use rustfs_filemeta::{FileInfoVersions, MetaCacheEntries, MetaCacheEntry, MetadataResolutionParams};
|
||||
use rustfs_utils::path::{encode_dir_object, path_join, path_to_bucket_object, path_to_bucket_object_with_base_path};
|
||||
use s3s::dto::{BucketLifecycleConfiguration, DefaultRetention, ReplicationConfiguration};
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration, ReplicationConfiguration};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Display;
|
||||
@@ -2192,7 +2192,7 @@ pub(crate) async fn should_skip_lifecycle_for_data_movement(
|
||||
bucket: &str,
|
||||
version: &rustfs_filemeta::FileInfo,
|
||||
lifecycle_config: Option<&BucketLifecycleConfiguration>,
|
||||
lock_retention: Option<DefaultRetention>,
|
||||
object_lock_config: Option<&ObjectLockConfiguration>,
|
||||
apply_actions: bool,
|
||||
event_source: &LcEventSrc,
|
||||
) -> Result<bool> {
|
||||
@@ -2202,12 +2202,16 @@ pub(crate) async fn should_skip_lifecycle_for_data_movement(
|
||||
|
||||
let versioned = BucketVersioningSys::prefix_enabled(bucket, &version.name).await;
|
||||
let object_info = crate::object_api::ObjectInfo::from_file_info(version, bucket, &version.name, versioned);
|
||||
let event = eval_action_from_lifecycle(lifecycle_config, lock_retention, &object_info).await;
|
||||
let event = eval_action_from_lifecycle(lifecycle_config, object_lock_config, &object_info).await;
|
||||
|
||||
match event.action {
|
||||
IlmAction::DeleteRestoredAction | IlmAction::DeleteRestoredVersionAction => {
|
||||
if apply_actions && object_info.is_remote() {
|
||||
let _ = apply_expiry_on_transitioned_object(store, &object_info, &event, event_source).await;
|
||||
let Ok(bucket_incarnation_id) = store.bucket_incarnation_id_from_disk(bucket).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
let _ =
|
||||
apply_expiry_on_transitioned_object(store, &object_info, &event, event_source, bucket_incarnation_id).await;
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -2215,7 +2219,7 @@ pub(crate) async fn should_skip_lifecycle_for_data_movement(
|
||||
if lifecycle_delete_all_versions_blocked_by_replication(store.clone(), bucket, &object_info.name, action).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
let applied = !apply_actions || apply_expiry_rule(&event, event_source, &object_info).await;
|
||||
let applied = !apply_actions || apply_expiry_rule_in(store, &event, event_source, &object_info).await;
|
||||
resolve_data_movement_lifecycle_expiry_result(action, apply_actions, applied)
|
||||
}
|
||||
_ => Ok(false),
|
||||
@@ -2647,7 +2651,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[allow(unused_assignments, clippy::too_many_arguments)]
|
||||
#[tracing::instrument(skip(self, set, _worker_permit, lifecycle_config, lock_retention, replication_config))]
|
||||
#[tracing::instrument(skip(self, set, _worker_permit, lifecycle_config, object_lock_config, replication_config))]
|
||||
async fn decommission_entry(
|
||||
self: &Arc<Self>,
|
||||
rx: CancellationToken,
|
||||
@@ -2657,7 +2661,7 @@ impl ECStore {
|
||||
set: Arc<SetDisks>,
|
||||
_worker_permit: OwnedSemaphorePermit,
|
||||
lifecycle_config: Option<BucketLifecycleConfiguration>,
|
||||
lock_retention: Option<DefaultRetention>,
|
||||
object_lock_config: Option<ObjectLockConfiguration>,
|
||||
replication_config: Option<(ReplicationConfiguration, OffsetDateTime)>,
|
||||
) -> Result<()> {
|
||||
debug!(
|
||||
@@ -2708,7 +2712,7 @@ impl ECStore {
|
||||
&bucket,
|
||||
version,
|
||||
lifecycle_config.as_ref(),
|
||||
lock_retention.clone(),
|
||||
object_lock_config.as_ref(),
|
||||
true,
|
||||
&LcEventSrc::Decom,
|
||||
)
|
||||
@@ -3013,7 +3017,14 @@ impl ECStore {
|
||||
&cleanup_preflight_allowed_missing,
|
||||
"decommission",
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
data_movement::SourceCleanupError::SourceChanged => Error::other(format!(
|
||||
"decommission: source cleanup preflight failed for {}/{}: source versions changed after migration started",
|
||||
bucket, entry.name
|
||||
)),
|
||||
data_movement::SourceCleanupError::Storage(err) => err,
|
||||
});
|
||||
resolve_decommission_entry_cleanup_delete_result(cleanup_result, bucket.as_str(), entry.name.as_str())?
|
||||
} else if decommissioned != fivs.versions.len() || expired > 0 {
|
||||
warn!(
|
||||
@@ -3113,7 +3124,7 @@ impl ECStore {
|
||||
let mut listing_workers = Vec::with_capacity(pool.disk_set.len());
|
||||
|
||||
let mut lifecycle_config = None;
|
||||
let mut lock_retention = None;
|
||||
let mut object_lock_config = None;
|
||||
let mut replication_config = None;
|
||||
|
||||
if bi.name != RUSTFS_META_BUCKET {
|
||||
@@ -3122,8 +3133,9 @@ impl ECStore {
|
||||
"versioning",
|
||||
BucketVersioningSys::get(&bi.name).await,
|
||||
)?;
|
||||
lifecycle_config = runtime_sources::bucket_lifecycle_config(&bi.name).await;
|
||||
lock_retention = BucketObjectLockSys::get(&bi.name).await;
|
||||
let expiry_configs = get_expiry_configs(self, &bi.name).await?;
|
||||
lifecycle_config = expiry_configs.lifecycle.map(|config| (*config).clone());
|
||||
object_lock_config = expiry_configs.object_lock.map(|config| (*config).clone());
|
||||
replication_config = resolve_decommission_optional_bucket_config_result(
|
||||
&bi.name,
|
||||
"replication",
|
||||
@@ -3155,7 +3167,7 @@ impl ECStore {
|
||||
let workers = workers.clone();
|
||||
let set = set.clone();
|
||||
let lifecycle_config = lifecycle_config.clone();
|
||||
let lock_retention = lock_retention.clone();
|
||||
let object_lock_config = object_lock_config.clone();
|
||||
let replication_config = replication_config.clone();
|
||||
let entry_error = entry_error.clone();
|
||||
let callback_rx = rx.clone();
|
||||
@@ -3165,7 +3177,7 @@ impl ECStore {
|
||||
let workers = workers.clone();
|
||||
let set = set.clone();
|
||||
let lifecycle_config = lifecycle_config.clone();
|
||||
let lock_retention = lock_retention.clone();
|
||||
let object_lock_config = object_lock_config.clone();
|
||||
let replication_config = replication_config.clone();
|
||||
let entry_error = entry_error.clone();
|
||||
let callback_rx = callback_rx.clone();
|
||||
@@ -3227,7 +3239,7 @@ impl ECStore {
|
||||
set,
|
||||
worker_permit,
|
||||
lifecycle_config,
|
||||
lock_retention,
|
||||
object_lock_config,
|
||||
replication_config,
|
||||
)
|
||||
.await
|
||||
@@ -3960,10 +3972,11 @@ impl ECStore {
|
||||
for set in &pool.disk_set {
|
||||
for bucket_info in &buckets {
|
||||
let mut lifecycle_config = None;
|
||||
let mut lock_retention = None;
|
||||
let mut object_lock_config = None;
|
||||
if bucket_info.name != RUSTFS_META_BUCKET {
|
||||
lifecycle_config = runtime_sources::bucket_lifecycle_config(&bucket_info.name).await;
|
||||
lock_retention = BucketObjectLockSys::get(&bucket_info.name).await;
|
||||
let expiry_configs = get_expiry_configs(self, &bucket_info.name).await?;
|
||||
lifecycle_config = expiry_configs.lifecycle.map(|config| (*config).clone());
|
||||
object_lock_config = expiry_configs.object_lock.map(|config| (*config).clone());
|
||||
}
|
||||
|
||||
let versions_found = Arc::new(AtomicUsize::new(0));
|
||||
@@ -3973,7 +3986,7 @@ impl ECStore {
|
||||
let entry_error_cb = entry_error.clone();
|
||||
let bucket_name = bucket_info.name.clone();
|
||||
let lifecycle_config_cb = lifecycle_config.clone();
|
||||
let lock_retention_cb = lock_retention.clone();
|
||||
let object_lock_config_cb = object_lock_config.clone();
|
||||
let store = Arc::clone(self);
|
||||
let callback_rx_cb = callback_rx.clone();
|
||||
|
||||
@@ -3982,7 +3995,7 @@ impl ECStore {
|
||||
let entry_error = entry_error_cb.clone();
|
||||
let bucket_name = bucket_name.clone();
|
||||
let lifecycle_config = lifecycle_config_cb.clone();
|
||||
let lock_retention = lock_retention_cb.clone();
|
||||
let object_lock_config = object_lock_config_cb.clone();
|
||||
let store = Arc::clone(&store);
|
||||
let callback_rx = callback_rx_cb.clone();
|
||||
Box::pin(async move {
|
||||
@@ -4024,7 +4037,7 @@ impl ECStore {
|
||||
&bucket_name,
|
||||
version,
|
||||
lifecycle_config.as_ref(),
|
||||
lock_retention.clone(),
|
||||
object_lock_config.as_ref(),
|
||||
false,
|
||||
&LcEventSrc::Decom,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::disk::error_reduce::count_errs;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::{Error, Result, is_all_volume_not_found, is_err_object_not_found, is_err_strict_volume_not_found};
|
||||
use crate::layout::set_heal::{formats_to_drives_info, new_heal_format_sets};
|
||||
use crate::multipart_listing::paginate_multipart_listing;
|
||||
use crate::storage_api_contracts::{
|
||||
@@ -71,6 +71,10 @@ type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
const LIST_MULTIPART_SETS_CONCURRENCY: usize = 4;
|
||||
|
||||
fn is_idempotent_delete_prefix_error(err: &Error) -> bool {
|
||||
is_err_object_not_found(err) || is_err_strict_volume_not_found(err)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sets {
|
||||
pub id: Uuid,
|
||||
@@ -339,8 +343,18 @@ impl Sets {
|
||||
futures.push(set.delete_object(bucket, object, opt.clone()));
|
||||
}
|
||||
|
||||
if let Some(err) = join_all(futures).await.into_iter().find_map(Result::err) {
|
||||
return Err(err);
|
||||
let errs = join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|result| result.err())
|
||||
.collect::<Vec<_>>();
|
||||
if is_all_volume_not_found(&errs) {
|
||||
return Err(StorageError::BucketNotFound(bucket.to_string()));
|
||||
}
|
||||
for err in errs.into_iter().flatten() {
|
||||
if !is_idempotent_delete_prefix_error(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -735,7 +749,7 @@ impl crate::storage_api_contracts::list::ListOperations for Sets {
|
||||
type WalkCancellation = CancellationToken;
|
||||
type WalkResultSender = tokio::sync::mpsc::Sender<ObjectInfoOrErr>;
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
#[tracing::instrument(level = "trace", skip(self))]
|
||||
async fn list_objects_v2(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
@@ -816,8 +830,19 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for Sets {
|
||||
let upload_id_marker = upload_id_marker.clone();
|
||||
let delimiter = delimiter.clone();
|
||||
async move {
|
||||
set.list_multipart_uploads(bucket, prefix, key_marker, upload_id_marker, delimiter, per_set_limit)
|
||||
.await
|
||||
// ECStore owns the bucket lifecycle fence and calls the
|
||||
// incarnation-aware pool helper. This lower-level trait
|
||||
// surface has no ECStore guard to propagate.
|
||||
set.list_multipart_uploads_for_incarnation(
|
||||
bucket,
|
||||
prefix,
|
||||
key_marker,
|
||||
upload_id_marker,
|
||||
delimiter,
|
||||
per_set_limit,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.buffer_unordered(LIST_MULTIPART_SETS_CONCURRENCY)
|
||||
@@ -1068,7 +1093,7 @@ impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
#[tracing::instrument(skip(self))]
|
||||
#[tracing::instrument(level = "trace", skip(self, opts), fields(bucket = %bucket, object = %object, version_id = %version_id))]
|
||||
async fn heal_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -1276,6 +1301,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_prefix_error_classification_only_ignores_absence() {
|
||||
assert!(is_idempotent_delete_prefix_error(&StorageError::FileNotFound));
|
||||
assert!(is_idempotent_delete_prefix_error(&StorageError::ObjectNotFound(
|
||||
"bucket".to_string(),
|
||||
"prefix".to_string()
|
||||
)));
|
||||
assert!(is_idempotent_delete_prefix_error(&StorageError::VolumeNotFound));
|
||||
assert!(is_idempotent_delete_prefix_error(&StorageError::BucketNotFound("bucket".to_string())));
|
||||
assert!(!is_idempotent_delete_prefix_error(&StorageError::DiskNotFound));
|
||||
assert!(!is_idempotent_delete_prefix_error(&StorageError::ErasureWriteQuorum));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sets_get_pool_and_set_returns_matching_coordinates() {
|
||||
let format = FormatV3::new(2, 2);
|
||||
@@ -1393,6 +1431,161 @@ mod tests {
|
||||
(temp_dirs, sets)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_surfaces_a_hard_error_from_any_set() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created across both sets");
|
||||
|
||||
let healthy_disks = sets.disk_set[0].disks.read().await.clone();
|
||||
for disk in healthy_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, "blocked/prefix/object", bytes::Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("healthy set should contain the prefix");
|
||||
}
|
||||
|
||||
let failing_disks = sets.disk_set[1].disks.read().await.clone();
|
||||
for disk in failing_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, "blocked", bytes::Bytes::from_static(b"not-a-directory"))
|
||||
.await
|
||||
.expect("failing set should contain a parent file");
|
||||
}
|
||||
|
||||
let err = sets
|
||||
.delete_object(
|
||||
&bucket,
|
||||
"blocked/prefix",
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a hard failure from one set must not be reported as success");
|
||||
|
||||
match err {
|
||||
StorageError::PrefixAccessDenied(error_bucket, error_prefix) => {
|
||||
assert_eq!(error_bucket, bucket);
|
||||
assert_eq!(error_prefix, "blocked/prefix");
|
||||
}
|
||||
other => panic!("unexpected recursive delete error: {other:?}"),
|
||||
}
|
||||
for disk in healthy_disks.iter().flatten() {
|
||||
assert!(
|
||||
matches!(disk.read_all(&bucket, "blocked/prefix/object").await, Err(DiskError::FileNotFound)),
|
||||
"the healthy set should still complete its prefix deletion"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_keeps_a_missing_bucket_idempotent_across_sets() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created across both sets");
|
||||
|
||||
let healthy_disks = sets.disk_set[0].disks.read().await.clone();
|
||||
for disk in healthy_disks.iter().flatten() {
|
||||
disk.write_all(&bucket, "existing/prefix/object", bytes::Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("healthy set should contain the prefix");
|
||||
}
|
||||
let missing_bucket_disks = sets.disk_set[1].disks.read().await.clone();
|
||||
for disk in missing_bucket_disks.iter().flatten() {
|
||||
disk.delete_volume(&bucket, true)
|
||||
.await
|
||||
.expect("the bucket should be removed from one set");
|
||||
}
|
||||
|
||||
sets.delete_object(
|
||||
&bucket,
|
||||
"existing/prefix",
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a missing bucket on one set should remain an idempotent success");
|
||||
for disk in healthy_disks.iter().flatten() {
|
||||
assert!(
|
||||
matches!(disk.read_all(&bucket, "existing/prefix/object").await, Err(DiskError::FileNotFound)),
|
||||
"the healthy set should still complete its prefix deletion"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_preserves_a_completely_missing_bucket_error() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let bucket = format!("delete-prefix-missing-{}", Uuid::new_v4().simple());
|
||||
|
||||
let err = sets
|
||||
.delete_object(
|
||||
&bucket,
|
||||
"missing/prefix",
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a completely missing bucket must not be reported as a successful object deletion");
|
||||
|
||||
assert_eq!(err, StorageError::BucketNotFound(bucket));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_prefix_fails_when_one_set_is_entirely_offline() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let bucket = format!("delete-prefix-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created across both sets");
|
||||
|
||||
let online_disks = sets.disk_set[0].disks.read().await.clone();
|
||||
let offline_disks = sets.disk_set[1].disks.read().await.clone();
|
||||
for disk in online_disks.iter().chain(offline_disks.iter()).flatten() {
|
||||
disk.write_all(&bucket, "offline/prefix/object", bytes::Bytes::from_static(b"data"))
|
||||
.await
|
||||
.expect("each set should contain the prefix before the outage");
|
||||
}
|
||||
*sets.disk_set[1].disks.write().await = vec![None, None];
|
||||
|
||||
let err = sets
|
||||
.delete_object(
|
||||
&bucket,
|
||||
"offline/prefix",
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("an entirely offline set must make the recursive delete fail");
|
||||
|
||||
assert!(
|
||||
matches!(err, StorageError::InsufficientWriteQuorum(ref error_bucket, ref error_prefix)
|
||||
if error_bucket == &bucket && error_prefix == "offline/prefix"),
|
||||
"unexpected offline-set error: {err:?}"
|
||||
);
|
||||
for disk in online_disks.iter().flatten() {
|
||||
assert!(matches!(
|
||||
disk.read_all(&bucket, "offline/prefix/object").await,
|
||||
Err(DiskError::FileNotFound)
|
||||
));
|
||||
}
|
||||
for disk in offline_disks.iter().flatten() {
|
||||
disk.read_all(&bucket, "offline/prefix/object")
|
||||
.await
|
||||
.expect("the offline set's untouched prefix must still be present");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_format_heal_accepts_quorum_from_a_nonzero_set() {
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
@@ -1556,7 +1749,19 @@ mod tests {
|
||||
upload_id_marker = page.next_upload_id_marker;
|
||||
}
|
||||
|
||||
assert_eq!(actual, expected, "set-level merge must return every upload exactly once");
|
||||
// Compare only the decoded `<uuid>x<timestamp>` suffixes: the full
|
||||
// upload id embeds the process-global deployment id, which a
|
||||
// concurrently running test can swap between create and list time.
|
||||
let normalize = |uploads: &[(String, String)]| {
|
||||
let mut normalized = uploads
|
||||
.iter()
|
||||
.map(|(key, upload_id)| (key.clone(), runtime_sources::upload_uuid_suffix(upload_id)))
|
||||
.collect::<Vec<_>>();
|
||||
normalized.sort();
|
||||
normalized
|
||||
};
|
||||
let actual = normalize(&actual);
|
||||
assert_eq!(actual, normalize(&expected), "set-level merge must return every upload exactly once");
|
||||
let mut deduped = actual.clone();
|
||||
deduped.dedup();
|
||||
assert_eq!(deduped.len(), actual.len(), "set-level pagination must not duplicate uploads");
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::set_disk::{SetDisks, get_lock_acquire_timeout};
|
||||
use crate::storage_api_contracts::{
|
||||
multipart::{CompletePart, MultipartOperations as _},
|
||||
namespace::NamespaceLocking as _,
|
||||
object::{ObjectIO as _, ObjectOperations as _},
|
||||
object::{HTTPPreconditions, ObjectIO as _, ObjectOperations as _},
|
||||
};
|
||||
use crate::store::ECStore;
|
||||
use bytes::Bytes;
|
||||
@@ -228,6 +228,7 @@ fn data_movement_complete_multipart_opts(object_info: &ObjectInfo, src_pool_idx:
|
||||
ObjectOptions {
|
||||
versioned: object_info.version_id.is_some(),
|
||||
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
||||
http_preconditions: data_movement_unversioned_target_precondition(object_info),
|
||||
data_movement: true,
|
||||
mod_time: object_info.mod_time,
|
||||
preserve_etag: object_info.etag.clone(),
|
||||
@@ -242,6 +243,7 @@ fn data_movement_put_object_opts(object_info: &ObjectInfo, src_pool_idx: usize)
|
||||
src_pool_idx,
|
||||
data_movement: true,
|
||||
version_id: object_info.version_id.as_ref().map(|v| v.to_string()),
|
||||
http_preconditions: data_movement_unversioned_target_precondition(object_info),
|
||||
mod_time: object_info.mod_time,
|
||||
user_defined: data_movement_user_defined(object_info),
|
||||
preserve_etag: object_info.etag.clone(),
|
||||
@@ -249,6 +251,17 @@ fn data_movement_put_object_opts(object_info: &ObjectInfo, src_pool_idx: usize)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unversioned_data_movement_object(object_info: &ObjectInfo) -> bool {
|
||||
object_info.version_id.is_none_or(|version_id| version_id.is_nil())
|
||||
}
|
||||
|
||||
fn data_movement_unversioned_target_precondition(object_info: &ObjectInfo) -> Option<HTTPPreconditions> {
|
||||
is_unversioned_data_movement_object(object_info).then(|| HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn data_movement_put_object_reader(
|
||||
bucket: &str,
|
||||
object_info: &ObjectInfo,
|
||||
@@ -337,7 +350,7 @@ fn schedule_data_movement_multipart_abort_cleanup(
|
||||
}
|
||||
|
||||
fn should_check_data_movement_overwrite_resume(err: &Error) -> bool {
|
||||
is_err_data_movement_overwrite(err)
|
||||
is_err_data_movement_overwrite(err) || matches!(err, Error::PreconditionFailed)
|
||||
}
|
||||
|
||||
fn effective_actual_size(info: &ObjectInfo) -> Option<i64> {
|
||||
@@ -403,6 +416,16 @@ fn is_equivalent_data_movement_object(source: &ObjectInfo, target: &ObjectInfo)
|
||||
&& are_equivalent_data_movement_parts(&source.parts, &target.parts)
|
||||
}
|
||||
|
||||
fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||
is_unversioned_data_movement_object(source)
|
||||
&& is_unversioned_data_movement_object(target)
|
||||
&& !target.delete_marker
|
||||
&& source
|
||||
.mod_time
|
||||
.zip(target.mod_time)
|
||||
.is_some_and(|(source_time, target_time)| target_time > source_time)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct SourceCleanupPartIdentity {
|
||||
number: usize,
|
||||
@@ -414,6 +437,15 @@ struct SourceCleanupPartIdentity {
|
||||
checksums: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct SourceCleanupErasureIdentity {
|
||||
algorithm: String,
|
||||
data_blocks: usize,
|
||||
parity_blocks: usize,
|
||||
block_size: usize,
|
||||
distribution: Vec<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) struct SourceCleanupVersionIdentity {
|
||||
name: String,
|
||||
@@ -424,10 +456,28 @@ pub(crate) struct SourceCleanupVersionIdentity {
|
||||
etag: Option<String>,
|
||||
checksum: Option<Vec<u8>>,
|
||||
data_dir: Option<uuid::Uuid>,
|
||||
transition_status: String,
|
||||
transitioned_objname: String,
|
||||
transition_tier: String,
|
||||
transition_version_id: Option<uuid::Uuid>,
|
||||
transition_version: Option<String>,
|
||||
transition_version_state: u8,
|
||||
expire_restored: bool,
|
||||
erasure: SourceCleanupErasureIdentity,
|
||||
metadata: BTreeMap<String, String>,
|
||||
parts: Vec<SourceCleanupPartIdentity>,
|
||||
}
|
||||
|
||||
fn source_cleanup_erasure_identity(erasure: &rustfs_filemeta::ErasureInfo) -> SourceCleanupErasureIdentity {
|
||||
SourceCleanupErasureIdentity {
|
||||
algorithm: erasure.algorithm.clone(),
|
||||
data_blocks: erasure.data_blocks,
|
||||
parity_blocks: erasure.parity_blocks,
|
||||
block_size: erasure.block_size,
|
||||
distribution: erasure.distribution.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_cleanup_part_identity(part: &ObjectPartInfo) -> SourceCleanupPartIdentity {
|
||||
SourceCleanupPartIdentity {
|
||||
number: part.number,
|
||||
@@ -457,6 +507,19 @@ pub(crate) fn source_cleanup_version_identity(version: &FileInfo) -> SourceClean
|
||||
etag: version.get_etag(),
|
||||
checksum: version.checksum.as_ref().map(|checksum| checksum.to_vec()),
|
||||
data_dir: version.data_dir,
|
||||
transition_status: version.transition_status.clone(),
|
||||
transitioned_objname: version.transitioned_objname.clone(),
|
||||
transition_tier: version.transition_tier.clone(),
|
||||
transition_version_id: version.transition_version_id,
|
||||
transition_version: version.transition_version.clone(),
|
||||
transition_version_state: match version.transition_version_state {
|
||||
rustfs_filemeta::TransitionVersionState::Unknown => 0,
|
||||
rustfs_filemeta::TransitionVersionState::KnownDisabled => 1,
|
||||
rustfs_filemeta::TransitionVersionState::SuspendedNull => 2,
|
||||
rustfs_filemeta::TransitionVersionState::Exact => 3,
|
||||
},
|
||||
expire_restored: version.expire_restored,
|
||||
erasure: source_cleanup_erasure_identity(&version.erasure),
|
||||
metadata: version
|
||||
.metadata
|
||||
.iter()
|
||||
@@ -472,10 +535,6 @@ fn source_cleanup_version_identities(fivs: &FileInfoVersions) -> Vec<SourceClean
|
||||
identities
|
||||
}
|
||||
|
||||
fn source_cleanup_versions_match(expected: &FileInfoVersions, current: &FileInfoVersions) -> bool {
|
||||
source_cleanup_versions_match_with_allowed_missing(expected, current, &[])
|
||||
}
|
||||
|
||||
fn source_cleanup_versions_match_with_allowed_missing(
|
||||
expected: &FileInfoVersions,
|
||||
current: &FileInfoVersions,
|
||||
@@ -507,6 +566,26 @@ fn source_cleanup_versions_match_with_allowed_missing(
|
||||
.all(|(identity, count)| allowed_counts.get(&identity).copied().unwrap_or_default() >= count)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum SourceCleanupError {
|
||||
#[error("source versions changed after migration started")]
|
||||
SourceChanged,
|
||||
#[error(transparent)]
|
||||
Storage(#[from] Error),
|
||||
}
|
||||
|
||||
fn ensure_source_cleanup_versions_match(
|
||||
expected: &FileInfoVersions,
|
||||
current: &FileInfoVersions,
|
||||
allowed_missing: &[SourceCleanupVersionIdentity],
|
||||
) -> std::result::Result<(), SourceCleanupError> {
|
||||
if source_cleanup_versions_match_with_allowed_missing(expected, current, allowed_missing) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SourceCleanupError::SourceChanged)
|
||||
}
|
||||
}
|
||||
|
||||
fn source_cleanup_preflight_error(op_label: &str, bucket: &str, object: &str, err: impl std::fmt::Display) -> Error {
|
||||
Error::other(format!("{op_label}: source cleanup preflight failed for {bucket}/{object}: {err}"))
|
||||
}
|
||||
@@ -529,21 +608,87 @@ pub(crate) async fn ensure_source_cleanup_versions_unchanged(
|
||||
expected: &FileInfoVersions,
|
||||
allowed_missing: &[SourceCleanupVersionIdentity],
|
||||
op_label: &str,
|
||||
) -> Result<()> {
|
||||
) -> std::result::Result<(), SourceCleanupError> {
|
||||
let Some(current) = load_source_cleanup_versions(set, bucket, object, op_label).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if source_cleanup_versions_match_with_allowed_missing(expected, ¤t, allowed_missing) {
|
||||
return Ok(());
|
||||
ensure_source_cleanup_versions_match(expected, ¤t, allowed_missing)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct SourceCleanupDeleteBarrierState {
|
||||
bucket: String,
|
||||
object: String,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct SourceCleanupDeleteBarrier {
|
||||
state: Arc<SourceCleanupDeleteBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static SOURCE_CLEANUP_DELETE_BARRIER: std::sync::OnceLock<std::sync::Mutex<Option<Arc<SourceCleanupDeleteBarrierState>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl SourceCleanupDeleteBarrier {
|
||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
||||
let state = Arc::new(SourceCleanupDeleteBarrierState {
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
let mut slot = SOURCE_CLEANUP_DELETE_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("source cleanup delete barrier mutex should not poison");
|
||||
assert!(slot.is_none(), "source cleanup delete barrier must be unique");
|
||||
*slot = Some(Arc::clone(&state));
|
||||
Self { state }
|
||||
}
|
||||
|
||||
Err(source_cleanup_preflight_error(
|
||||
op_label,
|
||||
bucket,
|
||||
object,
|
||||
"source versions changed after migration started",
|
||||
))
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(StdDuration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("source cleanup should reach the pre-delete barrier");
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for SourceCleanupDeleteBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
let mut slot = SOURCE_CLEANUP_DELETE_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("source cleanup delete barrier mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_source_cleanup_before_delete(bucket: &str, object: &str) {
|
||||
let barrier = SOURCE_CLEANUP_DELETE_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("source cleanup delete barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.bucket == bucket && barrier.object == object)
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.arrived.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
@@ -553,29 +698,32 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
expected: &FileInfoVersions,
|
||||
allowed_missing: &[SourceCleanupVersionIdentity],
|
||||
op_label: &str,
|
||||
) -> Result<ObjectInfo> {
|
||||
) -> std::result::Result<ObjectInfo, SourceCleanupError> {
|
||||
let cleanup_key = encode_dir_object(object);
|
||||
let ns_lock = set.new_ns_lock(bucket, cleanup_key.as_str()).await?;
|
||||
let _guard = ns_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||
let _guard = ns_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
ensure_source_cleanup_versions_unchanged(set.clone(), bucket, object, expected, allowed_missing, op_label).await?;
|
||||
|
||||
let result = set
|
||||
.delete_object(
|
||||
bucket,
|
||||
cleanup_key.as_str(),
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
delete_prefix_object: true,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
#[cfg(test)]
|
||||
pause_source_cleanup_before_delete(bucket, object).await;
|
||||
|
||||
let mut opts = ObjectOptions {
|
||||
delete_prefix: true,
|
||||
delete_prefix_object: true,
|
||||
data_movement: true,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
opts.add_namespace_lock_guard(&_guard);
|
||||
let result = set.delete_object(bucket, cleanup_key.as_str(), opts).await;
|
||||
if result.is_ok() {
|
||||
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
|
||||
}
|
||||
result
|
||||
result.map_err(SourceCleanupError::from)
|
||||
}
|
||||
|
||||
fn should_check_data_movement_resume_target(src_pool_idx: usize, target_pool_idx: usize) -> bool {
|
||||
@@ -626,7 +774,11 @@ fn resolve_data_movement_overwrite_resume_result(
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
Ok(is_equivalent_data_movement_object(source, &target))
|
||||
if is_equivalent_data_movement_object(source, &target) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
|
||||
}
|
||||
|
||||
async fn should_treat_data_movement_overwrite_as_complete(
|
||||
@@ -837,7 +989,6 @@ pub(crate) async fn migrate_object(
|
||||
bucket.as_str(),
|
||||
object_info.name.as_str()
|
||||
);
|
||||
mark_multipart_upload_completed(&abort_multipart_flag);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -856,6 +1007,32 @@ pub(crate) async fn migrate_object(
|
||||
}
|
||||
.await;
|
||||
|
||||
if multipart_result.is_ok() && should_abort_multipart_upload(&abort_multipart_flag) {
|
||||
let abort_result = match store.pools.get(target_pool_idx) {
|
||||
Some(pool) => {
|
||||
pool.abort_multipart_upload(&bucket, &object_info.name, &res.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
}
|
||||
None => Err(Error::other(format!(
|
||||
"{op_label}: target pool {target_pool_idx} is out of range while aborting superseded multipart upload"
|
||||
))),
|
||||
};
|
||||
if let Err(abort_err) = abort_result
|
||||
&& !is_err_invalid_upload_id(&abort_err)
|
||||
{
|
||||
error!("{op_label}: abort superseded multipart upload err {:?}", &abort_err);
|
||||
schedule_data_movement_multipart_abort_cleanup(
|
||||
store.clone(),
|
||||
target_pool_idx,
|
||||
bucket.clone(),
|
||||
object_info.name.clone(),
|
||||
res.upload_id.clone(),
|
||||
op_label,
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Err(primary_err) = multipart_result {
|
||||
if should_abort_multipart_upload(&abort_multipart_flag) {
|
||||
return match store
|
||||
@@ -1055,7 +1232,7 @@ mod tests {
|
||||
let expected = cleanup_test_versions(vec![first.clone(), second.clone()]);
|
||||
let current = cleanup_test_versions(vec![second, first]);
|
||||
|
||||
assert!(source_cleanup_versions_match(&expected, ¤t));
|
||||
assert!(source_cleanup_versions_match_with_allowed_missing(&expected, ¤t, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1063,7 +1240,40 @@ mod tests {
|
||||
let expected = cleanup_test_versions(vec![cleanup_test_file_info("object.txt", Uuid::from_u128(1), "source")]);
|
||||
let current = cleanup_test_versions(vec![cleanup_test_file_info("object.txt", Uuid::from_u128(1), "changed")]);
|
||||
|
||||
assert!(!source_cleanup_versions_match(&expected, ¤t));
|
||||
let err = ensure_source_cleanup_versions_match(&expected, ¤t, &[])
|
||||
.expect_err("changed source metadata must defer cleanup");
|
||||
assert!(matches!(err, SourceCleanupError::SourceChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_cleanup_preflight_rejects_changed_transition_or_erasure() {
|
||||
let expected = cleanup_test_versions(vec![cleanup_test_file_info("object.txt", Uuid::from_u128(1), "source")]);
|
||||
let mut current = expected.clone();
|
||||
current.versions[0].transition_tier = "COLD".to_string();
|
||||
let err = ensure_source_cleanup_versions_match(&expected, ¤t, &[])
|
||||
.expect_err("transition metadata changes must defer cleanup");
|
||||
assert!(matches!(err, SourceCleanupError::SourceChanged));
|
||||
|
||||
let mut current = expected.clone();
|
||||
current.versions[0].erasure.algorithm = "changed".to_string();
|
||||
let err = ensure_source_cleanup_versions_match(&expected, ¤t, &[])
|
||||
.expect_err("erasure metadata changes must defer cleanup");
|
||||
assert!(matches!(err, SourceCleanupError::SourceChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_cleanup_preflight_ignores_per_disk_erasure_fields() {
|
||||
let mut expected = cleanup_test_versions(vec![cleanup_test_file_info("object.txt", Uuid::from_u128(1), "source")]);
|
||||
expected.versions[0].erasure.checksums = vec![rustfs_filemeta::ChecksumInfo {
|
||||
part_number: 1,
|
||||
hash: Bytes::from_static(b"disk-a-checksum"),
|
||||
..Default::default()
|
||||
}];
|
||||
let mut current = expected.clone();
|
||||
current.versions[0].erasure.index = 7;
|
||||
current.versions[0].erasure.checksums[0].hash = Bytes::from_static(b"disk-b-checksum");
|
||||
|
||||
assert!(source_cleanup_versions_match_with_allowed_missing(&expected, ¤t, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1074,7 +1284,9 @@ mod tests {
|
||||
cleanup_test_file_info("object.txt", Uuid::from_u128(2), "new-version"),
|
||||
]);
|
||||
|
||||
assert!(!source_cleanup_versions_match(&expected, ¤t));
|
||||
let err = ensure_source_cleanup_versions_match(&expected, ¤t, &[])
|
||||
.expect_err("an added source version must defer cleanup");
|
||||
assert!(matches!(err, SourceCleanupError::SourceChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1095,7 +1307,9 @@ mod tests {
|
||||
let expected = cleanup_test_versions(vec![migrated.clone(), protected]);
|
||||
let current = cleanup_test_versions(vec![migrated]);
|
||||
|
||||
assert!(!source_cleanup_versions_match_with_allowed_missing(&expected, ¤t, &[]));
|
||||
let err = ensure_source_cleanup_versions_match(&expected, ¤t, &[])
|
||||
.expect_err("an unexpected missing version must defer cleanup");
|
||||
assert!(matches!(err, SourceCleanupError::SourceChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1107,7 +1321,9 @@ mod tests {
|
||||
let current = cleanup_test_versions(vec![migrated, new_version]);
|
||||
let allowed_missing = vec![source_cleanup_version_identity(&expired)];
|
||||
|
||||
assert!(!source_cleanup_versions_match_with_allowed_missing(&expected, ¤t, &allowed_missing));
|
||||
let err = ensure_source_cleanup_versions_match(&expected, ¤t, &allowed_missing)
|
||||
.expect_err("a new source version must defer cleanup even when an expired version may be missing");
|
||||
assert!(matches!(err, SourceCleanupError::SourceChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1170,12 +1386,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_check_data_movement_overwrite_resume_only_for_overwrite_error() {
|
||||
fn test_should_check_data_movement_overwrite_resume_accepts_conflict_errors() {
|
||||
assert!(should_check_data_movement_overwrite_resume(&Error::DataMovementOverwriteErr(
|
||||
"bucket-a".to_string(),
|
||||
"object-a".to_string(),
|
||||
"version-a".to_string(),
|
||||
)));
|
||||
assert!(should_check_data_movement_overwrite_resume(&Error::PreconditionFailed));
|
||||
assert!(!should_check_data_movement_overwrite_resume(&Error::SlowDown));
|
||||
}
|
||||
|
||||
@@ -1550,7 +1767,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_data_movement_complete_multipart_opts_preserves_mod_time_version_and_etag() {
|
||||
let mod_time = OffsetDateTime::now_utc();
|
||||
let version_id = Uuid::nil();
|
||||
let version_id = Uuid::from_u128(7);
|
||||
let object_info = ObjectInfo {
|
||||
version_id: Some(version_id),
|
||||
mod_time: Some(mod_time),
|
||||
@@ -1566,11 +1783,12 @@ mod tests {
|
||||
assert_eq!(opts.version_id.as_deref(), Some(version_id.to_string().as_str()));
|
||||
assert_eq!(opts.preserve_etag.as_deref(), Some("etag-value"));
|
||||
assert_eq!(opts.src_pool_idx, 7);
|
||||
assert!(opts.http_preconditions.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_movement_put_object_opts_preserves_version_and_etag() {
|
||||
let version_id = Uuid::nil();
|
||||
let version_id = Uuid::from_u128(9);
|
||||
let object_info = ObjectInfo {
|
||||
version_id: Some(version_id),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
@@ -1588,6 +1806,35 @@ mod tests {
|
||||
assert_eq!(opts.src_pool_idx, 9);
|
||||
assert!(opts.data_movement);
|
||||
assert_eq!(opts.mod_time, object_info.mod_time);
|
||||
assert!(opts.http_preconditions.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_movement_unversioned_put_and_complete_require_absent_target() {
|
||||
for version_id in [None, Some(Uuid::nil())] {
|
||||
let object_info = ObjectInfo {
|
||||
version_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let put_opts = data_movement_put_object_opts(&object_info, 9);
|
||||
let complete_opts = data_movement_complete_multipart_opts(&object_info, 9);
|
||||
|
||||
assert_eq!(
|
||||
put_opts
|
||||
.http_preconditions
|
||||
.as_ref()
|
||||
.and_then(HTTPPreconditions::if_none_match_value),
|
||||
Some("*")
|
||||
);
|
||||
assert_eq!(
|
||||
complete_opts
|
||||
.http_preconditions
|
||||
.as_ref()
|
||||
.and_then(HTTPPreconditions::if_none_match_value),
|
||||
Some("*")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1836,6 +2083,154 @@ mod tests {
|
||||
assert!(should_resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_precondition_conflict_accepts_newer_unversioned_target() {
|
||||
for version_id in [None, Some(Uuid::nil())] {
|
||||
let source = ObjectInfo {
|
||||
version_id,
|
||||
size: 128,
|
||||
etag: Some("etag-source".to_string()),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let target = ObjectInfo {
|
||||
etag: Some("etag-client-write".to_string()),
|
||||
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||
..source.clone()
|
||||
};
|
||||
|
||||
let should_resume =
|
||||
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||
.expect("precondition conflict target should be evaluated");
|
||||
|
||||
assert!(should_resume);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_precondition_conflict_accepts_equivalent_target() {
|
||||
let source = ObjectInfo {
|
||||
size: 128,
|
||||
etag: Some("etag-source".to_string()),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let should_resume =
|
||||
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(source.clone())), &source, 0, 1)
|
||||
.expect("equivalent precondition target should be evaluated");
|
||||
|
||||
assert!(should_resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_precondition_conflict_rejects_non_newer_unversioned_target() {
|
||||
let source = ObjectInfo {
|
||||
size: 128,
|
||||
etag: Some("etag-source".to_string()),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let target = ObjectInfo {
|
||||
etag: Some("etag-conflict".to_string()),
|
||||
..source.clone()
|
||||
};
|
||||
|
||||
let should_resume =
|
||||
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||
.expect("precondition conflict target should be evaluated");
|
||||
|
||||
assert!(!should_resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_precondition_conflict_rejects_newer_delete_marker() {
|
||||
let source = ObjectInfo {
|
||||
size: 128,
|
||||
etag: Some("etag-source".to_string()),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let target = ObjectInfo {
|
||||
delete_marker: true,
|
||||
etag: None,
|
||||
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||
..source.clone()
|
||||
};
|
||||
|
||||
let should_resume =
|
||||
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||
.expect("delete marker conflict should be evaluated");
|
||||
|
||||
assert!(!should_resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overwrite_error_rejects_newer_unversioned_target() {
|
||||
let source = ObjectInfo {
|
||||
size: 128,
|
||||
etag: Some("etag-source".to_string()),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let target = ObjectInfo {
|
||||
etag: Some("etag-client-write".to_string()),
|
||||
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||
..source.clone()
|
||||
};
|
||||
let err = Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string());
|
||||
|
||||
let should_resume = resolve_data_movement_overwrite_resume_result(&err, Ok(Some(target)), &source, 0, 1)
|
||||
.expect("pool-selection overwrite must require target equivalence");
|
||||
|
||||
assert!(!should_resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_precondition_conflict_rejects_newer_versioned_target() {
|
||||
let source = ObjectInfo {
|
||||
size: 128,
|
||||
etag: Some("etag-source".to_string()),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let target = ObjectInfo {
|
||||
version_id: Some(Uuid::from_u128(2)),
|
||||
etag: Some("etag-conflict".to_string()),
|
||||
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||
..source.clone()
|
||||
};
|
||||
|
||||
let should_resume =
|
||||
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||
.expect("versioned conflict target should be evaluated");
|
||||
|
||||
assert!(!should_resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_precondition_conflict_rejects_versioned_source_with_unversioned_target() {
|
||||
let source = ObjectInfo {
|
||||
version_id: Some(Uuid::from_u128(1)),
|
||||
size: 128,
|
||||
etag: Some("etag-source".to_string()),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
..Default::default()
|
||||
};
|
||||
let target = ObjectInfo {
|
||||
version_id: None,
|
||||
etag: Some("etag-conflict".to_string()),
|
||||
mod_time: OffsetDateTime::UNIX_EPOCH.checked_add(time::Duration::SECOND),
|
||||
..source.clone()
|
||||
};
|
||||
|
||||
let should_resume =
|
||||
resolve_data_movement_overwrite_resume_result(&Error::PreconditionFailed, Ok(Some(target)), &source, 0, 1)
|
||||
.expect("versioned source conflict should be evaluated");
|
||||
|
||||
assert!(!should_resume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_overwrite_resume_accepts_equivalent_target_version() {
|
||||
let source = ObjectInfo {
|
||||
|
||||
@@ -20,7 +20,7 @@ pub mod local_snapshot;
|
||||
use crate::storage_api_contracts::{
|
||||
bucket::{BucketOperations as _, BucketOptions},
|
||||
list::{ListOperations as _, StorageListObjectVersionsInfo},
|
||||
object::{EcstoreObjectIO, HTTPPreconditions, ObjectIO as _},
|
||||
object::{EcstoreObjectIO, HTTPPreconditions, ObjectIO as _, ObjectOperations as _},
|
||||
};
|
||||
use crate::{
|
||||
bucket::{metadata_sys::get_replication_config, versioning::VersioningApi as _, versioning_sys::BucketVersioningSys},
|
||||
@@ -33,8 +33,9 @@ use crate::{
|
||||
};
|
||||
pub use local_snapshot::{LocalUsageSnapshot, read_snapshot as read_local_snapshot, snapshot_path};
|
||||
use rustfs_data_usage::{
|
||||
BucketTargetUsageInfo, BucketUsageInfo, CompressionTotalInfo, DATA_USAGE_OBJECT_NAME, DataUsageCache, DataUsageEntry,
|
||||
DataUsageInfo, DiskUsageStatus, LEGACY_DATA_USAGE_OBJECT_NAME, SizeHistogram, SizeSummary, VersionsHistogram,
|
||||
BucketTargetUsageInfo, BucketUsageInfo, CompressionTotalInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
|
||||
DataUsageCache, DataUsageEntry, DataUsageInfo, DiskUsageStatus, LEGACY_DATA_USAGE_OBJECT_NAME, SizeHistogram, SizeSummary,
|
||||
VersionsHistogram, observed_data_usage_is_newer,
|
||||
};
|
||||
use rustfs_io_metrics::record_system_path_failure;
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
@@ -85,12 +86,57 @@ static USAGE_CACHE_UPDATING: OnceLock<CacheUpdating> = OnceLock::new();
|
||||
static LIVE_BUCKET_USAGE_CACHE: OnceLock<LiveBucketUsageCache> = OnceLock::new();
|
||||
static USAGE_MEMORY_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Best-available persisted usage for `bucket` when no authoritative source
|
||||
/// exists yet (issue #5716): after an upgrade from a pre-v2 release the only
|
||||
/// persisted usage data is the legacy `.usage.json`, which is demoted to
|
||||
/// non-authoritative, and the authoritative caches stay empty until the
|
||||
/// scanner's first complete cycle lands. Quota admission degrades to the
|
||||
/// pre-discard per-bucket sizes retained on the cached snapshot instead of
|
||||
/// failing every write closed.
|
||||
///
|
||||
/// The baseline is static between snapshot loads — live writes do not advance
|
||||
/// it — so hard-quota enforcement during the degraded window is advisory: the
|
||||
/// overrun is bounded only by the writes issued before the next complete
|
||||
/// scanner cycle replaces the baseline with authoritative usage. That is
|
||||
/// strictly tighter than beta.11 (usage treated as 0) and strictly more
|
||||
/// available than a blanket 503. The fallback applies to any window without
|
||||
/// authoritative usage, not only pre-v2 upgrades; the values always come from
|
||||
/// the last persisted scanner output. Loads go through the TTL-bounded
|
||||
/// snapshot cache, so the quota path adds at most one backend read per
|
||||
/// [`DATA_USAGE_CACHE_TTL_SECS`] window. Returns `None` for buckets absent
|
||||
/// from every persisted snapshot — those still fail closed.
|
||||
pub async fn lookup_degraded_bucket_usage_baseline(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
let ttl = Duration::from_secs(DATA_USAGE_CACHE_TTL_SECS);
|
||||
{
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
if let Some(cached) = cache
|
||||
.as_ref()
|
||||
.filter(|cached| tokio::time::Instant::now().duration_since(cached.loaded_at) < ttl)
|
||||
{
|
||||
return cached.degraded_baseline.get(bucket).copied();
|
||||
}
|
||||
}
|
||||
|
||||
// Stale or empty cache: refresh through the TTL-bounded loader. A failed
|
||||
// refresh carries the previous baseline forward, so quota admission keeps
|
||||
// its last grounded values through a backend read outage.
|
||||
let _ = load_data_usage_from_backend_cached(store).await;
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
cache
|
||||
.as_ref()
|
||||
.and_then(|cached| cached.degraded_baseline.get(bucket).copied())
|
||||
}
|
||||
|
||||
/// Cached copy of the last persisted data usage snapshot, served to admin
|
||||
/// endpoints for up to `DATA_USAGE_CACHE_TTL_SECS` between backend reads.
|
||||
#[derive(Debug, Clone)]
|
||||
struct CachedDataUsageSnapshot {
|
||||
info: Option<DataUsageInfo>,
|
||||
loaded_at: tokio::time::Instant,
|
||||
/// Pre-discard per-bucket sizes from the same load, retained even when the
|
||||
/// snapshot is incomplete and its bucket data is discarded. Consumed only
|
||||
/// by [`lookup_degraded_bucket_usage_baseline`] for quota admission.
|
||||
degraded_baseline: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
impl CachedDataUsageSnapshot {
|
||||
@@ -114,24 +160,34 @@ fn fresh_cached_data_usage_snapshot(
|
||||
|
||||
fn cache_data_usage_snapshot_result(
|
||||
cache: &mut Option<CachedDataUsageSnapshot>,
|
||||
result: Result<DataUsageInfo, Error>,
|
||||
result: Result<(DataUsageInfo, HashMap<String, u64>), Error>,
|
||||
loaded_at: tokio::time::Instant,
|
||||
refresh_generation: u64,
|
||||
current_generation: u64,
|
||||
) -> Option<Result<DataUsageInfo, Error>> {
|
||||
if data_usage_snapshot_generation() != refresh_generation {
|
||||
if current_generation != refresh_generation {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match result {
|
||||
Ok(info) => {
|
||||
Ok((info, degraded_baseline)) => {
|
||||
*cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(info.clone()),
|
||||
loaded_at,
|
||||
degraded_baseline,
|
||||
});
|
||||
Ok(info)
|
||||
}
|
||||
Err(e) => {
|
||||
*cache = Some(CachedDataUsageSnapshot { info: None, loaded_at });
|
||||
// Keep the previous baseline through a failed refresh: quota
|
||||
// admission must not lose its last grounded values because one
|
||||
// backend read errored.
|
||||
let degraded_baseline = cache.take().map(|cached| cached.degraded_baseline).unwrap_or_default();
|
||||
*cache = Some(CachedDataUsageSnapshot {
|
||||
info: None,
|
||||
loaded_at,
|
||||
degraded_baseline,
|
||||
});
|
||||
Err(e)
|
||||
}
|
||||
})
|
||||
@@ -142,6 +198,9 @@ type DataUsageSnapshotCache = Arc<RwLock<Option<CachedDataUsageSnapshot>>>;
|
||||
static DATA_USAGE_SNAPSHOT_CACHE: OnceLock<DataUsageSnapshotCache> = OnceLock::new();
|
||||
static DATA_USAGE_SNAPSHOT_REFRESH: OnceLock<Arc<TokioMutex<()>>> = OnceLock::new();
|
||||
static DATA_USAGE_SNAPSHOT_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
static ADMIN_DATA_USAGE_SNAPSHOT_CACHE: OnceLock<DataUsageSnapshotCache> = OnceLock::new();
|
||||
static ADMIN_DATA_USAGE_SNAPSHOT_REFRESH: OnceLock<Arc<TokioMutex<()>>> = OnceLock::new();
|
||||
static ADMIN_DATA_USAGE_SNAPSHOT_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
// Always-on revert detector for rustfs/backlog#1306: one relaxed increment per
|
||||
// full-bucket version listing is negligible and lets tests prove that admin
|
||||
@@ -200,11 +259,24 @@ fn data_usage_snapshot_generation() -> u64 {
|
||||
DATA_USAGE_SNAPSHOT_GENERATION.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn admin_data_usage_snapshot_cache() -> &'static DataUsageSnapshotCache {
|
||||
ADMIN_DATA_USAGE_SNAPSHOT_CACHE.get_or_init(|| Arc::new(RwLock::new(None)))
|
||||
}
|
||||
|
||||
fn admin_data_usage_snapshot_generation() -> u64 {
|
||||
ADMIN_DATA_USAGE_SNAPSHOT_GENERATION.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn clear_data_usage_snapshot_cache(cache: &mut Option<CachedDataUsageSnapshot>) {
|
||||
DATA_USAGE_SNAPSHOT_GENERATION.fetch_add(1, Ordering::AcqRel);
|
||||
*cache = None;
|
||||
}
|
||||
|
||||
fn clear_admin_data_usage_snapshot_cache(cache: &mut Option<CachedDataUsageSnapshot>) {
|
||||
ADMIN_DATA_USAGE_SNAPSHOT_GENERATION.fetch_add(1, Ordering::AcqRel);
|
||||
*cache = None;
|
||||
}
|
||||
|
||||
fn live_bucket_usage_cache() -> &'static LiveBucketUsageCache {
|
||||
LIVE_BUCKET_USAGE_CACHE.get_or_init(|| {
|
||||
moka::future::Cache::builder()
|
||||
@@ -229,6 +301,11 @@ lazy_static::lazy_static! {
|
||||
SLASH_SEPARATOR,
|
||||
DATA_USAGE_OBJECT_NAME
|
||||
);
|
||||
pub static ref DATA_USAGE_OBSERVED_OBJ_NAME_PATH: String = format!("{}{}{}",
|
||||
crate::disk::BUCKET_META_PREFIX,
|
||||
SLASH_SEPARATOR,
|
||||
DATA_USAGE_OBSERVED_OBJECT_NAME
|
||||
);
|
||||
static ref DATA_USAGE_OBJ_BACKUP_PATH: String = format!("{}.bkp", DATA_USAGE_OBJ_NAME_PATH.as_str());
|
||||
static ref LEGACY_DATA_USAGE_OBJ_NAME_PATH: String = format!("{}{}{}",
|
||||
crate::disk::BUCKET_META_PREFIX,
|
||||
@@ -303,6 +380,11 @@ fn stale_data_usage_persist_reason_for_source(
|
||||
/// Store data usage info to backend storage
|
||||
#[instrument(skip(store))]
|
||||
pub async fn store_data_usage_in_backend(data_usage_info: DataUsageInfo, store: Arc<ECStore>) -> Result<(), Error> {
|
||||
if data_usage_info.usage_snapshot_converged == Some(false) {
|
||||
return Err(Error::other(
|
||||
"nonconverged data usage observations cannot replace the quota-authoritative snapshot",
|
||||
));
|
||||
}
|
||||
// Prevent older data from overwriting newer persisted stats
|
||||
if let Ok((existing, source)) = load_data_usage_snapshot(store.clone()).await
|
||||
&& source.is_authoritative()
|
||||
@@ -323,10 +405,12 @@ async fn save_data_usage_in_backend(data_usage_info: DataUsageInfo, store: Arc<E
|
||||
serde_json::to_vec(&data_usage_info).map_err(|e| Error::other(format!("Failed to serialize data usage info: {e}")))?;
|
||||
|
||||
// Save to backend using the same mechanism as original code
|
||||
crate::config::com::save_config(store, &DATA_USAGE_OBJ_NAME_PATH, data)
|
||||
crate::config::com::save_config(store.clone(), &DATA_USAGE_OBJ_NAME_PATH, data)
|
||||
.await
|
||||
.map_err(Error::other)?;
|
||||
|
||||
cleanup_observed_data_usage_after_authoritative_save(store.as_ref(), &data_usage_info).await;
|
||||
|
||||
// Invalidate the cached snapshot so readers observe the new save on their
|
||||
// next request instead of waiting out the remaining TTL. The next cached
|
||||
// read reloads through `load_data_usage_from_backend`, keeping its
|
||||
@@ -336,6 +420,64 @@ async fn save_data_usage_in_backend(data_usage_info: DataUsageInfo, store: Arc<E
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
trait ObservedDataUsageSnapshotCleanup {
|
||||
async fn delete_observed_data_usage_snapshot(&self, revision: &str) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ObservedDataUsageSnapshotCleanup for ECStore {
|
||||
async fn delete_observed_data_usage_snapshot(&self, revision: &str) -> Result<(), Error> {
|
||||
self.delete_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(),
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
delete_prefix_object: true,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(revision.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_observed_data_usage_after_authoritative_save<S>(store: &S, authoritative: &DataUsageInfo)
|
||||
where
|
||||
S: EcstoreObjectIO + ObservedDataUsageSnapshotCleanup + ?Sized,
|
||||
{
|
||||
let (observed, revision) = match load_data_usage_for_bucket_removal(store, DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str()).await {
|
||||
Ok(Some(snapshot)) => snapshot,
|
||||
Ok(None) => return,
|
||||
Err(err) => {
|
||||
record_usage_snapshot_failure(
|
||||
"read_observed_before_authoritative_cleanup",
|
||||
DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(),
|
||||
&err,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if observed_data_usage_is_newer(&observed, authoritative) {
|
||||
return;
|
||||
}
|
||||
|
||||
match store.delete_observed_data_usage_snapshot(&revision).await {
|
||||
Ok(()) | Err(Error::ConfigNotFound | Error::FileNotFound | Error::ObjectNotFound(_, _) | Error::PreconditionFailed) => {}
|
||||
Err(err) => {
|
||||
record_usage_snapshot_failure(
|
||||
"delete_observed_after_authoritative_save",
|
||||
DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(),
|
||||
&err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_buckets_count_from_usage(data_usage_info: &mut DataUsageInfo) {
|
||||
data_usage_info.buckets_count = u64::try_from(data_usage_info.buckets_usage.len()).unwrap_or(u64::MAX);
|
||||
}
|
||||
@@ -389,6 +531,11 @@ pub(crate) async fn prepare_bucket_usage_for_namespace_change(
|
||||
let mut snapshot_cache = data_usage_snapshot_cache().write().await;
|
||||
ensure_bucket_namespace_guard(guard, bucket, "data usage snapshot cache cleanup")?;
|
||||
clear_data_usage_snapshot_cache(&mut snapshot_cache);
|
||||
drop(snapshot_cache);
|
||||
|
||||
let mut admin_snapshot_cache = admin_data_usage_snapshot_cache().write().await;
|
||||
ensure_bucket_namespace_guard(guard, bucket, "admin data usage snapshot cache cleanup")?;
|
||||
clear_admin_data_usage_snapshot_cache(&mut admin_snapshot_cache);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -404,6 +551,10 @@ where
|
||||
let mut snapshot_cache = data_usage_snapshot_cache().write().await;
|
||||
ensure_bucket_namespace_guard(guard, bucket, "data usage snapshot cache invalidation")?;
|
||||
clear_data_usage_snapshot_cache(&mut snapshot_cache);
|
||||
drop(snapshot_cache);
|
||||
let mut admin_snapshot_cache = admin_data_usage_snapshot_cache().write().await;
|
||||
ensure_bucket_namespace_guard(guard, bucket, "admin data usage snapshot cache invalidation")?;
|
||||
clear_admin_data_usage_snapshot_cache(&mut admin_snapshot_cache);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -497,6 +648,23 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
ensure_bucket_namespace_guard(guard, bucket, "observed data usage cleanup")?;
|
||||
if let Err(err) = remove_bucket_usage_from_object_with_retries(
|
||||
store,
|
||||
DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(),
|
||||
bucket,
|
||||
DATA_USAGE_REMOVE_CAS_RETRIES,
|
||||
None,
|
||||
guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// The authoritative timestamp was already advanced above, so admin
|
||||
// selection rejects this observation even if optional cleanup fails.
|
||||
// Never make an admin-only freshness artifact block DeleteBucket.
|
||||
record_usage_snapshot_failure("remove_bucket_from_observed", DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(), &err);
|
||||
}
|
||||
|
||||
for object in [
|
||||
LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
LEGACY_DATA_USAGE_OBJ_BACKUP_PATH.as_str(),
|
||||
@@ -761,10 +929,72 @@ async fn load_data_usage_snapshot(store: Arc<ECStore>) -> Result<(DataUsageInfo,
|
||||
/// Load data usage info from backend storage
|
||||
#[instrument(skip(store))]
|
||||
pub async fn load_data_usage_from_backend(store: Arc<ECStore>) -> Result<DataUsageInfo, Error> {
|
||||
Ok(load_data_usage_from_backend_with_baseline(store).await?.0)
|
||||
}
|
||||
|
||||
/// Like [`load_data_usage_from_backend`], but also returns the pre-discard
|
||||
/// per-bucket sizes so the cached loader can retain them as the degraded
|
||||
/// quota-admission baseline (issue #5716).
|
||||
async fn load_data_usage_from_backend_with_baseline(store: Arc<ECStore>) -> Result<(DataUsageInfo, HashMap<String, u64>), Error> {
|
||||
let (data_usage_info, source) = load_data_usage_snapshot(store).await?;
|
||||
Ok(normalize_loaded_data_usage(data_usage_info, source.is_authoritative()).await)
|
||||
}
|
||||
|
||||
async fn load_observed_data_usage_snapshot(store: Arc<ECStore>) -> Option<DataUsageInfo> {
|
||||
let data = match read_config_preserve_empty(store, &DATA_USAGE_OBSERVED_OBJ_NAME_PATH).await {
|
||||
Ok(data) => data,
|
||||
Err(Error::ConfigNotFound) => return None,
|
||||
Err(err) => {
|
||||
record_usage_snapshot_failure("read_observed", DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(), &err);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match parse_usage_snapshot(&data) {
|
||||
Ok(info) if info.usage_snapshot_converged == Some(false) && info.is_complete_bucket_usage_snapshot() => Some(info),
|
||||
Ok(_) => {
|
||||
error!(
|
||||
event = "data_usage_snapshot_load_failed",
|
||||
component = "ecstore",
|
||||
subsystem = "data_usage",
|
||||
state = "invalid_observed_snapshot",
|
||||
object = %DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(),
|
||||
"observed data usage snapshot was not a structurally complete nonconverged view"
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
record_usage_snapshot_decode_failure("parse_observed", DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(), &err);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_admin_data_usage_snapshot(
|
||||
mut authoritative: DataUsageInfo,
|
||||
authoritative_format: bool,
|
||||
observed: Option<DataUsageInfo>,
|
||||
) -> (DataUsageInfo, bool) {
|
||||
if authoritative_format
|
||||
&& authoritative.is_complete_bucket_usage_snapshot()
|
||||
&& authoritative.usage_snapshot_converged.is_none()
|
||||
{
|
||||
authoritative.usage_snapshot_converged = Some(true);
|
||||
}
|
||||
match observed {
|
||||
Some(observed) if observed_data_usage_is_newer(&observed, &authoritative) => (observed, true),
|
||||
_ => (authoritative, authoritative_format),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_admin_data_usage_from_backend(store: Arc<ECStore>) -> Result<DataUsageInfo, Error> {
|
||||
let (authoritative, source) = load_data_usage_snapshot(store.clone()).await?;
|
||||
let observed = load_observed_data_usage_snapshot(store).await;
|
||||
let (selected, selected_is_current_format) =
|
||||
select_admin_data_usage_snapshot(authoritative, source.is_authoritative(), observed);
|
||||
Ok(normalize_loaded_data_usage(selected, selected_is_current_format).await.0)
|
||||
}
|
||||
|
||||
fn discard_incomplete_bucket_usage(data_usage_info: &mut DataUsageInfo) {
|
||||
if !data_usage_info.is_complete_bucket_usage_snapshot() {
|
||||
data_usage_info.usage_snapshot_complete = false;
|
||||
@@ -807,7 +1037,13 @@ fn populate_backward_compatible_usage_maps(data_usage_info: &mut DataUsageInfo)
|
||||
}
|
||||
}
|
||||
|
||||
async fn normalize_loaded_data_usage(mut data_usage_info: DataUsageInfo, authoritative_format: bool) -> DataUsageInfo {
|
||||
/// Returns the normalized snapshot plus the pre-discard per-bucket sizes: the
|
||||
/// degraded quota-admission baseline captured before an incomplete snapshot
|
||||
/// drops its bucket data (issue #5716).
|
||||
async fn normalize_loaded_data_usage(
|
||||
mut data_usage_info: DataUsageInfo,
|
||||
authoritative_format: bool,
|
||||
) -> (DataUsageInfo, HashMap<String, u64>) {
|
||||
info!("Loaded data usage info from backend with {} buckets", data_usage_info.buckets_count);
|
||||
|
||||
if !authoritative_format {
|
||||
@@ -815,6 +1051,7 @@ async fn normalize_loaded_data_usage(mut data_usage_info: DataUsageInfo, authori
|
||||
}
|
||||
populate_backward_compatible_usage_maps(&mut data_usage_info);
|
||||
validate_complete_usage_snapshot(&mut data_usage_info);
|
||||
let degraded_baseline = data_usage_info.bucket_sizes.clone();
|
||||
discard_incomplete_bucket_usage(&mut data_usage_info);
|
||||
|
||||
// Handle replication info
|
||||
@@ -840,7 +1077,7 @@ async fn normalize_loaded_data_usage(mut data_usage_info: DataUsageInfo, authori
|
||||
}
|
||||
}
|
||||
|
||||
data_usage_info
|
||||
(data_usage_info, degraded_baseline)
|
||||
}
|
||||
|
||||
/// Load the persisted data usage snapshot through a small in-process cache.
|
||||
@@ -873,10 +1110,58 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
|
||||
}
|
||||
|
||||
let refresh_generation = data_usage_snapshot_generation();
|
||||
let result = load_data_usage_from_backend(store.clone()).await;
|
||||
let result = load_data_usage_from_backend_with_baseline(store.clone()).await;
|
||||
let loaded_at = tokio::time::Instant::now();
|
||||
let mut cache = data_usage_snapshot_cache().write().await;
|
||||
if let Some(result) = cache_data_usage_snapshot_result(&mut cache, result, loaded_at, refresh_generation) {
|
||||
if let Some(result) =
|
||||
cache_data_usage_snapshot_result(&mut cache, result, loaded_at, refresh_generation, data_usage_snapshot_generation())
|
||||
{
|
||||
return result;
|
||||
}
|
||||
drop(cache);
|
||||
drop(refresh_guard);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the freshest structurally complete snapshot for authenticated admin
|
||||
/// observability. A scan raced by namespace activity may be selected here, but
|
||||
/// never by [`load_data_usage_from_backend_cached`], which remains the
|
||||
/// converged source for quota admission.
|
||||
pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<DataUsageInfo, Error> {
|
||||
let ttl = Duration::from_secs(DATA_USAGE_CACHE_TTL_SECS);
|
||||
|
||||
loop {
|
||||
{
|
||||
let cache = admin_data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
let refresh_guard = ADMIN_DATA_USAGE_SNAPSHOT_REFRESH
|
||||
.get_or_init(|| Arc::new(TokioMutex::new(())))
|
||||
.lock()
|
||||
.await;
|
||||
{
|
||||
let cache = admin_data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
let refresh_generation = admin_data_usage_snapshot_generation();
|
||||
let result = load_admin_data_usage_from_backend(store.clone())
|
||||
.await
|
||||
.map(|info| (info, HashMap::new()));
|
||||
let loaded_at = tokio::time::Instant::now();
|
||||
let mut cache = admin_data_usage_snapshot_cache().write().await;
|
||||
if let Some(result) = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
result,
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
admin_data_usage_snapshot_generation(),
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
drop(cache);
|
||||
@@ -889,6 +1174,16 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
|
||||
pub async fn invalidate_data_usage_snapshot_cache() {
|
||||
let mut cache = data_usage_snapshot_cache().write().await;
|
||||
clear_data_usage_snapshot_cache(&mut cache);
|
||||
|
||||
let mut admin_cache = admin_data_usage_snapshot_cache().write().await;
|
||||
clear_admin_data_usage_snapshot_cache(&mut admin_cache);
|
||||
}
|
||||
|
||||
/// Invalidate only the admin/console view after an observational save. Quota
|
||||
/// admission continues to use the independently cached converged snapshot.
|
||||
pub async fn invalidate_admin_data_usage_snapshot_cache() {
|
||||
let mut cache = admin_data_usage_snapshot_cache().write().await;
|
||||
clear_admin_data_usage_snapshot_cache(&mut cache);
|
||||
}
|
||||
|
||||
/// Aggregate usage information from local disk snapshots.
|
||||
@@ -2012,6 +2307,7 @@ mod tests {
|
||||
struct UsageCasState {
|
||||
object: Option<(Vec<u8>, u64)>,
|
||||
backup_object: Option<(Vec<u8>, u64)>,
|
||||
observed_object: Option<(Vec<u8>, u64)>,
|
||||
legacy_object: Option<(Vec<u8>, u64)>,
|
||||
legacy_backup_object: Option<(Vec<u8>, u64)>,
|
||||
interleaving_snapshot: Option<Vec<u8>>,
|
||||
@@ -2031,10 +2327,24 @@ mod tests {
|
||||
state: Mutex<UsageCasState>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ObservedDataUsageSnapshotCleanup for UsageCasStore {
|
||||
async fn delete_observed_data_usage_snapshot(&self, revision: &str) -> Result<(), Error> {
|
||||
let mut state = self.state.lock().await;
|
||||
let current = state.observed_object.as_ref().ok_or(Error::FileNotFound)?.1;
|
||||
if revision != format!("usage-{current}") {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
state.observed_object = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum UsageObjectSlot {
|
||||
Primary,
|
||||
Backup,
|
||||
Observed,
|
||||
LegacyPrimary,
|
||||
LegacyBackup,
|
||||
}
|
||||
@@ -2063,6 +2373,7 @@ mod tests {
|
||||
let slot = match object {
|
||||
object if object == DATA_USAGE_OBJ_NAME_PATH.as_str() => UsageObjectSlot::Primary,
|
||||
object if object == DATA_USAGE_OBJ_BACKUP_PATH.as_str() => UsageObjectSlot::Backup,
|
||||
object if object == DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str() => UsageObjectSlot::Observed,
|
||||
object if object == LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str() => UsageObjectSlot::LegacyPrimary,
|
||||
object if object == LEGACY_DATA_USAGE_OBJ_BACKUP_PATH.as_str() => UsageObjectSlot::LegacyBackup,
|
||||
_ => return Err(Error::FileNotFound),
|
||||
@@ -2071,6 +2382,7 @@ mod tests {
|
||||
let stored = match slot {
|
||||
UsageObjectSlot::Primary => &state.object,
|
||||
UsageObjectSlot::Backup => &state.backup_object,
|
||||
UsageObjectSlot::Observed => &state.observed_object,
|
||||
UsageObjectSlot::LegacyPrimary => &state.legacy_object,
|
||||
UsageObjectSlot::LegacyBackup => &state.legacy_backup_object,
|
||||
};
|
||||
@@ -2110,6 +2422,7 @@ mod tests {
|
||||
let slot = match object {
|
||||
object if object == DATA_USAGE_OBJ_NAME_PATH.as_str() => UsageObjectSlot::Primary,
|
||||
object if object == DATA_USAGE_OBJ_BACKUP_PATH.as_str() => UsageObjectSlot::Backup,
|
||||
object if object == DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str() => UsageObjectSlot::Observed,
|
||||
object if object == LEGACY_DATA_USAGE_OBJ_NAME_PATH.as_str() => UsageObjectSlot::LegacyPrimary,
|
||||
object if object == LEGACY_DATA_USAGE_OBJ_BACKUP_PATH.as_str() => UsageObjectSlot::LegacyBackup,
|
||||
_ => return Err(Error::FileNotFound),
|
||||
@@ -2145,6 +2458,9 @@ mod tests {
|
||||
let revision = state.backup_object.as_ref().map_or(1, |(_, revision)| revision + 1);
|
||||
state.backup_object = Some((interleaving, revision));
|
||||
}
|
||||
if slot == UsageObjectSlot::Observed {
|
||||
return Err(Error::other("observed test fixture writes are injected directly"));
|
||||
}
|
||||
if slot == UsageObjectSlot::LegacyPrimary
|
||||
&& let Some(interleaving) = state.interleaving_legacy_snapshot.take()
|
||||
{
|
||||
@@ -2160,6 +2476,7 @@ mod tests {
|
||||
let current_revision = match slot {
|
||||
UsageObjectSlot::Primary => state.object.as_ref(),
|
||||
UsageObjectSlot::Backup => state.backup_object.as_ref(),
|
||||
UsageObjectSlot::Observed => state.observed_object.as_ref(),
|
||||
UsageObjectSlot::LegacyPrimary => state.legacy_object.as_ref(),
|
||||
UsageObjectSlot::LegacyBackup => state.legacy_backup_object.as_ref(),
|
||||
}
|
||||
@@ -2190,6 +2507,7 @@ mod tests {
|
||||
match slot {
|
||||
UsageObjectSlot::Primary => state.object = Some((buf, revision)),
|
||||
UsageObjectSlot::Backup => state.backup_object = Some((buf, revision)),
|
||||
UsageObjectSlot::Observed => state.observed_object = Some((buf, revision)),
|
||||
UsageObjectSlot::LegacyPrimary => state.legacy_object = Some((buf, revision)),
|
||||
UsageObjectSlot::LegacyBackup => state.legacy_backup_object = Some((buf, revision)),
|
||||
}
|
||||
@@ -2361,7 +2679,7 @@ mod tests {
|
||||
legacy.bucket_sizes.insert("large".to_string(), 0);
|
||||
legacy.buckets_count = 2;
|
||||
|
||||
let normalized = normalize_loaded_data_usage(legacy, false).await;
|
||||
let (normalized, degraded_baseline) = normalize_loaded_data_usage(legacy, false).await;
|
||||
|
||||
assert_eq!(normalized.buckets_count, 0);
|
||||
assert!(normalized.buckets_usage.is_empty());
|
||||
@@ -2369,6 +2687,10 @@ mod tests {
|
||||
assert_eq!(normalized.objects_total_count, 0);
|
||||
assert_eq!(normalized.objects_total_size, 0);
|
||||
assert!(!normalized.usage_snapshot_complete);
|
||||
// Issue #5716: the discarded sizes must survive as the degraded
|
||||
// quota-admission baseline.
|
||||
assert_eq!(degraded_baseline.get("control").copied(), Some(10_285));
|
||||
assert_eq!(degraded_baseline.get("large").copied(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2404,7 +2726,7 @@ mod tests {
|
||||
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
|
||||
info.buckets_count = 2;
|
||||
|
||||
let normalized = normalize_loaded_data_usage(info, true).await;
|
||||
let (normalized, _) = normalize_loaded_data_usage(info, true).await;
|
||||
|
||||
assert_eq!(normalized.buckets_count, 2);
|
||||
assert!(normalized.usage_snapshot_complete);
|
||||
@@ -2439,7 +2761,7 @@ mod tests {
|
||||
info.bucket_sizes.insert("partial".to_string(), 196_870_144);
|
||||
info.buckets_count = 1;
|
||||
|
||||
let normalized = normalize_loaded_data_usage(info, true).await;
|
||||
let (normalized, _) = normalize_loaded_data_usage(info, true).await;
|
||||
|
||||
assert_eq!(normalized.buckets_count, 0);
|
||||
assert!(!normalized.buckets_usage.contains_key("control"));
|
||||
@@ -2454,7 +2776,7 @@ mod tests {
|
||||
info.buckets_count = 2;
|
||||
|
||||
assert!(!data_usage_contains_bucket(&info, "missing"));
|
||||
let normalized = normalize_loaded_data_usage(info, true).await;
|
||||
let (normalized, _) = normalize_loaded_data_usage(info, true).await;
|
||||
|
||||
assert!(!normalized.usage_snapshot_complete);
|
||||
assert!(normalized.buckets_usage.is_empty());
|
||||
@@ -2463,7 +2785,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_empty_snapshot_remains_authoritative() {
|
||||
let normalized = normalize_loaded_data_usage(
|
||||
let (normalized, _) = normalize_loaded_data_usage(
|
||||
DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||
usage_snapshot_complete: true,
|
||||
@@ -2478,6 +2800,71 @@ mod tests {
|
||||
assert!(normalized.buckets_usage.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_snapshot_selection_requires_the_current_authoritative_baseline() {
|
||||
let authoritative = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||
scanner_epoch: Some(4),
|
||||
scanner_cycle: Some(10),
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
let observed = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1)),
|
||||
scanner_epoch: Some(4),
|
||||
scanner_cycle: Some(11),
|
||||
usage_snapshot_complete: true,
|
||||
usage_snapshot_converged: Some(false),
|
||||
usage_snapshot_authoritative_baseline: Some(authoritative.snapshot_identity()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (selected, _) = select_admin_data_usage_snapshot(authoritative.clone(), true, Some(observed.clone()));
|
||||
assert_eq!(selected.usage_snapshot_converged, Some(false));
|
||||
|
||||
let mut namespace_changed = authoritative;
|
||||
namespace_changed.last_update = Some(SystemTime::UNIX_EPOCH + Duration::from_secs(2));
|
||||
let (selected, _) = select_admin_data_usage_snapshot(namespace_changed, true, Some(observed));
|
||||
assert_eq!(selected.usage_snapshot_converged, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authoritative_save_cleanup_removes_observed_snapshot_best_effort() {
|
||||
let store = UsageCasStore::default();
|
||||
let authoritative = data_usage_info_for_test("bucket", 1, 10, SystemTime::UNIX_EPOCH + Duration::from_secs(2));
|
||||
let stale_observed = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1)),
|
||||
scanner_epoch: Some(4),
|
||||
scanner_cycle: Some(10),
|
||||
usage_snapshot_complete: true,
|
||||
usage_snapshot_converged: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
store.state.lock().await.observed_object =
|
||||
Some((serde_json::to_vec(&stale_observed).expect("observed snapshot should encode"), 1));
|
||||
|
||||
cleanup_observed_data_usage_after_authoritative_save(&store, &authoritative).await;
|
||||
assert!(store.state.lock().await.observed_object.is_none());
|
||||
|
||||
cleanup_observed_data_usage_after_authoritative_save(&store, &authoritative).await;
|
||||
assert!(store.state.lock().await.observed_object.is_none());
|
||||
|
||||
let newer_observed = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH + Duration::from_secs(3)),
|
||||
scanner_epoch: Some(4),
|
||||
scanner_cycle: Some(11),
|
||||
usage_snapshot_complete: true,
|
||||
usage_snapshot_converged: Some(false),
|
||||
usage_snapshot_authoritative_baseline: Some(authoritative.snapshot_identity()),
|
||||
..Default::default()
|
||||
};
|
||||
store.state.lock().await.observed_object =
|
||||
Some((serde_json::to_vec(&newer_observed).expect("observed snapshot should encode"), 2));
|
||||
|
||||
cleanup_observed_data_usage_after_authoritative_save(&store, &authoritative).await;
|
||||
assert!(store.state.lock().await.observed_object.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn cached_snapshot_failure_is_reused_until_ttl_expires() {
|
||||
@@ -2485,8 +2872,14 @@ mod tests {
|
||||
let mut cache = None;
|
||||
let refresh_generation = data_usage_snapshot_generation();
|
||||
|
||||
let first = cache_data_usage_snapshot_result(&mut cache, Err(Error::ErasureReadQuorum), loaded_at, refresh_generation)
|
||||
.expect("an uninterrupted refresh should populate the cache");
|
||||
let first = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
Err(Error::ErasureReadQuorum),
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
)
|
||||
.expect("an uninterrupted refresh should populate the cache");
|
||||
assert!(matches!(first, Err(Error::ErasureReadQuorum)));
|
||||
|
||||
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
|
||||
@@ -2504,9 +2897,15 @@ mod tests {
|
||||
let mut cache = None;
|
||||
let refresh_generation = data_usage_snapshot_generation();
|
||||
|
||||
let first = cache_data_usage_snapshot_result(&mut cache, Ok(expected), loaded_at, refresh_generation)
|
||||
.expect("an uninterrupted refresh should populate the cache")
|
||||
.expect("successful load must be returned");
|
||||
let first = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
Ok((expected, HashMap::new())),
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
)
|
||||
.expect("an uninterrupted refresh should populate the cache")
|
||||
.expect("successful load must be returned");
|
||||
assert_snapshot_bucket(&first, "bucket");
|
||||
|
||||
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
|
||||
@@ -2523,14 +2922,16 @@ mod tests {
|
||||
let mut cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(data_usage_info_for_test("stale", 1, 42, SystemTime::UNIX_EPOCH)),
|
||||
loaded_at,
|
||||
degraded_baseline: HashMap::new(),
|
||||
});
|
||||
clear_data_usage_snapshot_cache(&mut cache);
|
||||
|
||||
let stale_result = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
Ok(data_usage_info_for_test("stale", 1, 42, SystemTime::UNIX_EPOCH)),
|
||||
Ok((data_usage_info_for_test("stale", 1, 42, SystemTime::UNIX_EPOCH), HashMap::new())),
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
);
|
||||
|
||||
assert!(stale_result.is_none());
|
||||
@@ -3533,6 +3934,7 @@ mod tests {
|
||||
*snapshot_cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(successor),
|
||||
loaded_at: tokio::time::Instant::now(),
|
||||
degraded_baseline: HashMap::new(),
|
||||
});
|
||||
memory_cache()
|
||||
.write()
|
||||
@@ -3595,6 +3997,7 @@ mod tests {
|
||||
*snapshot_cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(successor),
|
||||
loaded_at: tokio::time::Instant::now(),
|
||||
degraded_baseline: HashMap::new(),
|
||||
});
|
||||
|
||||
let store_for_cleanup = store.clone();
|
||||
@@ -3646,6 +4049,7 @@ mod tests {
|
||||
*data_usage_snapshot_cache().write().await = Some(CachedDataUsageSnapshot {
|
||||
info: Some(stale),
|
||||
loaded_at: tokio::time::Instant::now(),
|
||||
degraded_baseline: HashMap::new(),
|
||||
});
|
||||
|
||||
remove_bucket_usage_from_backend_with_guard(&store, BUCKET, None)
|
||||
|
||||
@@ -24,6 +24,7 @@ pub(crate) const GET_OBJECT_PATH_EMPTY: &str = "empty";
|
||||
pub(crate) const GET_OBJECT_PATH_DIRECT_MEMORY: &str = "direct_memory";
|
||||
pub(crate) const GET_OBJECT_PATH_BODY_CACHE: &str = "body_cache";
|
||||
pub(crate) const GET_OBJECT_PATH_INLINE_DIRECT: &str = "inline_direct";
|
||||
pub(crate) const GET_OBJECT_PATH_INTERNAL_META: &str = "internal_meta";
|
||||
pub(crate) const GET_OBJECT_PATH_LEGACY_DUPLEX: &str = "legacy_duplex";
|
||||
pub(crate) const GET_OBJECT_PATH_REMOTE_TRANSITION: &str = "remote_transition";
|
||||
pub(crate) const GET_OBJECT_PATH_SET_DISK: &str = "set_disk";
|
||||
@@ -163,6 +164,7 @@ pub(crate) enum GetObjectFailureReason {
|
||||
DecodeError,
|
||||
DownstreamClosed,
|
||||
Io,
|
||||
MetadataMissing,
|
||||
RangeOrLengthInvalid,
|
||||
ReadQuorum,
|
||||
ShortRead,
|
||||
@@ -177,6 +179,7 @@ impl GetObjectFailureReason {
|
||||
Self::DecodeError => "decode_error",
|
||||
Self::DownstreamClosed => "downstream_closed",
|
||||
Self::Io => "io",
|
||||
Self::MetadataMissing => "metadata_missing",
|
||||
Self::RangeOrLengthInvalid => "range_or_length_invalid",
|
||||
Self::ReadQuorum => "read_quorum",
|
||||
Self::ShortRead => "short_read",
|
||||
@@ -190,6 +193,13 @@ pub(crate) fn classify_storage_error(err: &StorageError) -> GetObjectFailureReas
|
||||
match err {
|
||||
StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _) => GetObjectFailureReason::ReadQuorum,
|
||||
StorageError::FileCorrupt => GetObjectFailureReason::BitrotMismatch,
|
||||
StorageError::FileNotFound
|
||||
| StorageError::FileVersionNotFound
|
||||
| StorageError::VolumeNotFound
|
||||
| StorageError::BucketNotFound(_)
|
||||
| StorageError::ObjectNotFound(_, _)
|
||||
| StorageError::VersionNotFound(_, _, _)
|
||||
| StorageError::ConfigNotFound => GetObjectFailureReason::MetadataMissing,
|
||||
StorageError::InvalidRangeSpec(_) => GetObjectFailureReason::RangeOrLengthInvalid,
|
||||
StorageError::Io(io_err) => classify_io_error(io_err),
|
||||
_ => GetObjectFailureReason::Unknown,
|
||||
@@ -293,6 +303,34 @@ mod tests {
|
||||
classify_storage_error(&StorageError::InvalidRangeSpec("bad range".to_string())),
|
||||
GetObjectFailureReason::RangeOrLengthInvalid
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::FileNotFound),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::VolumeNotFound),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::ObjectNotFound("bucket".to_string(), "object".to_string())),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::BucketNotFound("bucket".to_string())),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::VersionNotFound(
|
||||
"bucket".to_string(),
|
||||
"object".to_string(),
|
||||
"version".to_string()
|
||||
)),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
assert_eq!(
|
||||
classify_storage_error(&StorageError::ConfigNotFound),
|
||||
GetObjectFailureReason::MetadataMissing
|
||||
);
|
||||
|
||||
let internal_broken_pipe = StorageError::Io(io::Error::from(io::ErrorKind::BrokenPipe));
|
||||
assert_eq!(classify_storage_error(&internal_broken_pipe), GetObjectFailureReason::Io);
|
||||
@@ -354,10 +392,12 @@ mod tests {
|
||||
assert_eq!(GetObjectFailureReason::DownstreamClosed.as_str(), "downstream_closed");
|
||||
assert_eq!(GetObjectFailureReason::BitrotMismatch.as_str(), "bitrot_mismatch");
|
||||
assert_eq!(GetObjectFailureReason::DecodeError.as_str(), "decode_error");
|
||||
assert_eq!(GetObjectFailureReason::MetadataMissing.as_str(), "metadata_missing");
|
||||
assert_eq!(GET_READER_BUFFER_OUTPUT, "output");
|
||||
assert_eq!(GET_READER_BUFFER_PREFETCH, "prefetch");
|
||||
assert_eq!(GET_OBJECT_PATH_CODEC_STREAMING_LEGACY_ENGINE, "codec_streaming_legacy_engine");
|
||||
assert_eq!(GET_OBJECT_PATH_CODEC_STREAMING_RUSTFS_ENGINE, "codec_streaming_rustfs_engine");
|
||||
assert_eq!(GET_OBJECT_PATH_INTERNAL_META, "internal_meta");
|
||||
assert_eq!(GET_DIRECT_MEMORY_DECISION_USE, "use");
|
||||
assert_eq!(GET_DIRECT_MEMORY_DECISION_FALLBACK, "fallback");
|
||||
assert_eq!(GET_DIRECT_MEMORY_REASON_NONE, "none");
|
||||
|
||||
@@ -1088,6 +1088,10 @@ impl LocalDiskWrapper {
|
||||
self.disk.clone()
|
||||
}
|
||||
|
||||
pub fn get_object_path_if_local(&self, volume: &str, path: &str) -> crate::disk::error::Result<std::path::PathBuf> {
|
||||
self.disk.get_object_path(volume, path)
|
||||
}
|
||||
|
||||
pub fn runtime_state(&self) -> RuntimeDriveHealthState {
|
||||
self.health.runtime_state()
|
||||
}
|
||||
|
||||
@@ -321,6 +321,14 @@ fn io_error_chain_contains_kind(io_error: &std::io::Error, kind: std::io::ErrorK
|
||||
|
||||
impl From<std::io::Error> for DiskError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
if let Some(error) = e.get_ref().and_then(|source| source.downcast_ref::<InternodeHttpError>()) {
|
||||
if error.is_remote_file_not_found() {
|
||||
return DiskError::FileNotFound;
|
||||
}
|
||||
if error.is_remote_volume_not_found() {
|
||||
return DiskError::VolumeNotFound;
|
||||
}
|
||||
}
|
||||
match e.downcast::<DiskError>() {
|
||||
Ok(disk_error) => disk_error,
|
||||
Err(io_error) => DiskError::Io(io_error),
|
||||
@@ -1044,6 +1052,19 @@ mod tests {
|
||||
assert!(!DiskError::FileNotFound.is_internode_http_status(429));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_internode_missing_errors_preserve_disk_error_types() {
|
||||
let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error());
|
||||
let volume_missing = DiskError::from(rustfs_rio::new_test_remote_volume_not_found_http_io_error());
|
||||
let unmarked_server_error = DiskError::from(rustfs_rio::new_test_internode_http_io_error(
|
||||
rustfs_rio::InternodeHttpErrorKind::HttpStatus(http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||
));
|
||||
|
||||
assert_eq!(file_missing, DiskError::FileNotFound);
|
||||
assert_eq!(volume_missing, DiskError::VolumeNotFound);
|
||||
assert!(matches!(unmarked_server_error, DiskError::Io(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metacache_output_stream_closed_classification_survives_clone() {
|
||||
let disk_error = DiskError::metacache_output_stream_closed();
|
||||
|
||||
@@ -119,7 +119,7 @@ fn read_all_data_std(path: &Path) -> core::result::Result<(Vec<u8>, Option<Offse
|
||||
Ok((bytes, modtime))
|
||||
}
|
||||
|
||||
fn inline_metadata_rollback_dir(version_id: Uuid, meta: &FileMeta) -> Uuid {
|
||||
pub(crate) fn inline_metadata_rollback_dir(version_id: Uuid, meta: &FileMeta) -> Uuid {
|
||||
let used_data_dirs: HashSet<Uuid> = meta.get_data_dirs().unwrap_or_default().into_iter().flatten().collect();
|
||||
let base = version_id.as_u128() ^ INLINE_METADATA_ROLLBACK_DIR_XOR;
|
||||
let mut salt = 0u128;
|
||||
@@ -240,8 +240,15 @@ async fn write_metadata_rollback_backup(object_dir: &Path, rollback_dir: Uuid, d
|
||||
}
|
||||
|
||||
async fn restore_metadata_backup(object_dir: &Path, xl_path: &Path, rollback_dir: Uuid) -> Result<()> {
|
||||
let backup_path = object_dir.join(rollback_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
rename_all(&backup_path, xl_path, object_dir).await
|
||||
let rollback_path = object_dir.join(rollback_dir.to_string());
|
||||
let backup_path = rollback_path.join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
rename_all(&backup_path, xl_path, object_dir).await?;
|
||||
// A synthetic inline rollback dir held only the backup the rename above
|
||||
// just consumed; reclaim it so the object dir can empty out. A real data
|
||||
// dir still holds its parts, so the non-recursive remove is a benign
|
||||
// no-op there (mirrors restore_delete_rollback).
|
||||
let _ = fs::remove_dir(&rollback_path).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_delete_rollback(object_dir: &Path, xl_path: &Path, rollback_dir: Uuid) -> Result<()> {
|
||||
@@ -684,6 +691,10 @@ const EVENT_DISK_LOCAL_CHECK_PARTS: &str = "disk_local_check_parts";
|
||||
const EVENT_DISK_LOCAL_ACCESS_FAILED: &str = "disk_local_access_failed";
|
||||
const EVENT_DISK_LOCAL_VOLUME_SETUP_FAILED: &str = "disk_local_volume_setup_failed";
|
||||
const EVENT_DISK_LOCAL_FORMAT_DECODE_FAILED: &str = "disk_local_format_decode_failed";
|
||||
/// A healing commit could not trash the stale destination data dir it is about
|
||||
/// to replace. Best effort — the rename that follows fails closed — but a
|
||||
/// recurring signal means heal is stuck on that drive.
|
||||
const EVENT_DISK_LOCAL_HEAL_PURGE_FAILED: &str = "disk_local_heal_purge_failed";
|
||||
const METRIC_GET_OBJECT_MMAP_PAGE_FAULTS_TOTAL: &str = "rustfs_io_get_object_mmap_page_faults_total";
|
||||
const METRIC_GET_OBJECT_DIRECT_READ_PAGE_FAULTS_TOTAL: &str = "rustfs_io_get_object_direct_read_page_faults_total";
|
||||
// io_uring read-backend gray-release observability (rustfs/backlog#1172).
|
||||
@@ -6686,6 +6697,50 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch positioned reads for local EC shard files in a single `spawn_blocking`.
|
||||
///
|
||||
/// Collapses per-shard blocking-pool round-trips that dominate warm GET
|
||||
/// fan-out on single-node multi-disk topologies.
|
||||
#[cfg(unix)]
|
||||
pub(crate) async fn batch_shard_pread(requests: Vec<(std::path::PathBuf, usize, usize)>) -> Vec<Result<Bytes>> {
|
||||
let n = requests.len();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use std::os::unix::fs::FileExt;
|
||||
|
||||
let mut results = Vec::with_capacity(n);
|
||||
for (file_path, offset, length) in requests {
|
||||
let r = (|| -> Result<Bytes> {
|
||||
let meta = std::fs::metadata(&file_path).map_err(DiskError::from)?;
|
||||
let end = offset.checked_add(length).ok_or(DiskError::FileCorrupt)?;
|
||||
if meta.len() < u64::try_from(end).unwrap_or(u64::MAX) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(&file_path).map_err(DiskError::from)?;
|
||||
let mut buf = vec![0u8; length];
|
||||
let mut total = 0usize;
|
||||
while total < length {
|
||||
let nbytes = file
|
||||
.read_at(&mut buf[total..], u64::try_from(offset + total).unwrap_or(u64::MAX))
|
||||
.map_err(DiskError::from)?;
|
||||
if nbytes == 0 {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
total += nbytes;
|
||||
}
|
||||
Ok(Bytes::from(buf))
|
||||
})();
|
||||
results.push(r);
|
||||
}
|
||||
results
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
let msg = format!("spawn_blocking join: {e}");
|
||||
(0..n).map(|_| Err(DiskError::other(msg.clone()))).collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DiskAPI for LocalDisk {
|
||||
fn to_string(&self) -> String {
|
||||
@@ -7802,6 +7857,9 @@ impl DiskAPI for LocalDisk {
|
||||
check_path_length(dst_file_path.to_string_lossy().to_string().as_str())?;
|
||||
|
||||
let no_inline = fi.data.is_none() && fi.size > 0;
|
||||
// Captured before `fi` is consumed by add_version; gates the stale
|
||||
// destination purge below.
|
||||
let fi_healing = fi.is_healing();
|
||||
|
||||
// Resolved once for the whole commit so a concurrent configuration
|
||||
// change can never leave a single rename_data half-synced. The tier is
|
||||
@@ -7918,6 +7976,26 @@ impl DiskAPI for LocalDisk {
|
||||
shard_sync_res?;
|
||||
remove_dst_base_before_commit(dst_path).map_err(to_file_error)?;
|
||||
|
||||
// Heal reuses the version's data_dir, so for in-place corruption
|
||||
// the destination dir still exists — and rename(2) cannot replace
|
||||
// a non-empty directory (EEXIST on XFS, ENOTEMPTY on ext4). Purge
|
||||
// it first, healing commits only; fresh PUTs mint a new data_dir
|
||||
// and never collide. Best effort: a real failure surfaces in the
|
||||
// rename below.
|
||||
if fi_healing
|
||||
&& let Some((_, dst_data_path)) = has_data_dir_path.as_ref()
|
||||
&& let Err(err) = self.move_to_trash(dst_data_path, true, false).await
|
||||
{
|
||||
warn!(
|
||||
event = EVENT_DISK_LOCAL_HEAL_PURGE_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
dst_path = ?dst_data_path,
|
||||
error = ?err,
|
||||
"Healing commit could not purge the stale destination data dir"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref()
|
||||
&& let Err(err) = rename_all(src_data_path, dst_data_path, &skip_parent).await
|
||||
{
|
||||
@@ -8112,7 +8190,9 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
|
||||
Ok(RenameDataResp {
|
||||
old_data_dir: rollback_data_dir,
|
||||
old_data_dir: has_old_data_dir,
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir: has_old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
})
|
||||
@@ -8303,8 +8383,9 @@ impl DiskAPI for LocalDisk {
|
||||
let _ = remove_file_if_exists(backup_path);
|
||||
}
|
||||
|
||||
Ok::<(Option<Uuid>, Option<Vec<u8>>, Option<OldCurrentSize>), std::io::Error>((
|
||||
Ok::<(Option<Uuid>, Option<Uuid>, Option<Vec<u8>>, Option<OldCurrentSize>), std::io::Error>((
|
||||
rollback_data_dir,
|
||||
old_data_dir,
|
||||
version_signature,
|
||||
old_current_size,
|
||||
))
|
||||
@@ -8319,7 +8400,7 @@ impl DiskAPI for LocalDisk {
|
||||
// invalidate itself, so it is done here. Inline objects carry their
|
||||
// data in xl.meta rather than separate part inodes, so this is mostly
|
||||
// defensive, but it keeps the inline and streaming branches consistent.
|
||||
let (old_data_dir, version_signature, old_current_size) = match inline_commit {
|
||||
let (old_data_dir, cleanup_data_dir, version_signature, old_current_size) = match inline_commit {
|
||||
Ok(committed) => committed,
|
||||
Err(err) => {
|
||||
for part_path in &invalidate_part_paths {
|
||||
@@ -8352,7 +8433,9 @@ impl DiskAPI for LocalDisk {
|
||||
}
|
||||
|
||||
Ok(RenameDataResp {
|
||||
old_data_dir,
|
||||
old_data_dir: cleanup_data_dir,
|
||||
rollback_data_dir: old_data_dir,
|
||||
cleanup_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
})
|
||||
@@ -9374,6 +9457,53 @@ mod test {
|
||||
assert!(!rollback_dir.is_nil());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_overwrite_does_not_report_rollback_dir_for_cleanup() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||
let bucket = "bucket";
|
||||
let object = "parent";
|
||||
let tmp_object = "tmp-write";
|
||||
let version_id = Uuid::nil();
|
||||
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
||||
fs::create_dir_all(dir.path().join(bucket).join(object))
|
||||
.await
|
||||
.expect("destination object directory should be created");
|
||||
fs::write(
|
||||
dir.path().join(bucket).join(object).join(STORAGE_FORMAT_FILE),
|
||||
test_meta(test_file_info(object, version_id, None, Some(Bytes::from_static(b"old")))),
|
||||
)
|
||||
.await
|
||||
.expect("old inline metadata should be written");
|
||||
fs::create_dir_all(dir.path().join(RUSTFS_META_TMP_BUCKET).join(tmp_object))
|
||||
.await
|
||||
.expect("staging object directory should be created");
|
||||
|
||||
let response = disk
|
||||
.rename_data(
|
||||
RUSTFS_META_TMP_BUCKET,
|
||||
tmp_object,
|
||||
test_file_info(object, version_id, None, Some(Bytes::from_static(b"new"))),
|
||||
bucket,
|
||||
object,
|
||||
)
|
||||
.await
|
||||
.expect("inline overwrite should commit");
|
||||
|
||||
assert_eq!(response.old_data_dir, None);
|
||||
assert_eq!(
|
||||
response.rollback_data_dir,
|
||||
Some(inline_metadata_rollback_dir(version_id, &FileMeta::new()))
|
||||
);
|
||||
assert_eq!(
|
||||
response.cleanup_data_dir, None,
|
||||
"synthetic rollback state must not be recursively reclaimed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_inline_rollback_backup_falls_back_when_hardlink_fails() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
@@ -11124,6 +11254,86 @@ mod test {
|
||||
(disk, dir)
|
||||
}
|
||||
|
||||
// Stage the bitrot-heal collision: a committed version whose data_dir is
|
||||
// present and non-empty, plus a replacement shard staged in tmp for the
|
||||
// SAME data_dir (heal repairs in place, it does not mint a new data_dir).
|
||||
async fn stage_healing_collision(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
tmp_object: &str,
|
||||
) -> (LocalDisk, tempfile::TempDir, std::path::PathBuf, FileInfo) {
|
||||
use tempfile::tempdir;
|
||||
let dir = tempdir().expect("temp dir should be created");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||
ensure_test_volume(&disk, bucket).await;
|
||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
||||
|
||||
let version_id = Uuid::parse_str("dddddddd-dddd-dddd-dddd-dddddddddddd").expect("version id should parse");
|
||||
let data_dir = Uuid::parse_str("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee").expect("data dir should parse");
|
||||
|
||||
let object_dir = dir.path().join(bucket).join(object);
|
||||
let dst_data_dir = object_dir.join(data_dir.to_string());
|
||||
fs::create_dir_all(&dst_data_dir)
|
||||
.await
|
||||
.expect("dst data dir should be created");
|
||||
fs::write(dst_data_dir.join("part.1"), b"stale-corrupt-shard")
|
||||
.await
|
||||
.expect("stale shard should be written");
|
||||
let old_fi = test_file_info(object, version_id, Some(data_dir), None);
|
||||
fs::write(object_dir.join(STORAGE_FORMAT_FILE), test_meta(old_fi))
|
||||
.await
|
||||
.expect("old metadata should be written");
|
||||
|
||||
let tmp_data_dir = dir
|
||||
.path()
|
||||
.join(RUSTFS_META_TMP_BUCKET)
|
||||
.join(tmp_object)
|
||||
.join(data_dir.to_string());
|
||||
fs::create_dir_all(&tmp_data_dir)
|
||||
.await
|
||||
.expect("tmp data dir should be created");
|
||||
fs::write(tmp_data_dir.join("part.1"), b"healed-shard")
|
||||
.await
|
||||
.expect("healed shard should be written");
|
||||
|
||||
let new_fi = test_file_info(object, version_id, Some(data_dir), None);
|
||||
(disk, dir, dst_data_dir.join("part.1"), new_fi)
|
||||
}
|
||||
|
||||
// A healing commit must replace a still-existing destination data dir;
|
||||
// without the purge it failed on every attempt and bitrot was never
|
||||
// repaired.
|
||||
#[tokio::test]
|
||||
async fn rename_data_healing_commit_replaces_stale_destination_data_dir() {
|
||||
let (disk, _dir, dst_part, mut new_fi) = stage_healing_collision("bucket", "bitrot-object", "tmp-heal-object").await;
|
||||
new_fi.set_healing();
|
||||
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, "tmp-heal-object", new_fi, "bucket", "bitrot-object")
|
||||
.await
|
||||
.expect("a healing rename_data must replace the stale destination data dir");
|
||||
|
||||
let content = fs::read(&dst_part).await.expect("healed shard should be readable");
|
||||
assert_eq!(content, b"healed-shard", "the healed shard must replace the stale corrupt content");
|
||||
}
|
||||
|
||||
// The purge is healing-gated: an ordinary commit colliding with a
|
||||
// non-empty data dir must keep failing loudly.
|
||||
#[tokio::test]
|
||||
async fn rename_data_non_healing_destination_collision_still_fails() {
|
||||
let (disk, _dir, dst_part, new_fi) = stage_healing_collision("bucket", "collision-object", "tmp-collision-object").await;
|
||||
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, "tmp-collision-object", new_fi, "bucket", "collision-object")
|
||||
.await
|
||||
.expect_err("a non-healing rename_data onto a non-empty destination data dir must fail");
|
||||
|
||||
let content = fs::read(&dst_part).await.expect("stale shard should still be readable");
|
||||
assert_eq!(
|
||||
content, b"stale-corrupt-shard",
|
||||
"a failed non-healing commit must leave the existing content untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rename_data_new_object_fsyncs_new_ancestor_dirs() {
|
||||
// A first PUT under a new prefix must fsync every newly created ancestor
|
||||
@@ -12132,6 +12342,54 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
// The undo_write restore consumes `<rollback>/xl.meta.bkp` by rename; a
|
||||
// synthetic rollback dir is then empty and must be reclaimed so the object
|
||||
// dir can empty out (BucketNotEmpty leak). A real data dir still holds its
|
||||
// parts and must survive the non-recursive remove.
|
||||
#[tokio::test]
|
||||
async fn restore_metadata_backup_reclaims_empty_rollback_dir_only() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().expect("temp dir should be created");
|
||||
let object_dir = dir.path().join("bucket").join("obj");
|
||||
let xl_path = object_dir.join(STORAGE_FORMAT_FILE);
|
||||
let rollback_dir = Uuid::new_v4();
|
||||
let rollback_path = object_dir.join(rollback_dir.to_string());
|
||||
fs::create_dir_all(&rollback_path)
|
||||
.await
|
||||
.expect("rollback dir should be created");
|
||||
fs::write(rollback_path.join(STORAGE_FORMAT_FILE_BACKUP), b"old-meta")
|
||||
.await
|
||||
.expect("backup should be written");
|
||||
|
||||
restore_metadata_backup(&object_dir, &xl_path, rollback_dir)
|
||||
.await
|
||||
.expect("restore should succeed");
|
||||
assert_eq!(
|
||||
fs::read(&xl_path).await.expect("xl.meta should be restored"),
|
||||
b"old-meta",
|
||||
"restore must move the backup back onto xl.meta"
|
||||
);
|
||||
assert!(!rollback_path.exists(), "an emptied synthetic rollback dir must be reclaimed");
|
||||
|
||||
// Real data dir: parts remain, the dir must survive.
|
||||
let real_dir = Uuid::new_v4();
|
||||
let real_path = object_dir.join(real_dir.to_string());
|
||||
fs::create_dir_all(&real_path).await.expect("real data dir should be created");
|
||||
fs::write(real_path.join(STORAGE_FORMAT_FILE_BACKUP), b"older-meta")
|
||||
.await
|
||||
.expect("backup should be written");
|
||||
fs::write(real_path.join("part.1"), b"data")
|
||||
.await
|
||||
.expect("part should be written");
|
||||
|
||||
restore_metadata_backup(&object_dir, &xl_path, real_dir)
|
||||
.await
|
||||
.expect("restore should succeed");
|
||||
assert!(real_path.join("part.1").exists(), "a real data dir must keep its parts");
|
||||
assert!(real_path.exists(), "a non-empty data dir must not be removed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_commit_failure_cleans_local_rollback_backup() {
|
||||
use tempfile::tempdir;
|
||||
@@ -17903,4 +18161,45 @@ mod test {
|
||||
(reads * shard_mib) as f64 / wall,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn test_batch_shard_pread_basic() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let payloads: &[&[u8]] = &[b"aaaaaa", b"bbbbbb", b"cccccc"];
|
||||
let mut requests = Vec::new();
|
||||
for (i, payload) in payloads.iter().enumerate() {
|
||||
let p = dir.path().join(format!("shard-{i}.bin"));
|
||||
std::fs::write(&p, payload).unwrap();
|
||||
requests.push((p, 0usize, payload.len()));
|
||||
}
|
||||
|
||||
let results = batch_shard_pread(requests).await;
|
||||
assert_eq!(results.len(), payloads.len());
|
||||
for (result, expected) in results.iter().zip(payloads.iter()) {
|
||||
assert_eq!(result.as_ref().unwrap().as_ref(), *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn test_batch_shard_pread_partial_errors() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let good_path = dir.path().join("good.bin");
|
||||
std::fs::write(&good_path, b"good data").unwrap();
|
||||
let missing_path = dir.path().join("does-not-exist.bin");
|
||||
|
||||
let requests = vec![(good_path, 0usize, 9usize), (missing_path, 0usize, 4usize)];
|
||||
|
||||
let results = batch_shard_pread(requests).await;
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].is_ok());
|
||||
assert_eq!(results[0].as_ref().unwrap().as_ref(), b"good data");
|
||||
assert!(results[1].is_err());
|
||||
assert!(matches!(results[1].as_ref().unwrap_err(), DiskError::Io(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ pub const PART_TRANSACTION_NEW_META: &str = "new.meta";
|
||||
pub const PART_TRANSACTION_OLD_META: &str = "old.meta";
|
||||
pub const PART_TRANSACTION_ROLLBACK: &str = "rollback";
|
||||
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_DISK: &str = "disk";
|
||||
const EVENT_DISK_PART_ERR_UNCLASSIFIED: &str = "disk_part_err_unclassified";
|
||||
|
||||
pub fn part_transaction_path(part_path: &str) -> String {
|
||||
match part_path.rsplit_once('/') {
|
||||
Some((parent, name)) => format!("{parent}/.{name}.rustfs-txn"),
|
||||
@@ -682,6 +686,15 @@ impl Disk {
|
||||
Disk::Remote(remote_disk) => remote_disk.enable_health_check(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the absolute filesystem path for a (volume, path) pair if this
|
||||
/// disk is local, or `None` if it is a remote disk.
|
||||
pub fn get_object_path_if_local(&self, volume: &str, path: &str) -> Option<crate::disk::error::Result<std::path::PathBuf>> {
|
||||
match self {
|
||||
Disk::Local(w) => Some(w.get_object_path_if_local(volume, path)),
|
||||
Disk::Remote(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn new_disk(ep: &Endpoint, opt: &DiskOption) -> Result<DiskStore> {
|
||||
@@ -1071,7 +1084,16 @@ pub enum OldCurrentSize {
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct RenameDataResp {
|
||||
/// Dereferenced erasure data directory retained for older coordinators.
|
||||
pub old_data_dir: Option<Uuid>,
|
||||
/// Directory containing rollback metadata. This may be synthetic and must
|
||||
/// not be used for post-commit data cleanup.
|
||||
#[serde(default)]
|
||||
pub rollback_data_dir: Option<Uuid>,
|
||||
/// Dereferenced erasure data directory that is safe to reclaim after the
|
||||
/// rename commits. Older peers omit this field, so missing means no cleanup.
|
||||
#[serde(default)]
|
||||
pub cleanup_data_dir: Option<Uuid>,
|
||||
pub sign: Option<Vec<u8>>,
|
||||
/// `None` means unknown — the disk could not determine the previous
|
||||
/// current version (pre-#1009 peer on the wire, or an existing dst
|
||||
@@ -1178,7 +1200,13 @@ pub fn conv_part_err_to_int(err: &Option<Error>) -> usize {
|
||||
Some(DiskError::DiskNotFound) => CHECK_PART_DISK_NOT_FOUND,
|
||||
None => CHECK_PART_SUCCESS,
|
||||
_ => {
|
||||
tracing::warn!("conv_part_err_to_int: unknown error: {err:?}");
|
||||
tracing::warn!(
|
||||
event = EVENT_DISK_PART_ERR_UNCLASSIFIED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK,
|
||||
error = ?err,
|
||||
"Part error has no check-part code and degrades to unknown"
|
||||
);
|
||||
CHECK_PART_UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -1468,11 +1496,15 @@ mod tests {
|
||||
|
||||
let resp = RenameDataResp {
|
||||
old_data_dir: Some(uuid),
|
||||
rollback_data_dir: Some(uuid),
|
||||
cleanup_data_dir: Some(uuid),
|
||||
sign: Some(signature.clone()),
|
||||
old_current_size: Some(OldCurrentSize::Present(42)),
|
||||
};
|
||||
|
||||
assert_eq!(resp.old_data_dir, Some(uuid));
|
||||
assert_eq!(resp.rollback_data_dir, Some(uuid));
|
||||
assert_eq!(resp.cleanup_data_dir, Some(uuid));
|
||||
assert_eq!(resp.sign, Some(signature));
|
||||
assert_eq!(resp.old_current_size, Some(OldCurrentSize::Present(42)));
|
||||
}
|
||||
@@ -1484,6 +1516,8 @@ mod tests {
|
||||
for old_current_size in [None, Some(OldCurrentSize::Absent), Some(OldCurrentSize::Present(1337))] {
|
||||
let resp = RenameDataResp {
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
sign: Some(vec![0x01, 0x02, 0x03]),
|
||||
old_current_size,
|
||||
};
|
||||
@@ -1492,6 +1526,8 @@ mod tests {
|
||||
let decoded: RenameDataResp = rmp_serde::decode::from_slice(&encoded).expect("named msgpack should decode");
|
||||
|
||||
assert_eq!(decoded.old_data_dir, resp.old_data_dir);
|
||||
assert_eq!(decoded.rollback_data_dir, resp.rollback_data_dir);
|
||||
assert_eq!(decoded.cleanup_data_dir, resp.cleanup_data_dir);
|
||||
assert_eq!(decoded.sign, resp.sign);
|
||||
assert_eq!(decoded.old_current_size, resp.old_current_size);
|
||||
}
|
||||
@@ -1517,6 +1553,8 @@ mod tests {
|
||||
let decoded: RenameDataResp = rmp_serde::decode::from_slice(&encoded).expect("legacy payload should decode");
|
||||
|
||||
assert_eq!(decoded.old_data_dir, legacy.old_data_dir);
|
||||
assert_eq!(decoded.rollback_data_dir, None);
|
||||
assert_eq!(decoded.cleanup_data_dir, None);
|
||||
assert_eq!(decoded.sign, legacy.sign);
|
||||
assert_eq!(decoded.old_current_size, None);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use crate::bucket::metadata_sys::ObjectLockConfigState;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::disk::format::FormatV3;
|
||||
use crate::disk::{DiskAPI, DiskOption, DiskStore, WalkDirOptions, new_disk};
|
||||
use crate::error::Error;
|
||||
use crate::io_support::rio::HashReader;
|
||||
use crate::object_api::{BLOCK_SIZE_V2, ObjectOptions, PutObjReader};
|
||||
use crate::object_api::{BLOCK_SIZE_V2, ObjectLockConfigSnapshot, ObjectOptions, PutObjReader};
|
||||
use crate::set_disk::SetDisks;
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::multipart::{CompletePart, MultipartOperations as _};
|
||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
|
||||
use crate::storage_api_contracts::range::HTTPRangeSpec;
|
||||
use crate::store::init_format::save_format_file;
|
||||
@@ -205,8 +207,28 @@ async fn blackbox_get_restores_body_after_one_shard_file_is_removed() {
|
||||
#[tokio::test]
|
||||
// Serialized: forces the reader-setup strategy through a process-global env var.
|
||||
#[serial_test::serial]
|
||||
async fn blackbox_get_restores_body_and_enqueues_repair_after_one_corrupt_shard() {
|
||||
use rustfs_common::heal_channel::{HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealRequestSource};
|
||||
async fn blackbox_heal_requests_preserve_repair_scope() {
|
||||
use rustfs_common::heal_channel::{
|
||||
HealAdmissionResult, HealChannelCommand, HealChannelPriority, HealChannelReceiver, HealChannelRequest, HealRequestSource,
|
||||
};
|
||||
|
||||
async fn receive_matching_heal(rx: &mut HealChannelReceiver, bucket: &str, object: &str) -> HealChannelRequest {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||
loop {
|
||||
match rx.recv().await.expect("heal channel should stay open") {
|
||||
HealChannelCommand::Start { request, response_tx }
|
||||
if request.bucket == bucket && request.object_prefix.as_deref() == Some(object) =>
|
||||
{
|
||||
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
|
||||
break request;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("matching heal request should be submitted")
|
||||
}
|
||||
|
||||
// Own the process-global heal channel so the read path's repair submission
|
||||
// becomes observable. init_heal_channel() succeeds exactly once per test
|
||||
@@ -219,6 +241,126 @@ async fn blackbox_get_restores_body_and_enqueues_repair_after_one_corrupt_shard(
|
||||
let mut heal_rx = rustfs_common::heal_channel::init_heal_channel()
|
||||
.expect("this must be the only ecstore test that owns the heal channel receiver");
|
||||
|
||||
// Ordinary PUTs use the same admission channel as read repair. A single
|
||||
// rename target failure still satisfies write quorum, so the committed
|
||||
// version must be queued for convergence without delaying the PUT ACK.
|
||||
let (_put_dirs, put_set) = make_local_set_disks(4, 2).await;
|
||||
let put_bucket = "bb-put-partial-convergence";
|
||||
let put_object = "object.bin";
|
||||
put_set
|
||||
.make_bucket(put_bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("PUT bucket should be created");
|
||||
let offline_disk = {
|
||||
let mut disks = put_set.disks.write().await;
|
||||
disks[0].take()
|
||||
};
|
||||
let mut put_reader = PutObjReader::from_vec(vec![0x42; BLOCK_SIZE_V2 + 1024]);
|
||||
let committed = put_set
|
||||
.put_object(
|
||||
put_bucket,
|
||||
put_object,
|
||||
&mut put_reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("partial ordinary PUT should succeed at write quorum");
|
||||
let committed_version = committed
|
||||
.version_id
|
||||
.expect("versioned PUT should return a version id")
|
||||
.to_string();
|
||||
|
||||
let request = tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||
loop {
|
||||
match heal_rx.recv().await.expect("heal channel should stay open") {
|
||||
HealChannelCommand::Start { request, response_tx }
|
||||
if request.bucket == put_bucket && request.object_prefix.as_deref() == Some(put_object) =>
|
||||
{
|
||||
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
|
||||
break request;
|
||||
}
|
||||
HealChannelCommand::Start { response_tx, .. } => {
|
||||
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("partial ordinary PUT should enqueue convergence heal");
|
||||
assert_eq!(request.object_version_id.as_deref(), Some(committed_version.as_str()));
|
||||
assert_eq!(request.pool_index, Some(0));
|
||||
assert_eq!(request.set_index, Some(0));
|
||||
|
||||
let duplicate_request = tokio::time::timeout(std::time::Duration::from_millis(100), async {
|
||||
loop {
|
||||
match heal_rx.recv().await.expect("heal channel should stay open") {
|
||||
HealChannelCommand::Start { request, response_tx }
|
||||
if request.bucket == put_bucket && request.object_prefix.as_deref() == Some(put_object) =>
|
||||
{
|
||||
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
|
||||
break Some(request);
|
||||
}
|
||||
HealChannelCommand::Start { response_tx, .. } => {
|
||||
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
assert!(duplicate_request.is_none(), "partial ordinary PUT must enqueue exactly one heal request");
|
||||
|
||||
{
|
||||
let mut disks = put_set.disks.write().await;
|
||||
disks[0] = offline_disk;
|
||||
}
|
||||
|
||||
let healthy_bucket = "bb-put-healthy-convergence";
|
||||
put_set
|
||||
.make_bucket(healthy_bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("healthy PUT bucket should be created");
|
||||
let mut healthy_reader = PutObjReader::from_vec(b"healthy".to_vec());
|
||||
put_set
|
||||
.put_object(
|
||||
healthy_bucket,
|
||||
put_object,
|
||||
&mut healthy_reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("healthy ordinary PUT should succeed");
|
||||
let healthy_request = tokio::time::timeout(std::time::Duration::from_millis(100), async {
|
||||
loop {
|
||||
match heal_rx.recv().await.expect("heal channel should stay open") {
|
||||
HealChannelCommand::Start { request, response_tx }
|
||||
if request.bucket == healthy_bucket && request.object_prefix.as_deref() == Some(put_object) =>
|
||||
{
|
||||
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
|
||||
break Some(request);
|
||||
}
|
||||
HealChannelCommand::Start { response_tx, .. } => {
|
||||
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
assert!(healthy_request.is_none(), "fully converged ordinary PUT must not enqueue heal");
|
||||
|
||||
// Keep data-blocks-first reader setup explicit for this deterministic
|
||||
// repair assertion (see ENV_RUSTFS_GET_DATA_BLOCKS_FIRST_READER_SETUP in
|
||||
// set_disk/core/io_primitives.rs): if a caller opts back into all-shards,
|
||||
@@ -275,19 +417,7 @@ async fn blackbox_get_restores_body_and_enqueues_repair_after_one_corrupt_shard(
|
||||
|
||||
assert_eq!(restored, payload);
|
||||
|
||||
let request = tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||
loop {
|
||||
match heal_rx.recv().await.expect("heal channel should stay open") {
|
||||
HealChannelCommand::Start { request, response_tx } if request.bucket == bucket => {
|
||||
let _ = response_tx.send(Ok(HealAdmissionResult::Accepted));
|
||||
break request;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("corrupt-shard GET should enqueue a read-repair heal request");
|
||||
let request = receive_matching_heal(&mut heal_rx, bucket, object).await;
|
||||
|
||||
assert_eq!(request.source, HealRequestSource::ReadRepair);
|
||||
assert_eq!(request.object_prefix.as_deref(), Some(object));
|
||||
@@ -296,6 +426,142 @@ async fn blackbox_get_restores_body_and_enqueues_repair_after_one_corrupt_shard(
|
||||
assert_eq!(request.set_index, Some(0));
|
||||
assert_eq!(request.priority, HealChannelPriority::Low);
|
||||
assert_eq!(request.recreate_missing, Some(true));
|
||||
|
||||
let mpu_bucket = "bb-mpu-convergence-heal";
|
||||
let partial_object = "partial.bin";
|
||||
let suspended_object = "suspended.bin";
|
||||
let payload = vec![0x5a; 1 << 20];
|
||||
let mpu_opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
set_disks
|
||||
.make_bucket(mpu_bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("multipart bucket should be created");
|
||||
|
||||
let stage_upload = async |object: &str| {
|
||||
let upload = set_disks
|
||||
.new_multipart_upload(mpu_bucket, object, &mpu_opts)
|
||||
.await
|
||||
.expect("multipart upload should be created");
|
||||
let mut reader = PutObjReader::new(
|
||||
HashReader::from_stream(
|
||||
Cursor::new(payload.clone()),
|
||||
payload.len() as i64,
|
||||
payload.len() as i64,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.expect("multipart reader should be constructed"),
|
||||
);
|
||||
let part = set_disks
|
||||
.put_object_part(mpu_bucket, object, &upload.upload_id, 1, &mut reader, &mpu_opts)
|
||||
.await
|
||||
.expect("multipart part should be written");
|
||||
(
|
||||
upload.upload_id,
|
||||
vec![CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
)
|
||||
};
|
||||
|
||||
let (partial_upload_id, partial_parts) = stage_upload(partial_object).await;
|
||||
let offline_disk = {
|
||||
let mut disks = set_disks.disks.write().await;
|
||||
disks[3].take().expect("fourth disk should be online before completion")
|
||||
};
|
||||
crate::crash_inject::arm(crate::crash_inject::CrashPoint::MultipartAfterCommitBeforePartsCleanup, partial_object);
|
||||
let completed = set_disks
|
||||
.clone()
|
||||
.complete_multipart_upload(mpu_bucket, partial_object, &partial_upload_id, partial_parts, &mpu_opts)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(completed, Err(Error::Unexpected)),
|
||||
"partial multipart completion should reach the post-commit crash point, got {completed:?}"
|
||||
);
|
||||
crate::crash_inject::disarm(crate::crash_inject::CrashPoint::MultipartAfterCommitBeforePartsCleanup, partial_object);
|
||||
|
||||
let request = receive_matching_heal(&mut heal_rx, mpu_bucket, partial_object).await;
|
||||
|
||||
let completed_version_id = request
|
||||
.object_version_id
|
||||
.clone()
|
||||
.expect("versioned multipart convergence heal must bind a version id");
|
||||
assert_eq!(request.pool_index, Some(0));
|
||||
assert_eq!(request.set_index, Some(0));
|
||||
assert_eq!(request.priority, HealChannelPriority::Normal);
|
||||
|
||||
let duplicate = tokio::time::timeout(std::time::Duration::from_millis(250), async {
|
||||
loop {
|
||||
match heal_rx.recv().await.expect("heal channel should stay open") {
|
||||
HealChannelCommand::Start { request, .. }
|
||||
if request.bucket == mpu_bucket && request.object_prefix.as_deref() == Some(partial_object) =>
|
||||
{
|
||||
break request;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(duplicate.is_err(), "partial multipart completion must enqueue exactly one heal request");
|
||||
|
||||
{
|
||||
let mut disks = set_disks.disks.write().await;
|
||||
disks[3] = Some(offline_disk);
|
||||
}
|
||||
let committed = set_disks
|
||||
.get_object_info(
|
||||
mpu_bucket,
|
||||
partial_object,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
versioned: true,
|
||||
version_id: Some(completed_version_id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("heal-bound multipart version should be committed and addressable");
|
||||
assert_eq!(committed.version_id.map(|version_id| version_id.to_string()), Some(completed_version_id));
|
||||
|
||||
let (suspended_upload_id, suspended_parts) = stage_upload(suspended_object).await;
|
||||
let offline_disk = {
|
||||
let mut disks = set_disks.disks.write().await;
|
||||
disks[3]
|
||||
.take()
|
||||
.expect("fourth disk should be online before suspended completion")
|
||||
};
|
||||
let suspended_opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
version_suspended: true,
|
||||
..Default::default()
|
||||
};
|
||||
let suspended = set_disks
|
||||
.clone()
|
||||
.complete_multipart_upload(mpu_bucket, suspended_object, &suspended_upload_id, suspended_parts, &suspended_opts)
|
||||
.await
|
||||
.expect("suspended multipart completion should succeed at write quorum");
|
||||
{
|
||||
let mut disks = set_disks.disks.write().await;
|
||||
disks[3] = Some(offline_disk);
|
||||
}
|
||||
assert!(
|
||||
suspended.version_id.is_some_and(|version_id| version_id.is_nil()),
|
||||
"suspended multipart completion should publish the null version"
|
||||
);
|
||||
|
||||
let request = receive_matching_heal(&mut heal_rx, mpu_bucket, suspended_object).await;
|
||||
|
||||
let null_version_id = uuid::Uuid::nil().to_string();
|
||||
assert_eq!(request.object_version_id.as_deref(), Some(null_version_id.as_str()));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -357,6 +623,7 @@ async fn blackbox_delete_marker_hides_object_body_without_erasing_prior_version_
|
||||
let opts = ObjectOptions {
|
||||
no_lock: true,
|
||||
version_suspended: true,
|
||||
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(ObjectLockConfigState::ConfirmedAbsent))),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -624,6 +891,9 @@ mod old_current_size_backfill {
|
||||
ObjectOptions {
|
||||
no_lock: true,
|
||||
versioned: true,
|
||||
object_lock_config_snapshot: Some(Arc::new(ObjectLockConfigSnapshot::new(
|
||||
ObjectLockConfigState::ConfirmedAbsent,
|
||||
))),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,12 +20,21 @@ use bytes::{Bytes, BytesMut};
|
||||
use reed_solomon_erasure::galois_8::ReedSolomon;
|
||||
use reed_solomon_simd;
|
||||
use smallvec::SmallVec;
|
||||
use std::io;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io,
|
||||
sync::{Arc, OnceLock, RwLock},
|
||||
};
|
||||
use tokio::io::AsyncRead;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MODERN_MAX_TOTAL_SHARDS: usize = <reed_solomon_erasure::galois_8::Field as reed_solomon_erasure::Field>::ORDER;
|
||||
const MODERN_REED_SOLOMON_CACHE_MAX_ENTRIES: usize = 64;
|
||||
|
||||
type ModernReedSolomonCache = RwLock<HashMap<(usize, usize), Arc<ReedSolomon>>>;
|
||||
|
||||
static MODERN_REED_SOLOMON_CACHE: OnceLock<ModernReedSolomonCache> = OnceLock::new();
|
||||
|
||||
/// Errors returned when constructing an [`Erasure`] codec.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -275,7 +284,7 @@ impl LegacyReedSolomonEncoder {
|
||||
pub struct ReedSolomonEncoder {
|
||||
data_shards: usize,
|
||||
parity_shards: usize,
|
||||
encoder: Option<ReedSolomon>,
|
||||
encoder: Option<Arc<ReedSolomon>>,
|
||||
}
|
||||
|
||||
impl Clone for ReedSolomonEncoder {
|
||||
@@ -291,7 +300,7 @@ impl Clone for ReedSolomonEncoder {
|
||||
impl ReedSolomonEncoder {
|
||||
fn try_new_typed(data_shards: usize, parity_shards: usize) -> Result<Self, reed_solomon_erasure::Error> {
|
||||
let encoder = if parity_shards > 0 {
|
||||
Some(ReedSolomon::new(data_shards, parity_shards)?)
|
||||
Some(cached_modern_reed_solomon(data_shards, parity_shards)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -362,6 +371,30 @@ impl ReedSolomonEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_modern_reed_solomon(data_shards: usize, parity_shards: usize) -> Result<Arc<ReedSolomon>, reed_solomon_erasure::Error> {
|
||||
let key = (data_shards, parity_shards);
|
||||
let cache = MODERN_REED_SOLOMON_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
|
||||
|
||||
if let Some(encoder) = cache
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(&key)
|
||||
.cloned()
|
||||
{
|
||||
return Ok(encoder);
|
||||
}
|
||||
|
||||
let encoder = Arc::new(ReedSolomon::new(data_shards, parity_shards)?);
|
||||
let mut cache = cache.write().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(existing) = cache.get(&key) {
|
||||
return Ok(Arc::clone(existing));
|
||||
}
|
||||
if cache.len() < MODERN_REED_SOLOMON_CACHE_MAX_ENTRIES {
|
||||
cache.insert(key, Arc::clone(&encoder));
|
||||
}
|
||||
Ok(encoder)
|
||||
}
|
||||
|
||||
fn encode_parity_shards<F>(shards: &mut [Option<Vec<u8>>], data_shards: usize, parity_shards: usize, encode: F) -> io::Result<()>
|
||||
where
|
||||
F: FnOnce(SmallVec<[&mut [u8]; 16]>) -> io::Result<()>,
|
||||
@@ -1272,6 +1305,16 @@ mod tests {
|
||||
assert!(legacy.legacy_encoder.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modern_encoder_construction_reuses_cached_codec() {
|
||||
let first = ReedSolomonEncoder::try_new_typed(31, 7).expect("modern codec should construct");
|
||||
let second = ReedSolomonEncoder::try_new_typed(31, 7).expect("modern codec should construct");
|
||||
|
||||
let first = first.encoder.as_ref().expect("modern codec should initialize an encoder");
|
||||
let second = second.encoder.as_ref().expect("modern codec should initialize an encoder");
|
||||
assert!(Arc::ptr_eq(first, second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_errors_preserve_encoder_sources() {
|
||||
let modern = ErasureConstructionError::ModernEncoder {
|
||||
|
||||
@@ -24,7 +24,11 @@ use std::io;
|
||||
use std::io::ErrorKind;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncRead;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_ERASURE: &str = "erasure";
|
||||
const EVENT_ERASURE_HEAL_STARTED: &str = "erasure_heal_started";
|
||||
|
||||
async fn read_heal_shards<R>(
|
||||
readers: &mut [Option<BitrotReader<R>>],
|
||||
@@ -115,11 +119,14 @@ impl super::Erasure {
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + Sync,
|
||||
{
|
||||
info!(
|
||||
"Erasure heal, writers len: {}, readers len: {}, total_length: {}",
|
||||
writers.len(),
|
||||
readers.len(),
|
||||
total_length
|
||||
trace!(
|
||||
event = EVENT_ERASURE_HEAL_STARTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_ERASURE,
|
||||
writer_count = writers.len(),
|
||||
reader_count = readers.len(),
|
||||
total_length,
|
||||
"Erasure heal started"
|
||||
);
|
||||
if writers.len() != self.parity_shards + self.data_shards {
|
||||
return Err(Error::other("invalid argument"));
|
||||
|
||||
@@ -483,10 +483,20 @@ fn instrument_raw_shard_writer(writer: FileWriter, is_local: bool) -> FileWriter
|
||||
}
|
||||
|
||||
fn bitrot_encoded_range(offset: usize, length: usize, shard_size: usize, checksum_algo: HashAlgorithm) -> (usize, usize) {
|
||||
(
|
||||
offset.div_ceil(shard_size) * checksum_algo.size() + offset,
|
||||
length.div_ceil(shard_size) * checksum_algo.size() + length,
|
||||
)
|
||||
adjust_shard_read_params(offset, length, shard_size, &checksum_algo)
|
||||
}
|
||||
|
||||
/// Adjusts a raw (offset, length) pair to account for per-shard checksum overhead.
|
||||
/// Returns (adjusted_offset, adjusted_length).
|
||||
pub(crate) fn adjust_shard_read_params(
|
||||
offset: usize,
|
||||
length: usize,
|
||||
shard_size: usize,
|
||||
checksum_algo: &HashAlgorithm,
|
||||
) -> (usize, usize) {
|
||||
let adj_len = length.div_ceil(shard_size) * checksum_algo.size() + length;
|
||||
let adj_off = offset.div_ceil(shard_size) * checksum_algo.size() + offset;
|
||||
(adj_off, adj_len)
|
||||
}
|
||||
|
||||
/// Create a BitrotReader from either inline data or disk file stream
|
||||
|
||||
@@ -380,6 +380,12 @@ impl WritePlan {
|
||||
}
|
||||
|
||||
pub fn apply(self, mut reader: HashReader, actual_size: i64) -> std::io::Result<HashReader> {
|
||||
// Transformations create new HashReaders around the plaintext reader. Keep
|
||||
// the request checksum metadata on the final reader for multipart/single
|
||||
// PUT persistence, but leave verification to the plaintext reader.
|
||||
let checksum = reader.content_hash().clone();
|
||||
let trailer = reader.get_trailer().cloned();
|
||||
|
||||
let encrypted = self.encryption.is_some();
|
||||
if let Some(algorithm) = self.compression {
|
||||
reader = HashReader::from_reader(
|
||||
@@ -438,6 +444,12 @@ impl WritePlan {
|
||||
};
|
||||
}
|
||||
|
||||
// `ignore_value` deliberately avoids a second hasher over compressed or
|
||||
// encrypted bytes. The inner reader still validates the plaintext request
|
||||
// checksum while this outer reader exposes the request checksum context.
|
||||
reader.add_non_trailing_checksum(checksum, true)?;
|
||||
reader.set_trailer(trailer);
|
||||
|
||||
Ok(reader)
|
||||
}
|
||||
}
|
||||
@@ -445,10 +457,73 @@ impl WritePlan {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use rustfs_rio::{Checksum, ChecksumType};
|
||||
use rustfs_utils::CompressionAlgorithm;
|
||||
use std::io::Cursor;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
async fn assert_non_trailing_checksum_survives(plan: WritePlan) {
|
||||
let plaintext = b"checksum-context-through-write-plan".repeat(256);
|
||||
let actual_size = plaintext.len() as i64;
|
||||
let checksum = Checksum::new_from_data(ChecksumType::CRC32, &plaintext).expect("create CRC32 checksum");
|
||||
let mut reader = HashReader::from_stream(Cursor::new(plaintext), actual_size, actual_size, None, None, false)
|
||||
.expect("create hash reader");
|
||||
reader
|
||||
.add_non_trailing_checksum(Some(checksum.clone()), false)
|
||||
.expect("attach plaintext checksum");
|
||||
|
||||
let mut transformed = plan.apply(reader, actual_size).expect("apply write plan");
|
||||
assert_eq!(transformed.content_crc_type(), Some(ChecksumType::CRC32));
|
||||
|
||||
let mut transformed_bytes = Vec::new();
|
||||
transformed
|
||||
.read_to_end(&mut transformed_bytes)
|
||||
.await
|
||||
.expect("stream transformed data without rehashing ciphertext");
|
||||
|
||||
assert!(!transformed_bytes.is_empty());
|
||||
assert_eq!(transformed.content_crc().get("CRC32"), Some(&checksum.encoded));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_plan_preserves_non_trailing_checksum_context_across_transforms() {
|
||||
assert_non_trailing_checksum_survives(WritePlan::new().with_compression(CompressionAlgorithm::default())).await;
|
||||
assert_non_trailing_checksum_survives(
|
||||
WritePlan::new().with_encryption(WriteEncryption::singlepart([0x5Au8; 32], [0xA5u8; 12])),
|
||||
)
|
||||
.await;
|
||||
assert_non_trailing_checksum_survives(
|
||||
WritePlan::new()
|
||||
.with_compression(CompressionAlgorithm::default())
|
||||
.with_encryption(WriteEncryption::singlepart([0x5Au8; 32], [0xA5u8; 12])),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_plan_preserves_trailing_checksum_type_across_transforms() {
|
||||
let plaintext = b"trailing-checksum-context".to_vec();
|
||||
let actual_size = plaintext.len() as i64;
|
||||
let mut reader = HashReader::from_stream(Cursor::new(plaintext), actual_size, actual_size, None, None, false)
|
||||
.expect("create hash reader");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-amz-trailer", HeaderValue::from_static("x-amz-checksum-crc32"));
|
||||
reader
|
||||
.add_checksum_from_s3s(&headers, None, false)
|
||||
.expect("attach trailing checksum metadata");
|
||||
|
||||
let transformed = WritePlan::new()
|
||||
.with_encryption(WriteEncryption::singlepart([0x5Au8; 32], [0xA5u8; 12]))
|
||||
.apply(reader, actual_size)
|
||||
.expect("apply encryption plan");
|
||||
|
||||
assert_eq!(
|
||||
transformed.content_crc_type(),
|
||||
Some(ChecksumType(ChecksumType::CRC32.0 | ChecksumType::TRAILING.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "rio-v2")]
|
||||
fn s2_chunk_types(stream: &[u8]) -> Vec<u8> {
|
||||
let mut chunk_types = Vec::new();
|
||||
|
||||
@@ -1744,9 +1744,11 @@ mod test {
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_resolver_negative_result_reaches_the_dns_allowlist() {
|
||||
let err = get_host_ip(Host::Domain("rustfs-startup-negative.invalid"))
|
||||
.await
|
||||
.expect_err("the reserved .invalid domain must not resolve");
|
||||
let Err(err) = get_host_ip(Host::Domain("rustfs-startup-negative.invalid")).await else {
|
||||
// Some corporate and ISP resolvers synthesize an address for
|
||||
// unknown names, including the reserved .invalid suffix.
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
is_retryable_dns_error(&err),
|
||||
"system resolver error kind {:?} and message {err:?} must retain retry provenance",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
use std::slice::Iter;
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
use crate::bucket::utils::is_meta_bucketname;
|
||||
use crate::disk::DiskInfo;
|
||||
use crate::error::{Error, Result};
|
||||
@@ -135,13 +137,31 @@ pub(crate) async fn build_server_pools_available_space(
|
||||
continue;
|
||||
}
|
||||
|
||||
if !is_meta_bucketname(bucket) && !has_space_for(zinfo, size).await.unwrap_or_default() {
|
||||
server_pools[i] = PoolAvailableSpace {
|
||||
index: i,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
continue;
|
||||
if !is_meta_bucketname(bucket) {
|
||||
match has_space_for(zinfo, size).await {
|
||||
Ok(false) => {
|
||||
server_pools[i] = PoolAvailableSpace {
|
||||
index: i,
|
||||
..Default::default()
|
||||
};
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
// Not enough online disks to reliably determine space
|
||||
// (e.g. transient disk_info failures during decommission
|
||||
// I/O). Do NOT exclude the pool — compute available
|
||||
// space from whatever disks responded. The actual write
|
||||
// will enforce its own quorum; a premature zero here
|
||||
// causes spurious "Disk full" errors when only the
|
||||
// decommission source pool is suspended.
|
||||
warn!(
|
||||
pool_index = i,
|
||||
error = %err,
|
||||
"Pool space check incomplete; falling back to online-disk estimate"
|
||||
);
|
||||
}
|
||||
Ok(true) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut available = 0;
|
||||
@@ -270,4 +290,55 @@ mod tests {
|
||||
assert_eq!(spaces.0[0].available, 2);
|
||||
assert_eq!(spaces.0[0].max_used_pct, 90);
|
||||
}
|
||||
|
||||
/// When some disks are offline (disk_info returned None), `has_space_for`
|
||||
/// returns an error instead of a definitive "no space". The pool must NOT
|
||||
/// be excluded — available space should be estimated from the disks that
|
||||
/// did respond. This covers the decommission scenario where the active
|
||||
/// pool is the only write target but some of its disks transiently fail
|
||||
/// their disk_info check under heavy migration I/O.
|
||||
#[tokio::test]
|
||||
async fn build_server_pools_available_space_estimates_from_online_disks_when_some_offline() {
|
||||
// 4-disk set, only2 online — has_space_for returns Err (need >=2).
|
||||
let infos = vec![vec![
|
||||
Some(disk_info(1_000, 200, 800)),
|
||||
None,
|
||||
None,
|
||||
Some(disk_info(1_000, 300, 700)),
|
||||
]];
|
||||
|
||||
let spaces = build_server_pools_available_space("bucket-a", 64, &[1], &infos).await;
|
||||
|
||||
assert_eq!(spaces.0.len(), 1);
|
||||
// available = (800 + 700) * n_sets(1) = 1500
|
||||
assert_eq!(spaces.0[0].available, 1_500);
|
||||
assert_eq!(spaces.0[0].max_used_pct, 30);
|
||||
}
|
||||
|
||||
/// In a 2-pool decommission scenario, pool 0 is empty (suspended) and
|
||||
/// pool 1 has some offline disks but still reports available space.
|
||||
/// `get_available_pool_idx` must find pool 1.
|
||||
#[tokio::test]
|
||||
async fn build_server_pools_available_space_active_pool_with_partial_disk_failure_during_decommission() {
|
||||
// Pool 0: suspended → empty zinfo (handled by get_server_pools_available_space)
|
||||
// Pool 1: 4-disk set, 2 online (has_space_for returns Err)
|
||||
let infos = vec![
|
||||
Vec::new(), // pool 0: suspended, empty
|
||||
vec![
|
||||
Some(disk_info(2_000, 500, 1_500)),
|
||||
None,
|
||||
None,
|
||||
Some(disk_info(2_000, 600, 1_400)),
|
||||
],
|
||||
];
|
||||
|
||||
let spaces = build_server_pools_available_space("bucket-a", 64, &[1, 1], &infos).await;
|
||||
|
||||
assert_eq!(spaces.0.len(), 2);
|
||||
// Pool 0: empty zinfo → available=0
|
||||
assert_eq!(spaces.0[0].available, 0);
|
||||
// Pool 1: (1500 + 1400) * 1 = 2900
|
||||
assert_eq!(spaces.0[1].available, 2_900);
|
||||
assert_eq!(spaces.total_available(), 2_900);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,43 +20,193 @@ use crate::storage_api_contracts::{
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DeleteLockFence {
|
||||
#[derive(Clone)]
|
||||
pub struct NamespaceLockFence {
|
||||
signals: Arc<Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>>,
|
||||
#[cfg(test)]
|
||||
forced_lost: bool,
|
||||
forced_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl Debug for DeleteLockFence {
|
||||
impl Debug for NamespaceLockFence {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DeleteLockFence")
|
||||
f.debug_struct("NamespaceLockFence")
|
||||
.field("signal_count", &self.signals.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeleteLockFence {
|
||||
pub(crate) fn new(signals: Vec<Arc<rustfs_lock::distributed_lock::LockLostSignal>>) -> Self {
|
||||
impl NamespaceLockFence {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
signals: Arc::new(signals),
|
||||
signals: Arc::default(),
|
||||
#[cfg(test)]
|
||||
forced_lost: false,
|
||||
forced_lost: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_lock_lost(&self) -> bool {
|
||||
#[cfg(test)]
|
||||
if self.forced_lost {
|
||||
if self.forced_lost.load(std::sync::atomic::Ordering::Acquire) {
|
||||
return true;
|
||||
}
|
||||
self.signals.iter().any(|signal| signal.is_lost())
|
||||
}
|
||||
|
||||
pub(crate) fn add_signal(&mut self, signal: Arc<rustfs_lock::distributed_lock::LockLostSignal>) {
|
||||
Arc::make_mut(&mut self.signals).push(signal);
|
||||
}
|
||||
|
||||
fn extend(&mut self, other: &Self) {
|
||||
if Arc::ptr_eq(&self.signals, &other.signals) {
|
||||
return;
|
||||
}
|
||||
Arc::make_mut(&mut self.signals).extend(other.signals.iter().cloned());
|
||||
#[cfg(test)]
|
||||
if other.forced_lost.load(std::sync::atomic::Ordering::Acquire) {
|
||||
self.forced_lost.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lost_for_test() -> Self {
|
||||
let fence = Self::new();
|
||||
fence.forced_lost.store(true, std::sync::atomic::Ordering::Release);
|
||||
fence
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn loss_handle_for_test() -> (Self, Arc<std::sync::atomic::AtomicBool>) {
|
||||
let fence = Self::new();
|
||||
(fence.clone(), Arc::clone(&fence.forced_lost))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ObjectLockConfigSnapshot {
|
||||
store_id: Option<Uuid>,
|
||||
bucket: Option<String>,
|
||||
bucket_incarnation_id: Option<Uuid>,
|
||||
config_revision: Option<OffsetDateTime>,
|
||||
state: crate::bucket::metadata_sys::ObjectLockConfigState,
|
||||
lifecycle_fence: NamespaceLockFence,
|
||||
_lifecycle_guard: Option<rustfs_lock::NamespaceLockGuard>,
|
||||
metadata_transaction_guard: Option<rustfs_lock::NamespaceLockGuard>,
|
||||
}
|
||||
|
||||
impl ObjectLockConfigSnapshot {
|
||||
pub(crate) fn new(state: crate::bucket::metadata_sys::ObjectLockConfigState) -> Self {
|
||||
Self {
|
||||
signals: Arc::default(),
|
||||
forced_lost: true,
|
||||
store_id: None,
|
||||
bucket: None,
|
||||
bucket_incarnation_id: None,
|
||||
config_revision: None,
|
||||
state,
|
||||
lifecycle_fence: NamespaceLockFence::new(),
|
||||
_lifecycle_guard: None,
|
||||
metadata_transaction_guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_store_bucket(
|
||||
store_id: Uuid,
|
||||
bucket: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
config_revision: OffsetDateTime,
|
||||
state: crate::bucket::metadata_sys::ObjectLockConfigState,
|
||||
) -> Self {
|
||||
Self {
|
||||
store_id: Some(store_id),
|
||||
bucket: Some(bucket.to_string()),
|
||||
bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
config_revision: Some(config_revision),
|
||||
state,
|
||||
lifecycle_fence: NamespaceLockFence::new(),
|
||||
_lifecycle_guard: None,
|
||||
metadata_transaction_guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_guarded_store_bucket(
|
||||
store_id: Uuid,
|
||||
bucket: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
config_revision: OffsetDateTime,
|
||||
state: crate::bucket::metadata_sys::ObjectLockConfigState,
|
||||
lifecycle_guard: rustfs_lock::NamespaceLockGuard,
|
||||
metadata_transaction_guard: rustfs_lock::NamespaceLockGuard,
|
||||
) -> Self {
|
||||
let mut lifecycle_fence = NamespaceLockFence::new();
|
||||
if let Some(signal) = lifecycle_guard.lock_lost_signal() {
|
||||
lifecycle_fence.add_signal(signal);
|
||||
}
|
||||
Self {
|
||||
store_id: Some(store_id),
|
||||
bucket: Some(bucket.to_string()),
|
||||
bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
config_revision: Some(config_revision),
|
||||
state,
|
||||
lifecycle_fence,
|
||||
_lifecycle_guard: Some(lifecycle_guard),
|
||||
metadata_transaction_guard: Some(metadata_transaction_guard),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_store_bucket_under_lifecycle_fence(
|
||||
store_id: Uuid,
|
||||
bucket: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
config_revision: OffsetDateTime,
|
||||
state: crate::bucket::metadata_sys::ObjectLockConfigState,
|
||||
lifecycle_fence: NamespaceLockFence,
|
||||
metadata_transaction_guard: rustfs_lock::NamespaceLockGuard,
|
||||
) -> Self {
|
||||
Self {
|
||||
store_id: Some(store_id),
|
||||
bucket: Some(bucket.to_string()),
|
||||
bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
config_revision: Some(config_revision),
|
||||
state,
|
||||
lifecycle_fence,
|
||||
_lifecycle_guard: None,
|
||||
metadata_transaction_guard: Some(metadata_transaction_guard),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_for_store_bucket(
|
||||
&self,
|
||||
store_id: Uuid,
|
||||
bucket: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
config_revision: OffsetDateTime,
|
||||
) -> bool {
|
||||
self.store_id == Some(store_id)
|
||||
&& self.bucket.as_deref() == Some(bucket)
|
||||
&& self.bucket_incarnation_id == Some(bucket_incarnation_id)
|
||||
&& self.config_revision == Some(config_revision)
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &crate::bucket::metadata_sys::ObjectLockConfigState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
pub(crate) fn is_valid_for_destructive_put(&self, store_id: Uuid, bucket: &str, bucket_incarnation_id: Uuid) -> bool {
|
||||
self.store_id == Some(store_id)
|
||||
&& self.bucket.as_deref() == Some(bucket)
|
||||
&& self.bucket_incarnation_id == Some(bucket_incarnation_id)
|
||||
&& self.config_revision.is_some()
|
||||
&& !self.lifecycle_fence.is_lock_lost()
|
||||
&& self
|
||||
.metadata_transaction_guard
|
||||
.as_ref()
|
||||
.is_some_and(|guard| !guard.is_lock_lost())
|
||||
}
|
||||
|
||||
pub(crate) fn add_lock_fences(&self, opts: &mut ObjectOptions) {
|
||||
opts.bucket_lifecycle_lock_fence
|
||||
.get_or_insert_with(NamespaceLockFence::new)
|
||||
.extend(&self.lifecycle_fence);
|
||||
if let Some(guard) = self.metadata_transaction_guard.as_ref() {
|
||||
opts.add_namespace_lock_guard(guard);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +223,8 @@ pub struct ObjectOptions {
|
||||
pub version_id: Option<String>,
|
||||
/// RustFS-only compare-and-set condition checked under the object write lock.
|
||||
pub expected_current_version_id: Option<String>,
|
||||
/// Persisted bucket incarnation observed before authorization.
|
||||
pub expected_bucket_incarnation_id: Option<Uuid>,
|
||||
pub no_lock: bool,
|
||||
/// True when an upper layer already holds the object read lock before
|
||||
/// forwarding a no_lock read to the set layer.
|
||||
@@ -96,7 +248,10 @@ pub struct ObjectOptions {
|
||||
|
||||
pub delete_replication: Option<ReplicationState>,
|
||||
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
||||
pub delete_lock_fence: Option<DeleteLockFence>,
|
||||
pub namespace_lock_fence: Option<NamespaceLockFence>,
|
||||
/// Proves an upper layer holds the bucket lifecycle sentinel. A separate
|
||||
/// fence avoids recursively acquiring the read lock behind a queued writer.
|
||||
pub bucket_lifecycle_lock_fence: Option<NamespaceLockFence>,
|
||||
pub replication_request: bool,
|
||||
pub delete_marker: bool,
|
||||
pub synthetic_version_id: bool,
|
||||
@@ -108,6 +263,9 @@ pub struct ObjectOptions {
|
||||
pub eval_metadata: Option<HashMap<String, String>>,
|
||||
pub object_lock_retention: Option<ObjectLockRetentionOptions>,
|
||||
pub object_lock_delete: Option<crate::storage_api_contracts::object::ObjectLockDeleteOptions>,
|
||||
/// Authoritative bucket Object Lock snapshot installed inside `ECStore`
|
||||
/// before a destructive commit reaches the set layer.
|
||||
pub object_lock_config_snapshot: Option<Arc<ObjectLockConfigSnapshot>>,
|
||||
|
||||
pub want_checksum: Option<Checksum>,
|
||||
pub skip_verify_bitrot: bool,
|
||||
@@ -118,6 +276,33 @@ pub struct ObjectOptions {
|
||||
}
|
||||
|
||||
impl ObjectOptions {
|
||||
pub(crate) fn overwrites_existing_version(&self) -> bool {
|
||||
self.version_id.is_some() || !self.versioned || self.version_suspended
|
||||
}
|
||||
|
||||
pub(crate) fn add_namespace_lock_lost_signal(&mut self, signal: Arc<rustfs_lock::distributed_lock::LockLostSignal>) {
|
||||
self.namespace_lock_fence
|
||||
.get_or_insert_with(NamespaceLockFence::new)
|
||||
.add_signal(signal);
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_namespace_lock_fence(&mut self) {
|
||||
self.namespace_lock_fence.get_or_insert_with(NamespaceLockFence::new);
|
||||
}
|
||||
|
||||
pub fn add_namespace_lock_guard(&mut self, guard: &rustfs_lock::NamespaceLockGuard) {
|
||||
if let Some(signal) = guard.lock_lost_signal() {
|
||||
self.add_namespace_lock_lost_signal(signal);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_bucket_lifecycle_lock_guard(&mut self, guard: &rustfs_lock::NamespaceLockGuard) {
|
||||
let fence = self.bucket_lifecycle_lock_fence.get_or_insert_with(NamespaceLockFence::new);
|
||||
if let Some(signal) = guard.lock_lost_signal() {
|
||||
fence.add_signal(signal);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_delete_replication_state(&mut self, dsc: ReplicateDecision) {
|
||||
let mut rs = ReplicationState {
|
||||
replicate_decision_str: dsc.to_string(),
|
||||
@@ -565,8 +750,17 @@ impl ObjectInfo {
|
||||
delimiter: Option<String>,
|
||||
after_version_marker: Option<VersionMarker>,
|
||||
) -> Vec<ObjectInfo> {
|
||||
Self::from_meta_cache_entries_sorted_versions_with_purge(entries, bucket, prefix, delimiter, after_version_marker, false)
|
||||
.await
|
||||
Self::from_meta_cache_entries_sorted_versions_with_purge(
|
||||
entries,
|
||||
bucket,
|
||||
prefix,
|
||||
delimiter,
|
||||
after_version_marker,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.0
|
||||
}
|
||||
|
||||
pub(crate) async fn from_meta_cache_entries_sorted_versions_for_lifecycle(
|
||||
@@ -576,8 +770,40 @@ impl ObjectInfo {
|
||||
delimiter: Option<String>,
|
||||
after_version_marker: Option<VersionMarker>,
|
||||
) -> Vec<ObjectInfo> {
|
||||
Self::from_meta_cache_entries_sorted_versions_with_purge(entries, bucket, prefix, delimiter, after_version_marker, true)
|
||||
.await
|
||||
Self::from_meta_cache_entries_sorted_versions_with_purge(
|
||||
entries,
|
||||
bucket,
|
||||
prefix,
|
||||
delimiter,
|
||||
after_version_marker,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.0
|
||||
}
|
||||
|
||||
pub(crate) async fn from_meta_cache_entries_sorted_versions_for_recursive_delete(
|
||||
entries: &MetaCacheEntriesSorted,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
delimiter: Option<String>,
|
||||
after_version_marker: Option<VersionMarker>,
|
||||
) -> Result<Vec<ObjectInfo>> {
|
||||
let (objects, error) = Self::from_meta_cache_entries_sorted_versions_with_purge(
|
||||
entries,
|
||||
bucket,
|
||||
prefix,
|
||||
delimiter,
|
||||
after_version_marker,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
match error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(objects),
|
||||
}
|
||||
}
|
||||
|
||||
async fn from_meta_cache_entries_sorted_versions_with_purge(
|
||||
@@ -587,7 +813,8 @@ impl ObjectInfo {
|
||||
delimiter: Option<String>,
|
||||
after_version_marker: Option<VersionMarker>,
|
||||
include_version_purge: bool,
|
||||
) -> Vec<ObjectInfo> {
|
||||
fail_on_decode_error: bool,
|
||||
) -> (Vec<ObjectInfo>, Option<Error>) {
|
||||
let vcfg = get_versioning_config(bucket).await.ok();
|
||||
let mut objects = Vec::with_capacity(entries.entries().len());
|
||||
let mut prev_prefix = "";
|
||||
@@ -623,6 +850,9 @@ impl ObjectInfo {
|
||||
let file_infos = match entry.file_info_versions(bucket) {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
if fail_on_decode_error {
|
||||
return (objects, Some(err.into()));
|
||||
}
|
||||
warn!("file_info_versions err {:?}", err);
|
||||
continue;
|
||||
}
|
||||
@@ -674,7 +904,7 @@ impl ObjectInfo {
|
||||
}
|
||||
}
|
||||
|
||||
objects
|
||||
(objects, None)
|
||||
}
|
||||
|
||||
pub async fn from_meta_cache_entries_sorted_infos(
|
||||
@@ -762,6 +992,10 @@ impl ObjectInfo {
|
||||
}
|
||||
|
||||
pub fn replication_state(&self) -> ReplicationState {
|
||||
// Derived from the durable internal keys, not from the wire form: the
|
||||
// state's positional encoding skips this map.
|
||||
let (target_delete_marker_version_ids, target_delete_marker_version_ids_corrupt) =
|
||||
rustfs_utils::http::target_delete_marker_versions(&self.user_defined);
|
||||
ReplicationState {
|
||||
replication_status_internal: self.replication_status_internal.clone(),
|
||||
version_purge_status_internal: self.version_purge_status_internal.clone(),
|
||||
@@ -779,6 +1013,8 @@ impl ObjectInfo {
|
||||
.map(|arn| (arn, v.clone()))
|
||||
})
|
||||
.collect(),
|
||||
target_delete_marker_version_ids,
|
||||
target_delete_marker_version_ids_corrupt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -830,6 +1066,25 @@ fn versions_after_marker(file_infos: &rustfs_filemeta::FileInfoVersions, marker:
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn object_lock_config_snapshot_is_bound_to_store_bucket_and_incarnation() {
|
||||
let store_id = Uuid::new_v4();
|
||||
let incarnation_id = Uuid::new_v4();
|
||||
let snapshot = ObjectLockConfigSnapshot::for_store_bucket(
|
||||
store_id,
|
||||
"source-bucket",
|
||||
incarnation_id,
|
||||
OffsetDateTime::UNIX_EPOCH,
|
||||
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent,
|
||||
);
|
||||
|
||||
assert!(snapshot.is_for_store_bucket(store_id, "source-bucket", incarnation_id, OffsetDateTime::UNIX_EPOCH));
|
||||
assert!(!snapshot.is_for_store_bucket(Uuid::new_v4(), "source-bucket", incarnation_id, OffsetDateTime::UNIX_EPOCH));
|
||||
assert!(!snapshot.is_for_store_bucket(store_id, "other-bucket", incarnation_id, OffsetDateTime::UNIX_EPOCH));
|
||||
assert!(!snapshot.is_for_store_bucket(store_id, "source-bucket", Uuid::new_v4(), OffsetDateTime::UNIX_EPOCH));
|
||||
assert!(!snapshot.is_for_store_bucket(store_id, "source-bucket", incarnation_id, OffsetDateTime::now_utc()));
|
||||
}
|
||||
use rustfs_filemeta::{FileInfo, FileMeta, MetaCacheEntry, TRANSITION_COMPLETE};
|
||||
|
||||
fn inline_fast_path_object(size: i64, versioned: bool) -> ObjectInfo {
|
||||
@@ -945,10 +1200,11 @@ mod tests {
|
||||
#[test]
|
||||
fn object_info_replication_helpers_parse_target_status_and_reset_headers() {
|
||||
let reset_key = rustfs_utils::http::internal_key_rustfs("replication-reset-arn:target-a");
|
||||
let user_defined = HashMap::from([(reset_key, "reset-id".to_string())]);
|
||||
let object = ObjectInfo {
|
||||
replication_status_internal: Some("arn:target-a=COMPLETED;arn:target-b=FAILED;".to_string()),
|
||||
version_purge_status_internal: Some("arn:target-a=PENDING;".to_string()),
|
||||
user_defined: Arc::new(HashMap::from([(reset_key, "reset-id".to_string())])),
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
@@ -246,6 +246,22 @@ pub fn deployment_id() -> Option<String> {
|
||||
get_global_deployment_id()
|
||||
}
|
||||
|
||||
/// Test-only inverse of [`deployment_upload_id`]: returns the raw
|
||||
/// `<uuid>x<timestamp>` suffix without the deployment-id prefix. Under plain
|
||||
/// `cargo test` (thread-parallel, shared process globals) a concurrently
|
||||
/// running test that re-initializes a store can swap the global deployment id
|
||||
/// between create time and list time, so assertions must compare only this
|
||||
/// suffix, never the full encoded upload id.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn upload_uuid_suffix(upload_id: &str) -> String {
|
||||
base64_simd::URL_SAFE_NO_PAD
|
||||
.decode_to_vec(upload_id.as_bytes())
|
||||
.ok()
|
||||
.and_then(|decoded| String::from_utf8(decoded).ok())
|
||||
.and_then(|decoded| decoded.split_once('.').map(|(_, suffix)| suffix.to_owned()))
|
||||
.unwrap_or_else(|| upload_id.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn replication_pool() -> Option<Arc<DynReplicationPool>> {
|
||||
crate::runtime::global::current_ctx().replication_pool()
|
||||
}
|
||||
@@ -549,8 +565,13 @@ pub(crate) async fn initialize_local_disk_maps(
|
||||
endpoint_pools: EndpointServerPools,
|
||||
opt: &DiskOption,
|
||||
) -> Result<()> {
|
||||
// Every caller passes the FULL topology, so (re)initialization must replace
|
||||
// any previous registration wholesale: appending would leave the pool/set
|
||||
// vectors sized for a stale topology and panic on wider disk indices (seen
|
||||
// as cross-test contamination under single-process `cargo test`).
|
||||
let set_drives = instance_ctx.local_disk_set_drives();
|
||||
let mut global_set_drives = set_drives.write().await;
|
||||
global_set_drives.clear();
|
||||
for pool_eps in endpoint_pools.as_ref().iter() {
|
||||
let mut set_count_drives = Vec::with_capacity(pool_eps.set_count);
|
||||
for _ in 0..pool_eps.set_count {
|
||||
@@ -562,6 +583,7 @@ pub(crate) async fn initialize_local_disk_maps(
|
||||
|
||||
let map = instance_ctx.local_disk_map();
|
||||
let mut global_local_disk_map = map.write().await;
|
||||
global_local_disk_map.clear();
|
||||
|
||||
for pool_eps in endpoint_pools.as_ref().iter() {
|
||||
for ep in pool_eps.endpoints.as_ref().iter() {
|
||||
@@ -727,4 +749,69 @@ mod tests {
|
||||
process_ctx.local_disk_id_map().write().await.remove(&process_sentinel);
|
||||
bootstrap_ctx.local_disk_id_map().write().await.remove(&bootstrap_sentinel);
|
||||
}
|
||||
|
||||
/// Re-initializing the same context with a WIDER topology must replace the
|
||||
/// previous registration, not append to it: the stale pool-0 drive vector
|
||||
/// (sized for the narrow topology) made `global_set_drives[0][0][disk_idx]`
|
||||
/// panic for the wider set's higher disk indices. CI's nextest
|
||||
/// process-per-test isolation never exercises re-init, so this pins it.
|
||||
#[tokio::test]
|
||||
async fn reinitializing_local_disk_maps_replaces_previous_topology() {
|
||||
use crate::disk::DiskOption;
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("reinit test directory should be created");
|
||||
let build_pools = |label: &str, disk_count: usize| {
|
||||
let mut endpoints = Vec::new();
|
||||
for disk_idx in 0..disk_count {
|
||||
let disk_path = temp_dir.path().join(format!("{label}-disk{disk_idx}"));
|
||||
std::fs::create_dir_all(&disk_path).expect("reinit test disk should be created");
|
||||
let mut endpoint =
|
||||
Endpoint::try_from(disk_path.to_str().expect("disk path should be utf8")).expect("endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(disk_idx);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: disk_count,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: format!("reinit-test-{label}"),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
}])
|
||||
};
|
||||
let opt = DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
};
|
||||
|
||||
let instance_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
super::initialize_local_disk_maps(&instance_ctx, build_pools("narrow", 2), &opt)
|
||||
.await
|
||||
.expect("narrow topology should initialize");
|
||||
super::initialize_local_disk_maps(&instance_ctx, build_pools("wide", 4), &opt)
|
||||
.await
|
||||
.expect("re-initializing with a wider topology must not panic or fail");
|
||||
|
||||
let set_drives = instance_ctx.local_disk_set_drives();
|
||||
let set_drives = set_drives.read().await;
|
||||
assert_eq!(set_drives.len(), 1, "stale pools must not accumulate across re-inits");
|
||||
assert_eq!(set_drives[0][0].len(), 4, "pool 0 set 0 must be sized for the new topology");
|
||||
assert!(
|
||||
set_drives[0][0].iter().all(Option::is_some),
|
||||
"every wide-topology drive slot must be registered"
|
||||
);
|
||||
drop(set_drives);
|
||||
|
||||
let disk_map = instance_ctx.local_disk_map();
|
||||
let disk_map = disk_map.read().await;
|
||||
assert_eq!(disk_map.len(), 4, "stale narrow-topology disk entries must be dropped");
|
||||
assert!(
|
||||
disk_map.keys().all(|path| path.contains("wide-disk")),
|
||||
"only the new topology's disks may remain registered: {:?}",
|
||||
disk_map.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
use crate::diagnostics::admin_server_info::get_local_server_property;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::storage_api_contracts::admin::StorageAdminApi;
|
||||
#[cfg(test)]
|
||||
use chrono::Utc;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_common::{heal_channel::DriveState, metrics::global_metrics};
|
||||
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
||||
use rustfs_madmin::metrics::{
|
||||
@@ -67,6 +69,18 @@ impl MetricType {
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_millis_to_jiff_timestamp(millis: u64, fallback: Timestamp) -> Timestamp {
|
||||
let millis = match i64::try_from(millis) {
|
||||
Ok(millis) => millis,
|
||||
Err(_) => return fallback,
|
||||
};
|
||||
|
||||
match Timestamp::from_millisecond(millis) {
|
||||
Ok(timestamp) => timestamp,
|
||||
Err(_) => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_madmin_scanner_metrics(metrics: rustfs_common::metrics::ScannerMetricsReport) -> MadminScannerMetrics {
|
||||
MadminScannerMetrics {
|
||||
collected_at: metrics.collected_at,
|
||||
@@ -386,7 +400,7 @@ pub async fn collect_local_metrics(types: MetricType, opts: &CollectMetricsOpts)
|
||||
if types.contains(&MetricType::DISK) {
|
||||
debug!("start get disk metrics");
|
||||
let mut aggr = DiskMetric {
|
||||
collected_at: Utc::now(),
|
||||
collected_at: Timestamp::now(),
|
||||
..Default::default()
|
||||
};
|
||||
for (name, disk) in collect_local_disks_metrics(&opts.disks).await.into_iter() {
|
||||
@@ -412,7 +426,7 @@ pub async fn collect_local_metrics(types: MetricType, opts: &CollectMetricsOpts)
|
||||
if types.contains(&MetricType::NET) {
|
||||
let snapshot = global_internode_metrics().snapshot();
|
||||
real_time_metrics.aggregated.net = Some(NetMetrics {
|
||||
collected_at: Utc::now(),
|
||||
collected_at: Timestamp::now(),
|
||||
interface_name: "internode".to_string(),
|
||||
net_stats: NetDevLine {
|
||||
name: "internode".to_string(),
|
||||
@@ -428,10 +442,9 @@ pub async fn collect_local_metrics(types: MetricType, opts: &CollectMetricsOpts)
|
||||
// if types.contains(&MetricType::CPU) {}
|
||||
|
||||
if types.contains(&MetricType::RPC) {
|
||||
let collected_at = Utc::now();
|
||||
let collected_at = Timestamp::now();
|
||||
let snapshot = global_internode_metrics().snapshot();
|
||||
let last_connect_time =
|
||||
chrono::DateTime::<Utc>::from_timestamp_millis(snapshot.last_dial_unix_millis as i64).unwrap_or(collected_at);
|
||||
let last_connect_time = unix_millis_to_jiff_timestamp(snapshot.last_dial_unix_millis, collected_at);
|
||||
|
||||
real_time_metrics.aggregated.rpc = Some(RPCMetrics {
|
||||
collected_at,
|
||||
@@ -543,6 +556,10 @@ mod test {
|
||||
use serial_test::serial;
|
||||
use std::time::Duration;
|
||||
|
||||
fn chrono_to_jiff_timestamp(timestamp: chrono::DateTime<Utc>) -> jiff::Timestamp {
|
||||
jiff::Timestamp::try_from(std::time::SystemTime::from(timestamp)).expect("test timestamp should fit in jiff")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tes_types() {
|
||||
let t = MetricType::ALL;
|
||||
@@ -591,7 +608,7 @@ mod test {
|
||||
let current_started = Utc::now() - chrono::Duration::seconds(5);
|
||||
let scanner = to_madmin_scanner_metrics(rustfs_common::metrics::ScannerMetricsReport {
|
||||
current_cycle_active: true,
|
||||
current_started,
|
||||
current_started: chrono_to_jiff_timestamp(current_started),
|
||||
last_cycle_partial_source: "usage".to_string(),
|
||||
last_cycle_partial_source_code: 1,
|
||||
partial_cycles_by_source: vec![rustfs_common::metrics::ScannerSourceCycleSnapshot {
|
||||
@@ -602,7 +619,7 @@ mod test {
|
||||
});
|
||||
|
||||
assert_eq!(scanner.current_cycle_active, Some(true));
|
||||
assert_eq!(scanner.current_started, current_started);
|
||||
assert_eq!(scanner.current_started, chrono_to_jiff_timestamp(current_started));
|
||||
assert_eq!(scanner.last_cycle_partial_source, "usage");
|
||||
assert_eq!(scanner.last_cycle_partial_source_code, 1);
|
||||
let usage = scanner
|
||||
@@ -643,7 +660,7 @@ mod test {
|
||||
aggregated.merge(decoded);
|
||||
let scanner = aggregated.aggregated.scanner.expect("scanner metrics");
|
||||
assert_eq!(scanner.current_cycle_active, Some(true));
|
||||
assert_eq!(scanner.current_started, cycle_started);
|
||||
assert_eq!(scanner.current_started, chrono_to_jiff_timestamp(cycle_started));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user