mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-29 09:38:59 +00:00
Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc254f6318 | |||
| 95c1022c80 | |||
| 49f07aff39 | |||
| bbb479aec0 | |||
| b912ef977f | |||
| fa4e76172b | |||
| 0cec476966 | |||
| 9555899a96 | |||
| a0efd8a447 | |||
| 37d157b3ad | |||
| 237564b82d | |||
| f7affffa58 | |||
| 4a31d64f0d | |||
| 05e3db6674 | |||
| 08d2907f72 | |||
| 94a5e27114 | |||
| 775279b6fd | |||
| c442b0f449 | |||
| 60abc2c074 | |||
| bfccbe1088 | |||
| 58305bf479 | |||
| eff1d0c219 | |||
| c22d9d316b | |||
| bf985f58bc | |||
| 9b2953ee5d | |||
| 3d63a755a9 | |||
| b3b3eb57af | |||
| 4839096440 | |||
| eb755e2b97 | |||
| 4d8088ddbd | |||
| 7f146fc5de | |||
| 5426237a49 | |||
| d7afa4e38e | |||
| a3f5a8eaf9 | |||
| 547c678eed | |||
| df945b275a | |||
| 2e78a49c95 | |||
| 7ad0e726db | |||
| 45c3386b68 | |||
| 7af92b4f54 | |||
| daa627ee0e | |||
| 5c3d3a8220 | |||
| 6bc5fc77b5 | |||
| e822fc1552 | |||
| 2f6115e058 | |||
| 882e1a71b1 | |||
| b432f31c2c | |||
| 6e0640444e | |||
| 4fb9b0dc7f | |||
| 2216f00cfd | |||
| fd2a87d47e | |||
| 007cb7ea38 | |||
| c15a148c50 | |||
| 8e591e64d9 | |||
| 821e056f70 | |||
| a6695f6d30 | |||
| 5db7330d2a | |||
| 837286f959 | |||
| f65ac198dc | |||
| 1e10d752b9 | |||
| 362f6026ac | |||
| 5cedab09ab | |||
| d385cea7c6 | |||
| d5df66ac4f | |||
| 5a768d3a44 | |||
| 6d3ce90c0f | |||
| 0e42a3d1d9 | |||
| c3aac2279f | |||
| 300beff970 | |||
| 0a2370c024 | |||
| 0c9d721910 | |||
| f6c227628f | |||
| 0cbfa1ac90 | |||
| ab5aa54035 | |||
| 464bf45e15 | |||
| bc2d8e0c60 | |||
| 24cf2cdb78 | |||
| e076e8cc6e | |||
| 9d84056d7b | |||
| f566b382a0 | |||
| a909f2f27b | |||
| 5af997ce5f | |||
| 4bd8cc1369 | |||
| 3500f2e5ee | |||
| e0e2eb30a9 | |||
| 467fb0a15c | |||
| 0902538ceb | |||
| fec09968b9 | |||
| 4b09239ceb | |||
| cc3c39da5c | |||
| 07f6d5cda6 | |||
| e79337bb5c | |||
| 683ff52a7b | |||
| 63e57378d6 | |||
| b2a376c2d2 | |||
| 887868e7cd | |||
| 0ea7f17fd7 | |||
| 5532510e42 | |||
| fc6dfa891a | |||
| 994678cfcf | |||
| cee5009c57 | |||
| bb130e2655 | |||
| 0711a6f4fe | |||
| 0a25d25e68 | |||
| 009dd93788 | |||
| 7cacd1f558 | |||
| c3242f83ba | |||
| e0bd18bd50 | |||
| 7f0c42e2bb | |||
| 09a991e735 | |||
| 14c249c266 | |||
| 5acc52b7f9 | |||
| 26663e0d5d | |||
| caeaa4bf34 | |||
| 05f52beea3 | |||
| 25cf7922f5 | |||
| e6706bb94a | |||
| a609b92b3c | |||
| 4e353fb3c8 | |||
| 058f81c61f | |||
| b4e3c7117e | |||
| 23f4683f20 | |||
| a539b33583 | |||
| 4e63ee962f | |||
| 12b7f22fca | |||
| a047bbfcfb | |||
| 556f8ed62f | |||
| 02ad75e552 | |||
| 21c85481b8 | |||
| d5409845e2 | |||
| 7667b7aaf8 | |||
| 29b4a98e74 | |||
| fa7499ce1c | |||
| 1397a4e7ca | |||
| afaea2a3ec | |||
| 78dd2d40d3 | |||
| 342f9f94bc | |||
| d887e7e31d | |||
| 0eb9dd5bdc | |||
| 50c4dcca4f | |||
| bdf3f0484d | |||
| e897b2d7bb | |||
| 92f72c3912 | |||
| 6fa2d06731 | |||
| 18ff36c22d | |||
| 03af8e472b | |||
| 42fc840630 | |||
| 3fe935982f | |||
| 6d8e196f36 | |||
| 5c99ca1328 | |||
| 44d2c3bd34 | |||
| 7f19e9a465 | |||
| 92f83bfe15 | |||
| 21787a4742 | |||
| d874831cec | |||
| 6da69180d8 | |||
| 258b7d6f06 | |||
| 05886a2c3c | |||
| 99e1f5fbd2 | |||
| c984bc7251 | |||
| 233865d172 | |||
| c5eb1c69ba | |||
| 77b5e1b64c | |||
| 23a5db4012 | |||
| 516f7fecc1 | |||
| 1e95e6d311 | |||
| 3cbe3d6b94 | |||
| 61d4e04d65 | |||
| 6974963e20 | |||
| 7ab0955f8b | |||
| 2cf5fd6bfc | |||
| de2c337fae | |||
| 5988606e68 | |||
| e73ed4f2a1 | |||
| ffde6c43ee | |||
| f52dde87d1 | |||
| 8e83087ba4 | |||
| a63b79004c | |||
| 0364523dad | |||
| 2dc4d0b651 | |||
| 2ee111ad8b | |||
| 9ddb30139d | |||
| ffd1b94e1f | |||
| ce41adfa9b | |||
| 59361ed786 | |||
| dfb0a20048 | |||
| 7320d7fab2 | |||
| 28d19db9fc | |||
| e257573962 | |||
| 45b675c641 | |||
| 05caec0bd5 | |||
| eaa17e0441 | |||
| 2b6cc0ee08 | |||
| 938f7296f9 | |||
| 866ac5073d | |||
| 187a060919 | |||
| cda443bd81 | |||
| 44b1916103 | |||
| 9d1b10144f | |||
| d7f30fe0a2 | |||
| 20c4ea864a | |||
| d1c2c42c90 | |||
| fc43b149c5 | |||
| fa235e9018 | |||
| dd46de0945 | |||
| bf6f0e5e81 | |||
| beb807ae2b | |||
| d8426dc459 | |||
| f46ea6e14f | |||
| fa26b6730d | |||
| 7876319811 | |||
| ea417b6a32 | |||
| 4963412265 | |||
| 8ac618e6c2 | |||
| 1c88aa43c1 | |||
| a5a73610b6 | |||
| 9eaf5fc8e3 | |||
| 3132637294 | |||
| 358caa23cb | |||
| 6765aca3f9 | |||
| 4133fbe0fc | |||
| 6f6d8a4d3e | |||
| cb344a3c77 | |||
| 36e97aba26 | |||
| a2fc6e15df | |||
| 0269c47bc6 | |||
| d26adc29ca | |||
| 5131ba8271 | |||
| 14f31b797a | |||
| 9f61bad94f | |||
| 8e214104f3 | |||
| 6bab9e421b | |||
| d9e0a25174 | |||
| 1c8088d0b2 | |||
| ffcdab900a | |||
| b94bf874bf | |||
| 7d96ffd7fb | |||
| 027a749646 | |||
| 6c23b8506e | |||
| 8166561702 | |||
| 9866f68d86 | |||
| 0e8f1a187c | |||
| b32bd1f8a9 | |||
| c9848a6096 | |||
| 6e88ab2a25 | |||
| 05d4480f08 | |||
| abee09dad9 | |||
| a044d11443 | |||
| e1e6a8b020 | |||
| 5cfe4ccc7d | |||
| df2db15ce8 | |||
| 0321e9350d | |||
| cd9a2eecb1 | |||
| 8b09634e62 | |||
| 1ede77b1c1 | |||
| 666e251b78 | |||
| 65ba138c27 | |||
| 92ae19b340 | |||
| 4607c3be53 | |||
| e0bac66941 | |||
| 31dc78eab0 | |||
| daca7294c7 | |||
| 1655f3192e | |||
| 0adb3c5ea1 | |||
| 68e156a5c5 | |||
| 3f60cc743e | |||
| 35af4a611f |
@@ -84,6 +84,9 @@ Null report example: "Rewrote the diff as an in-place edit (no smaller equivalen
|
||||
- For any secret/token/signature/password comparison in the diff, check it uses a constant-time compare (e.g. subtle/constant_time_eq), not == or early-return byte loops. Then check the failure-response paths: construct an invalid-user request and an invalid-secret request and confirm they are indistinguishable (same error, no early length short-circuit) so an attacker cannot enumerate valid users or time-side-channel the secret.
|
||||
- Where: crates/protocols/ (FTPS/WebDAV/FormPost auth), crates/credentials/, rustfs/src/auth.rs, RPC signature verification
|
||||
- Evidence: GHSA-3p3x-734c-h5vx (FTPS/WebDAV early-return string equality + distinguishable invalid-user vs invalid-password). Fix commits 3c3113619 (constant-time FTPS/WebDAV) and c41062f27 (constant-time FormPost signature). 3p3x was fixed by PR #4403.
|
||||
- If the diff parses or transports secret-bearing config (env vars, key files, connection strings), grep every error-construction and format site on that value's path (`format!` feeding `Error::other`/`configuration_error`/`panic!`/`expect`) for interpolation of the raw value or of variables named like secret material. Construct the likeliest misconfiguration: the operator supplies the bare secret without the expected `<name>:` prefix (or with a stray newline) — if the parse-failure hint echoes the input, the secret lands in startup logs. Error strings are log content; the hint may name the env var and expected format, never the value. If the diff re-implements an existing parse helper, diff the two error paths — the duplicate is where the leak hides.
|
||||
- Where: rustfs/src/init.rs (env plumbing), crates/kms/src/config.rs, crates/credentials/, any from_env/parse on secret values; mechanical backstop in scripts/check_logging_guardrails.sh (secret-interpolation check)
|
||||
- Evidence: PR #5222 introduced `got: {secret_str}` in build_static_kms_config's format-hint error — a bare base64 key (the secret itself) would have been echoed into startup logs; fixed by PR #5243. The parallel parse in KmsConfig::from_env already omitted the value: the leak lived only in the duplicated copy (AGENTS.md 'Reuse Before You Write').
|
||||
- If the diff touches internode/RPC auth secret handling, trace whether the RPC HMAC secret can fall back to a public default (e.g. 'rustfsadmin', 'rustfs rpc') or be derived deterministically from the S3 root credentials. Construct the case where RUSTFS_RPC_SECRET is unset and confirm the code fails closed rather than silently using a default or a root-derived key. Verify RPC signing keys are independent random secrets, not reused across S3-root/RPC-HMAC/STS-JWT roles.
|
||||
- Where: crates/credentials/, crates/ecstore/src/rpc/, internode auth setup
|
||||
- Evidence: GHSA-r5qv-rc46-hv8q (fell back to 'rustfsadmin'), GHSA-75fx/68cw (RPC secret derivable from root creds → forgeable signatures), GHSA-h956 (hard-coded 'rustfs rpc'), GHSA-m77q (STS JWT reused root secret). Fix commit 7b2055405 (fail closed when deriving RPC secret from default credentials, PR#4402).
|
||||
|
||||
@@ -99,6 +99,8 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
|
||||
### Logging and debug output
|
||||
- Logs must never include access keys beyond safe identifiers, secret keys, session tokens, JWT claims, HMAC secrets, expected signatures, license secrets, or raw response bodies containing credentials.
|
||||
- Treat `Debug` implementations, `?value` tracing, merged config dumps, and dependency-level HTTP body logging as leak surfaces.
|
||||
- Error and panic messages are log content: they propagate through `?` and get printed by `error!`/startup logging far from where they were constructed. Never interpolate a raw config or credential value into an error string.
|
||||
- A value that fails secret-format parsing is usually the secret itself (e.g. a bare base64 key missing its `<name>:` prefix), so a parse-failure hint must name the env var or file and the expected format, never echo the input. Redacting `Debug` impls does not cover this channel.
|
||||
- Add log-capture tests or targeted unit tests for redaction wrappers when changing credential structs or response bodies.
|
||||
|
||||
### RPC, parsing, and panic safety
|
||||
@@ -139,6 +141,7 @@ Use these prompts while reviewing a diff:
|
||||
- Does a public/default/empty config change security behavior from fail-closed to fail-open?
|
||||
- Is any attacker-controlled value later used as a path, policy condition, credential identity, log field, URL, Origin, or response body?
|
||||
- Does this response contain stored replication, remote target, or service credentials that need redaction or stricter authorization?
|
||||
- Does any error constructor or `format!` interpolate a variable that can hold secret material, including a config parse error that echoes the raw input?
|
||||
- Does an IAM export/import path expose or trust plaintext credential secrets beyond the caller's intended authority?
|
||||
- Can this STS/OIDC path issue credentials without SigV4, trusted issuer validation, allowlisted redirects, or trusted-proxy host/scheme handling?
|
||||
- Can a service-account or STS token omit `exp`, forge `sessionPolicy`, or use a principal-controlled key as signing authority?
|
||||
|
||||
@@ -26,6 +26,11 @@ script-tests: ## Run shell script tests
|
||||
@echo "Running script tests..."
|
||||
./scripts/test_build_rustfs_options.sh
|
||||
./scripts/test_entrypoint_credentials.sh
|
||||
./scripts/test_internode_grpc_ab_bench.sh
|
||||
./scripts/test_object_batch_bench_enhanced.sh
|
||||
./scripts/test_exact_1mib_handoff_abba.sh
|
||||
./scripts/test_pinned_paired_abba_bench.sh
|
||||
./scripts/test_manual_transition_runbooks.sh
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||
|
||||
+24
-3
@@ -39,6 +39,7 @@ ecstore-serial-flaky = { max-threads = 1 }
|
||||
# servers never run at once. ci-7's nightly picks these up via the e2e suite;
|
||||
# they are deliberately NOT in the fast PR `e2e-smoke` filter.
|
||||
e2e-reliability = { max-threads = 1 }
|
||||
e2e-inline-boundaries = { max-threads = 1 }
|
||||
|
||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||
[[profile.default.overrides]]
|
||||
@@ -54,6 +55,12 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# 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 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.
|
||||
@@ -61,6 +68,10 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
||||
test-group = 'e2e-reliability'
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -125,6 +136,12 @@ test-group = 'e2e-reliability'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# 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'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -156,7 +173,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 + 27 nightly = 47 total
|
||||
# regexes byte-identical. Count invariant: 20 here + 28 nightly = 48 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
|
||||
@@ -192,7 +209,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_source_invalid_date|content_encoding|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools)_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|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::/)
|
||||
@@ -211,7 +228,7 @@ fail-fast = false
|
||||
# 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.
|
||||
# * 11 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
||||
# * 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.
|
||||
# * 1 `_real_single_node` service-account round-trip test.
|
||||
@@ -314,3 +331,7 @@ path = "junit.xml"
|
||||
[[profile.e2e-full.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
||||
test-group = 'e2e-reliability'
|
||||
|
||||
[[profile.e2e-full.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
name: Test and Lint
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
|
||||
Vendored
+35
-8
@@ -172,7 +172,7 @@
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/rustfs with sse",
|
||||
"name": "Debug executable target/debug/rustfs with sse kms",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/rustfs",
|
||||
@@ -200,7 +200,7 @@
|
||||
// 2. kms local backend test key
|
||||
// "RUSTFS_KMS_ENABLE": "true",
|
||||
// "RUSTFS_KMS_BACKEND": "local",
|
||||
// "RUSTFS_KMS_KEY_DIR": "./target/kms-key-dir",
|
||||
// "RUSTFS_KMS_KEY_DIR": "/tmp/kms-key-dir",
|
||||
// "RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
@@ -212,13 +212,40 @@
|
||||
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
// 4. kms vault transit backend test key
|
||||
// "RUSTFS_KMS_ENABLE": "true",
|
||||
// "RUSTFS_KMS_BACKEND": "vault-transit",
|
||||
// "RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200",
|
||||
// "RUSTFS_KMS_VAULT_TOKEN": "Dev Token",
|
||||
// "RUSTFS_KMS_VAULT_MOUNT_PATH": "transit",
|
||||
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
// 5、kms static backend test key
|
||||
"RUSTFS_KMS_ENABLE": "true",
|
||||
"RUSTFS_KMS_BACKEND": "vault-transit",
|
||||
"RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200",
|
||||
"RUSTFS_KMS_VAULT_TOKEN": "Dev Token",
|
||||
"RUSTFS_KMS_VAULT_MOUNT_PATH": "transit",
|
||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||
|
||||
"RUSTFS_KMS_BACKEND": "static",
|
||||
"RUSTFS_KMS_STATIC_SECRET_KEY": "rustfs-master-key:2dfNXGHlsEflGVCxb+5DIdGEl1sIvtwX+QfmYasi5QM="
|
||||
},
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Debug executable target/debug/rustfs with local sse",
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/target/debug/rustfs",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||
"RUSTFS_VOLUMES": "./target/volumes/test{1...4}",
|
||||
"RUSTFS_ADDRESS": ":9000",
|
||||
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||
"RUSTFS_CONSOLE_ADDRESS": "127.0.0.1:9001",
|
||||
"RUSTFS_OBS_LOG_DIRECTORY": "./target/logs",
|
||||
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||
"RUSTFS_SSE_S3_MASTER_KEY": "xGb3aYSp825j2tPpg8JrUzghiXsIkfdOtmrsJ/iafiM=",
|
||||
"RUST_LOG": "rustfs=debug,ecstore=debug,s3s=debug,iam=debug",
|
||||
},
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
|
||||
@@ -21,6 +21,12 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
|
||||
- Avoid redundant file reads, repeated commands, and unnecessary exploratory work once enough context is available.
|
||||
- A good result is a minimal diff with clear assumptions, no over-engineering, and independent verification that survives Adversarial Validation (below).
|
||||
|
||||
## Autonomy and Approval Boundaries
|
||||
|
||||
- Inquiry tasks (answer, explain, review, diagnose, plan): report findings; do not change files unless a fix is explicitly requested.
|
||||
- Action tasks (change, build, fix): make in-scope local changes without asking for approval.
|
||||
- Ask for confirmation before destructive or hard-to-reverse operations (force-pushes, history rewrites, deleting data or branches), merging a PR (reviewer approval required), or any material expansion of the requested scope.
|
||||
|
||||
## Communication and Language
|
||||
|
||||
- Respond in the same language used by the requester.
|
||||
|
||||
Generated
+230
-177
File diff suppressed because it is too large
Load Diff
+72
-69
@@ -68,8 +68,8 @@ resolver = "3"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.96.0"
|
||||
version = "1.0.0-beta.10"
|
||||
rust-version = "1.97.1"
|
||||
version = "1.0.0-beta.11"
|
||||
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.10" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.10" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.10" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.10" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-beta.10" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.10" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.10" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.10" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.10" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.10" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.10" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.10" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.10" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.10" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.10" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.10" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.10" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.10" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.10" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.10" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.10" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.10" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.10" }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.10" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.10" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.10" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.10" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.10" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.10" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.10" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.10" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.10" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.10" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.10" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.10" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.10" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.10" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.10" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.10" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.10" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.10" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.10" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.10" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.10" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.10" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.10" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-beta.11" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.11" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.11" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.11" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-beta.11" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.11" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.11" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.11" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.11" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.11" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.11" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.11" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.11" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.11" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.11" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.11" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.11" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.11" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.11" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.11" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.11" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.11" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.11" }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.11" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.11" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.11" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.11" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.11" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.11" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.11" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.11" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.11" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.11" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.11" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.11" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.11" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.11" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.11" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.11" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.11" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.11" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.11" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.11" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.11" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.11" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.11" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -140,7 +140,7 @@ mysql_async = { default-features = false, version = "0.37" }
|
||||
async-compression = { version = "0.4.42" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.91"
|
||||
async-nats = "0.50.0"
|
||||
async-nats = { version = "0.50.0", default-features = false }
|
||||
axum = "0.8.9"
|
||||
futures = "0.3.33"
|
||||
futures-core = "0.3.33"
|
||||
@@ -161,9 +161,9 @@ rustfs-kafka-async = { version = "1.2.0" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.53.1" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.4" }
|
||||
tokio-stream = { version = "0.1.18" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.5"
|
||||
tokio-util = { version = "0.7.18" }
|
||||
tokio-util = { version = "0.7.19" }
|
||||
tonic = { version = "0.14.6" }
|
||||
tonic-prost = { version = "0.14.6" }
|
||||
tonic-prost-build = { version = "0.14.6" }
|
||||
@@ -196,13 +196,13 @@ blake2 = "=0.11.0-rc.6"
|
||||
chacha20poly1305 = { version = "=0.11.0" }
|
||||
crc-fast = "1.10.0"
|
||||
hmac = { version = "0.13.0" }
|
||||
jsonwebtoken = { version = "10.4.0" }
|
||||
jsonwebtoken = { version = "11.0.0" }
|
||||
openidconnect = { default-features = false, version = "4.0" }
|
||||
pbkdf2 = "0.13.0"
|
||||
rsa = { version = "=0.10.0-rc.18" }
|
||||
rustls = { default-features = false, version = "0.23.42" }
|
||||
rustls-native-certs = "0.8"
|
||||
rustls-pki-types = "1.15.0"
|
||||
rustls-pki-types = "1.15.1"
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
subtle = "2.6"
|
||||
@@ -211,7 +211,7 @@ zeroize = { version = "1.9.0" }
|
||||
# Time and Date
|
||||
chrono = { version = "0.4.45" }
|
||||
humantime = "2.4.0"
|
||||
jiff = { version = "0.2.34" }
|
||||
jiff = { version = "0.2.35" }
|
||||
time = { version = "0.3.54" }
|
||||
|
||||
# Database
|
||||
@@ -225,16 +225,17 @@ arc-swap = "1.9.2"
|
||||
astral-tokio-tar = "0.6.4"
|
||||
atoi = "3.1.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.9.0" }
|
||||
aws-config = { version = "1.10.1" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.138.1" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.140.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.13.0" }
|
||||
aws-smithy-runtime-api = { version = "1.14.0" }
|
||||
aws-smithy-types = { version = "1.6.1" }
|
||||
base64 = "0.22.1"
|
||||
base64 = "0.23.0"
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
clap = { version = "4.6.3" }
|
||||
clap = { version = "4.6.4" }
|
||||
const-str = { version = "1.1.0" }
|
||||
convert_case = "0.11.0"
|
||||
criterion = { version = "0.8" }
|
||||
@@ -243,11 +244,12 @@ 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, version = "54.1.0" }
|
||||
derive_builder = "0.20.2"
|
||||
enumset = "1.1.14"
|
||||
faster-hex = "0.10.0"
|
||||
flate2 = "1.1.9"
|
||||
glob = "0.3.3"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.16.0"
|
||||
google-cloud-auth = "1.14.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
@@ -256,7 +258,7 @@ hex-simd = "0.8.0"
|
||||
highway = { version = "1.3.0" }
|
||||
ipnetwork = { version = "0.21.1" }
|
||||
lazy_static = "1.5.0"
|
||||
libc = "0.2.187"
|
||||
libc = "0.2.189"
|
||||
libsystemd = "0.7.2"
|
||||
local-ip-address = "0.6.13"
|
||||
memmap2 = "0.9.11"
|
||||
@@ -278,7 +280,7 @@ pretty_assertions = "1.4.1"
|
||||
rand = { version = "0.10.2" }
|
||||
ratelimit = "0.10.1"
|
||||
rayon = "1.12.0"
|
||||
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.0" }
|
||||
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
|
||||
@@ -286,8 +288,8 @@ redis = { version = "1.4.1" }
|
||||
rustix = { version = "1.1.4" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/s3s-project/s3s.git", rev = "a5471625975f5014f7b28eee7e4d801f1b32f529" }
|
||||
serial_test = "3.5.0"
|
||||
s3s = { git = "https://github.com/cxymds/s3s.git", rev = "fe3941d91fa1c69956f209a9145995c9f0235bff" }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.3"
|
||||
smallvec = { version = "1.15.2" }
|
||||
@@ -313,7 +315,7 @@ vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
windows = { version = "0.62.2" }
|
||||
xxhash-rust = { version = "0.8.17" }
|
||||
xxhash-rust = { version = "0.8.18" }
|
||||
zip = "8.6.0"
|
||||
zstd = "0.13.3"
|
||||
|
||||
@@ -323,17 +325,18 @@ dial9-tokio-telemetry = "0.3"
|
||||
opentelemetry = { version = "0.32.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.32.0" }
|
||||
opentelemetry-otlp = { version = "0.32.0" }
|
||||
opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["metrics", "gen-tonic-messages"] }
|
||||
opentelemetry_sdk = { version = "0.32.1" }
|
||||
opentelemetry-semantic-conventions = { version = "0.32.1" }
|
||||
opentelemetry-stdout = { version = "0.32.0" }
|
||||
pyroscope = { version = "2.1.0" }
|
||||
pyroscope = { version = "2.1.1" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.0" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "10.0.1" }
|
||||
rcgen = "0.14.8"
|
||||
russh = { version = "0.62.3" }
|
||||
rcgen = { version = "0.14.8", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.62.4" }
|
||||
russh-sftp = "2.3.0"
|
||||
|
||||
# WebDAV
|
||||
@@ -341,7 +344,7 @@ dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
mimalloc = "0.1.52"
|
||||
hotpath = "0.21.5"
|
||||
hotpath = "0.22.0"
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
FROM rust:1.97-trixie
|
||||
FROM rust:1.97.1-trixie
|
||||
|
||||
RUN set -eux; \
|
||||
export DEBIAN_FRONTEND=noninteractive; \
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ ARG RUSTFS_BUILD_FEATURES=""
|
||||
# -----------------------------
|
||||
# Build stage
|
||||
# -----------------------------
|
||||
FROM rust:1.97-trixie AS builder
|
||||
FROM rust:1.97.1-trixie AS builder
|
||||
|
||||
# Re-declare args after FROM
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
@@ -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.10
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.11
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
@@ -163,6 +163,7 @@ docker run -d --name rustfs -p 9000:9000 \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY=on \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY=http://<host-ip>:3020/webhook \
|
||||
-e RUSTFS_NOTIFY_WEBHOOK_QUEUE_DIR_PRIMARY=/tmp/rustfs-events \
|
||||
-e RUSTFS_OUTBOUND_ALLOW_ORIGINS=http://<host-ip>:3020 \
|
||||
rustfs/rustfs:latest
|
||||
```
|
||||
|
||||
@@ -171,6 +172,11 @@ Notes:
|
||||
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
|
||||
- If queue dir is omitted, default is `/opt/rustfs/events`; ensure it is writable by the container runtime user.
|
||||
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
|
||||
- Since `1.0.0-beta.11`, webhook endpoints on private or container networks
|
||||
(`Docker Compose service names`, `host.docker.internal`, RFC 1918 addresses) are
|
||||
blocked unless their exact `scheme://host:port` origin is listed in
|
||||
`RUSTFS_OUTBOUND_ALLOW_ORIGINS` (the origin only, without the path). See
|
||||
[Outbound Connection Policy](docs/operations/outbound-connection-policy.md).
|
||||
|
||||
**NOTE**: We recommend reviewing the `docker-compose.yml` file before running. It defines several services including Grafana, Prometheus, and Jaeger, which are helpful for RustFS observability. If you wish to start Redis or Nginx containers, you can specify the corresponding profiles.
|
||||
|
||||
@@ -262,7 +268,7 @@ rustfs --help
|
||||
2. **Create a Bucket**: Use the console to create a new bucket for your objects.
|
||||
3. **Upload Objects**: You can upload files directly through the console or use S3-compatible APIs/clients to interact with your RustFS instance.
|
||||
|
||||
**NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured.html).
|
||||
**NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured).
|
||||
|
||||
### OIDC Roles Claim (Microsoft Entra ID)
|
||||
|
||||
|
||||
+2
-2
@@ -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.10
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.11
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
@@ -214,7 +214,7 @@ rustfs --help
|
||||
2. **创建存储桶**: 使用控制台为您的对象创建一个新的存储桶 (Bucket)。
|
||||
3. **上传对象**: 您可以直接通过控制台上传文件,或使用 S3 兼容的 API/客户端与您的 RustFS 实例进行交互。
|
||||
|
||||
**注意**: 如果您希望通过 `https` 访问 RustFS 实例,请参考 [TLS 配置文档](https://docs.rustfs.com/integration/tls-configured.html)。
|
||||
**注意**: 如果您希望通过 `https` 访问 RustFS 实例,请参考 [TLS 配置文档](https://docs.rustfs.com/integration/tls-configured)。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -292,8 +292,8 @@ impl AuditPipeline {
|
||||
}
|
||||
|
||||
pub async fn snapshot_target_health(&self) -> Vec<rustfs_targets::RuntimeTargetHealthSnapshot> {
|
||||
let registry = self.registry.lock().await;
|
||||
registry.runtime_manager().health_snapshots().await
|
||||
let targets = self.registry.lock().await.list_target_values();
|
||||
rustfs_targets::health_snapshots_for_targets(targets).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ mod tests {
|
||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||
use rustfs_targets::{StoreError, Target, TargetError};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
|
||||
/// Mock target whose `save()` outcome is fixed at construction so tests can
|
||||
/// force full-success / full-failure / partial-failure fan-outs.
|
||||
@@ -578,6 +578,7 @@ mod tests {
|
||||
struct MockTarget {
|
||||
id: TargetID,
|
||||
fail: bool,
|
||||
health_gate: Option<(Arc<Notify>, Arc<Notify>)>,
|
||||
}
|
||||
|
||||
impl MockTarget {
|
||||
@@ -585,8 +586,14 @@ mod tests {
|
||||
Self {
|
||||
id: TargetID::new(id.to_string(), "webhook".to_string()),
|
||||
fail,
|
||||
health_gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_health_gate(mut self, started: Arc<Notify>, release: Arc<Notify>) -> Self {
|
||||
self.health_gate = Some((started, release));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -599,6 +606,10 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||
if let Some((started, release)) = &self.health_gate {
|
||||
started.notify_one();
|
||||
release.notified().await;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -673,6 +684,24 @@ mod tests {
|
||||
pipeline.dispatch(entry()).await.expect("no targets should return Ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_probe_does_not_hold_the_registry_lock() {
|
||||
let started = Arc::new(Notify::new());
|
||||
let release = Arc::new(Notify::new());
|
||||
let pipeline = pipeline_with(vec![MockTarget::new("blocked", false).with_health_gate(started.clone(), release.clone())]);
|
||||
let registry = Arc::clone(&pipeline.registry);
|
||||
let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await });
|
||||
started.notified().await;
|
||||
|
||||
let guard = tokio::time::timeout(std::time::Duration::from_secs(1), registry.lock())
|
||||
.await
|
||||
.expect("network health probe must not retain the audit registry lock");
|
||||
drop(guard);
|
||||
release.notify_one();
|
||||
|
||||
assert_eq!(snapshot_task.await.expect("snapshot task should finish").len(), 1);
|
||||
}
|
||||
|
||||
// backlog#962: dispatch_batch must mirror dispatch and propagate a
|
||||
// whole-batch loss instead of returning Ok.
|
||||
#[tokio::test]
|
||||
|
||||
+142
-17
@@ -768,6 +768,7 @@ pub struct Metrics {
|
||||
last_scan_cycle_replication_checks: AtomicU64,
|
||||
last_scan_cycle_usage_saves: AtomicU64,
|
||||
failed_scan_cycles: AtomicU64,
|
||||
superseded_scan_cycles: AtomicU64,
|
||||
partial_scan_cycles_unknown: AtomicU64,
|
||||
partial_scan_cycles_runtime: AtomicU64,
|
||||
partial_scan_cycles_objects: AtomicU64,
|
||||
@@ -785,6 +786,9 @@ pub struct Metrics {
|
||||
scanner_expiry_queue_missed: AtomicU64,
|
||||
scanner_expiry_queued_total: AtomicU64,
|
||||
scanner_expiry_missed_total: AtomicU64,
|
||||
scanner_expiry_blocked_total: AtomicU64,
|
||||
scanner_expiry_not_enqueued_total: AtomicU64,
|
||||
scanner_expiry_delete_failed_total: AtomicU64,
|
||||
scanner_transition_queue_capacity: AtomicU64,
|
||||
scanner_transition_queued: AtomicU64,
|
||||
scanner_transition_active: AtomicU64,
|
||||
@@ -833,10 +837,12 @@ const SCAN_CYCLE_RESULT_UNKNOWN: u8 = 0;
|
||||
const SCAN_CYCLE_RESULT_SUCCESS: u8 = 1;
|
||||
const SCAN_CYCLE_RESULT_ERROR: u8 = 2;
|
||||
const SCAN_CYCLE_RESULT_PARTIAL: u8 = 3;
|
||||
const SCAN_CYCLE_RESULT_SUPERSEDED: u8 = 4;
|
||||
const SCAN_CYCLE_RESULT_UNKNOWN_LABEL: &str = "unknown";
|
||||
const SCAN_CYCLE_RESULT_SUCCESS_LABEL: &str = "success";
|
||||
const SCAN_CYCLE_RESULT_ERROR_LABEL: &str = "error";
|
||||
const SCAN_CYCLE_RESULT_PARTIAL_LABEL: &str = "partial";
|
||||
const SCAN_CYCLE_RESULT_SUPERSEDED_LABEL: &str = "superseded";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ScanCyclePartialReason {
|
||||
@@ -1048,6 +1054,12 @@ pub struct ScannerLifecycleExpirySnapshot {
|
||||
pub queue_missed: u64,
|
||||
pub scanner_queued: u64,
|
||||
pub scanner_missed: u64,
|
||||
#[serde(default)]
|
||||
pub scanner_blocked: u64,
|
||||
#[serde(default)]
|
||||
pub scanner_not_enqueued: u64,
|
||||
#[serde(default)]
|
||||
pub delete_failed: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -1208,6 +1220,8 @@ pub struct ScannerMetricsReport {
|
||||
pub last_cycle_usage_saves: u64,
|
||||
pub failed_cycles: u64,
|
||||
#[serde(default)]
|
||||
pub superseded_cycles: u64,
|
||||
#[serde(default)]
|
||||
pub partial_cycles_unknown: u64,
|
||||
#[serde(default)]
|
||||
pub partial_cycles_runtime: u64,
|
||||
@@ -1310,6 +1324,7 @@ fn scan_cycle_result_label(result: u8) -> &'static str {
|
||||
SCAN_CYCLE_RESULT_SUCCESS => SCAN_CYCLE_RESULT_SUCCESS_LABEL,
|
||||
SCAN_CYCLE_RESULT_ERROR => SCAN_CYCLE_RESULT_ERROR_LABEL,
|
||||
SCAN_CYCLE_RESULT_PARTIAL => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
|
||||
SCAN_CYCLE_RESULT_SUPERSEDED => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL,
|
||||
_ => SCAN_CYCLE_RESULT_UNKNOWN_LABEL,
|
||||
}
|
||||
}
|
||||
@@ -1633,6 +1648,11 @@ pub fn emit_scan_cycle_partial_with_source(
|
||||
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_PARTIAL_LABEL).increment(1);
|
||||
}
|
||||
|
||||
pub fn emit_scan_cycle_superseded(duration: Duration) {
|
||||
global_metrics().record_scan_cycle_superseded(duration);
|
||||
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL).increment(1);
|
||||
}
|
||||
|
||||
pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) {
|
||||
let result = if success { "success" } else { "error" };
|
||||
metrics::counter!(
|
||||
@@ -1726,6 +1746,7 @@ impl Metrics {
|
||||
last_scan_cycle_replication_checks: AtomicU64::new(0),
|
||||
last_scan_cycle_usage_saves: AtomicU64::new(0),
|
||||
failed_scan_cycles: AtomicU64::new(0),
|
||||
superseded_scan_cycles: AtomicU64::new(0),
|
||||
partial_scan_cycles_unknown: AtomicU64::new(0),
|
||||
partial_scan_cycles_runtime: AtomicU64::new(0),
|
||||
partial_scan_cycles_objects: AtomicU64::new(0),
|
||||
@@ -1743,6 +1764,9 @@ impl Metrics {
|
||||
scanner_expiry_queue_missed: AtomicU64::new(0),
|
||||
scanner_expiry_queued_total: AtomicU64::new(0),
|
||||
scanner_expiry_missed_total: AtomicU64::new(0),
|
||||
scanner_expiry_blocked_total: AtomicU64::new(0),
|
||||
scanner_expiry_not_enqueued_total: AtomicU64::new(0),
|
||||
scanner_expiry_delete_failed_total: AtomicU64::new(0),
|
||||
scanner_transition_queue_capacity: AtomicU64::new(0),
|
||||
scanner_transition_queued: AtomicU64::new(0),
|
||||
scanner_transition_active: AtomicU64::new(0),
|
||||
@@ -1973,9 +1997,18 @@ impl Metrics {
|
||||
self.scanner_expiry_queued_total.fetch_add(count, Ordering::Relaxed);
|
||||
} else {
|
||||
self.scanner_expiry_missed_total.fetch_add(count, Ordering::Relaxed);
|
||||
self.scanner_expiry_not_enqueued_total.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_scanner_expiry_blocked(&self, count: u64) {
|
||||
self.scanner_expiry_blocked_total.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_scanner_expiry_delete_failed(&self, count: u64) {
|
||||
self.scanner_expiry_delete_failed_total.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_scanner_transition_enqueue_result(&self, count: u64, queued: bool) {
|
||||
self.record_scanner_ilm_enqueue_result(count, queued);
|
||||
if queued {
|
||||
@@ -2349,6 +2382,18 @@ impl Metrics {
|
||||
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_scan_cycle_superseded(&self, duration: Duration) {
|
||||
self.record_scanner_cycle_end_time();
|
||||
self.superseded_scan_cycles.fetch_add(1, Ordering::Relaxed);
|
||||
self.last_scan_cycle_result
|
||||
.store(SCAN_CYCLE_RESULT_SUPERSEDED, Ordering::Relaxed);
|
||||
self.last_scan_cycle_partial_reason
|
||||
.store(ScanCyclePartialReason::Unknown as u8, Ordering::Relaxed);
|
||||
self.last_scan_cycle_partial_source.store(0, Ordering::Relaxed);
|
||||
self.last_scan_cycle_duration_millis
|
||||
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_scan_cycle_partial(&self, duration: Duration, reason: ScanCyclePartialReason) {
|
||||
self.record_scan_cycle_partial_with_source(duration, reason, None);
|
||||
}
|
||||
@@ -2802,6 +2847,7 @@ impl Metrics {
|
||||
m.last_cycle_replication_repair =
|
||||
self.scanner_replication_repair_work_counter_snapshots(&self.last_scan_cycle_replication_repair_work);
|
||||
m.failed_cycles = self.failed_scan_cycles.load(Ordering::Relaxed);
|
||||
m.superseded_cycles = self.superseded_scan_cycles.load(Ordering::Relaxed);
|
||||
m.partial_cycles_unknown = self.partial_scan_cycles_unknown.load(Ordering::Relaxed);
|
||||
m.partial_cycles_runtime = self.partial_scan_cycles_runtime.load(Ordering::Relaxed);
|
||||
m.partial_cycles_objects = self.partial_scan_cycles_objects.load(Ordering::Relaxed);
|
||||
@@ -2825,6 +2871,9 @@ impl Metrics {
|
||||
queue_missed: self.scanner_expiry_queue_missed.load(Ordering::Relaxed),
|
||||
scanner_queued: self.scanner_expiry_queued_total.load(Ordering::Relaxed),
|
||||
scanner_missed: self.scanner_expiry_missed_total.load(Ordering::Relaxed),
|
||||
scanner_blocked: self.scanner_expiry_blocked_total.load(Ordering::Relaxed),
|
||||
scanner_not_enqueued: self.scanner_expiry_not_enqueued_total.load(Ordering::Relaxed),
|
||||
delete_failed: self.scanner_expiry_delete_failed_total.load(Ordering::Relaxed),
|
||||
};
|
||||
m.lifecycle_transition = ScannerLifecycleTransitionSnapshot {
|
||||
current_queue_capacity: self.scanner_transition_queue_capacity.load(Ordering::Relaxed),
|
||||
@@ -2950,19 +2999,15 @@ pub type CloseDiskFn = Arc<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>>
|
||||
|
||||
/// Register a new disk in the global path tracker and return two callbacks:
|
||||
/// one to update the current path and one to deregister the disk when done.
|
||||
pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn, CloseDiskFn) {
|
||||
pub async fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn, CloseDiskFn) {
|
||||
let tracker = Arc::new(CurrentPathTracker::new(initial.to_string()));
|
||||
let disk_name = disk.to_string();
|
||||
|
||||
let tracker_clone = Arc::clone(&tracker);
|
||||
let disk_insert = disk_name.clone();
|
||||
tokio::spawn(async move {
|
||||
global_metrics()
|
||||
.current_paths
|
||||
.write()
|
||||
.await
|
||||
.insert(disk_insert, tracker_clone);
|
||||
});
|
||||
global_metrics()
|
||||
.current_paths
|
||||
.write()
|
||||
.await
|
||||
.insert(disk_name.clone(), Arc::clone(&tracker));
|
||||
|
||||
let update_fn: UpdateCurrentPathFn = {
|
||||
let tracker = Arc::clone(&tracker);
|
||||
@@ -2990,23 +3035,28 @@ pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn,
|
||||
// CloseDiskGuard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct CloseDiskGuard(CloseDiskFn);
|
||||
pub struct CloseDiskGuard(Option<CloseDiskFn>);
|
||||
|
||||
impl CloseDiskGuard {
|
||||
pub fn new(close_disk: CloseDiskFn) -> Self {
|
||||
Self(close_disk)
|
||||
Self(Some(close_disk))
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
self.0().await;
|
||||
pub async fn close(&mut self) {
|
||||
let Some(close_disk) = self.0.clone() else {
|
||||
return;
|
||||
};
|
||||
close_disk().await;
|
||||
self.0 = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CloseDiskGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
let close_fn = self.0.clone();
|
||||
handle.spawn(async move { close_fn().await });
|
||||
if let Some(close_disk) = self.0.take()
|
||||
&& let Ok(handle) = tokio::runtime::Handle::try_current()
|
||||
{
|
||||
handle.spawn(close_disk());
|
||||
}
|
||||
// If there is no runtime we are in a test or shutdown path; skip cleanup.
|
||||
}
|
||||
@@ -3016,6 +3066,61 @@ impl Drop for CloseDiskGuard {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_disk_guard_runs_cleanup_when_an_early_return_drops_it() {
|
||||
let (closed_tx, closed_rx) = tokio::sync::oneshot::channel();
|
||||
let closed_tx = Arc::new(std::sync::Mutex::new(Some(closed_tx)));
|
||||
let close_disk: CloseDiskFn = {
|
||||
let closed_tx = Arc::clone(&closed_tx);
|
||||
Arc::new(move || {
|
||||
let closed_tx = closed_tx.lock().expect("close callback lock").take();
|
||||
Box::pin(async move {
|
||||
if let Some(closed_tx) = closed_tx {
|
||||
let _ = closed_tx.send(());
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let guard = CloseDiskGuard::new(close_disk);
|
||||
drop(guard);
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), closed_rx)
|
||||
.await
|
||||
.expect("drop cleanup should run")
|
||||
.expect("drop cleanup should signal");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_disk_guard_runs_explicit_cleanup_once() {
|
||||
let close_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let close_disk: CloseDiskFn = {
|
||||
let close_count = Arc::clone(&close_count);
|
||||
Arc::new(move || {
|
||||
close_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Box::pin(std::future::ready(()))
|
||||
})
|
||||
};
|
||||
|
||||
let mut guard = CloseDiskGuard::new(close_disk);
|
||||
guard.close().await;
|
||||
drop(guard);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
assert_eq!(close_count.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_path_updater_registers_before_return() {
|
||||
let disk = format!("test-disk-{}", uuid::Uuid::new_v4());
|
||||
let (_update_path, close_disk) = current_path_updater(&disk, "bucket-a").await;
|
||||
|
||||
assert!(global_metrics().current_paths.read().await.contains_key(&disk));
|
||||
|
||||
close_disk().await;
|
||||
assert!(!global_metrics().current_paths.read().await.contains_key(&disk));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_counts_active_scan_paths() {
|
||||
let metrics = Metrics::new();
|
||||
@@ -3304,6 +3409,8 @@ mod tests {
|
||||
});
|
||||
metrics.record_scanner_expiry_enqueue_result(6, true);
|
||||
metrics.record_scanner_expiry_enqueue_result(2, false);
|
||||
metrics.record_scanner_expiry_blocked(4);
|
||||
metrics.record_scanner_expiry_delete_failed(1);
|
||||
|
||||
let report = metrics.report().await;
|
||||
|
||||
@@ -3314,6 +3421,9 @@ mod tests {
|
||||
assert_eq!(report.lifecycle_expiry.queue_missed, 3);
|
||||
assert_eq!(report.lifecycle_expiry.scanner_queued, 6);
|
||||
assert_eq!(report.lifecycle_expiry.scanner_missed, 2);
|
||||
assert_eq!(report.lifecycle_expiry.scanner_blocked, 4);
|
||||
assert_eq!(report.lifecycle_expiry.scanner_not_enqueued, 2);
|
||||
assert_eq!(report.lifecycle_expiry.delete_failed, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3850,6 +3960,21 @@ mod tests {
|
||||
assert_eq!(report.failed_cycles, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_tracks_superseded_cycle_without_failed_increment() {
|
||||
let metrics = Metrics::new();
|
||||
metrics.record_scan_cycle_superseded(Duration::from_millis(750));
|
||||
|
||||
let report = metrics.report().await;
|
||||
|
||||
assert_eq!(report.last_cycle_result, SCAN_CYCLE_RESULT_SUPERSEDED_LABEL);
|
||||
assert_eq!(report.last_cycle_result_code, u64::from(SCAN_CYCLE_RESULT_SUPERSEDED));
|
||||
assert_eq!(report.last_cycle_duration_seconds, 0.75);
|
||||
assert_eq!(report.failed_cycles, 0);
|
||||
assert_eq!(report.superseded_cycles, 1);
|
||||
assert_eq!(report.partial_cycles, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_tracks_successful_scan_cycle_without_failed_increment() {
|
||||
let metrics = Metrics::new();
|
||||
|
||||
@@ -28,6 +28,15 @@ pub const MAX_ADMIN_REQUEST_BODY_SIZE: usize = 1024 * 1024; // 1 MB
|
||||
/// Rationale: ZIP archives with hundreds of IAM entities. 10MB allows ~10,000 small configs.
|
||||
pub const MAX_IAM_IMPORT_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// Maximum total size the members of an IAM import ZIP may expand to (100 MB).
|
||||
/// Used for: bounding decompression of `ImportIam` archive members.
|
||||
/// Rationale: `MAX_IAM_IMPORT_SIZE` caps the *compressed* upload only. Deflate
|
||||
/// reaches ratios far above 100:1, so without a separate budget a 10 MB archive
|
||||
/// can expand without bound. 100 MB keeps a 10x headroom over the compressed cap
|
||||
/// — ample for legitimate IAM exports, which are small JSON documents — while
|
||||
/// keeping the worst case bounded.
|
||||
pub const MAX_IAM_IMPORT_EXPANDED_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
/// Maximum size for bucket metadata import operations (100 MB)
|
||||
/// Used for: Bucket metadata import containing configurations for many buckets
|
||||
/// Rationale: Large deployments may have thousands of buckets with various configs.
|
||||
@@ -54,3 +63,12 @@ pub const MAX_HEAL_REQUEST_SIZE: usize = 1024 * 1024; // 1 MB
|
||||
/// 10MB provides generous headroom for legitimate responses while preventing
|
||||
/// memory exhaustion from malicious or misconfigured remote services.
|
||||
pub const MAX_S3_CLIENT_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
/// Maximum size for OIDC provider response bodies (1 MB)
|
||||
/// Used for: discovery documents, JWKS documents and token endpoint responses
|
||||
/// Rationale: a hostile or compromised identity provider must not be able to exhaust
|
||||
/// memory through an arbitrarily large or endless response body.
|
||||
/// - Discovery documents: typically < 10KB
|
||||
/// - JWKS documents: typically < 50KB
|
||||
/// - Token responses: typically < 10KB
|
||||
pub const MAX_OIDC_RESPONSE_SIZE: usize = 1024 * 1024; // 1 MB
|
||||
|
||||
@@ -97,19 +97,80 @@ pub const ENV_INTERNODE_RPC_MAX_MESSAGE_SIZE: &str = "RUSTFS_INTERNODE_RPC_MAX_M
|
||||
pub const ENV_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: &str = "RUSTFS_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES";
|
||||
pub const DEFAULT_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Stop dual-writing the JSON compatibility strings on internode metadata RPCs and send only the
|
||||
/// Request stopping the JSON compatibility strings on internode metadata RPCs and sending only the
|
||||
/// msgpack `_bin` payloads (grpc-optimization P2-1).
|
||||
///
|
||||
/// Defaults to `false` (dual-write, byte-for-byte legacy behavior). This is a rollout lever, not a
|
||||
/// wire-format change: it may only be enabled **after** the JSON-fallback counter
|
||||
/// (`rustfs_system_network_internode_msgpack_json_fallback_total`) has read zero across a release
|
||||
/// window fleet-wide, confirming every peer decodes `_bin` first. Single-env rollback. See
|
||||
/// Defaults to `false` (dual-write, byte-for-byte legacy behavior). This is only a request; RustFS
|
||||
/// keeps JSON compatibility fields unless [`ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED`] is also
|
||||
/// true after the release-window convergence and rollback gates pass. See
|
||||
/// `docs/operations/internode-msgpack-json-convergence-runbook.md`.
|
||||
pub const ENV_INTERNODE_RPC_MSGPACK_ONLY: &str = "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY";
|
||||
pub const DEFAULT_INTERNODE_RPC_MSGPACK_ONLY: bool = false;
|
||||
|
||||
// Compile-time invariant: dual-write by default so the base build is byte-for-byte legacy behavior.
|
||||
/// Explicit fleet-wide confirmation gate for [`ENV_INTERNODE_RPC_MSGPACK_ONLY`].
|
||||
///
|
||||
/// This separate default-off guard prevents a single legacy flag from accidentally emptying JSON
|
||||
/// fields in a mixed-version fleet where an older peer still reads the JSON field.
|
||||
pub const ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED: &str = "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED";
|
||||
pub const DEFAULT_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED: bool = false;
|
||||
|
||||
// Compile-time invariants: dual-write by default so the base build is byte-for-byte legacy behavior.
|
||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_MSGPACK_ONLY);
|
||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED);
|
||||
|
||||
/// Require target-bound v2 signatures on every internode gRPC request, rejecting the legacy
|
||||
/// constant-target fallback instead of accepting it (<https://github.com/rustfs/backlog/issues/1327>).
|
||||
///
|
||||
/// Defaults to `false` (fail-open): a request without any v2 auth headers keeps authenticating
|
||||
/// through the legacy signature, so legacy-only peers survive rolling upgrades with byte-for-byte
|
||||
/// the pre-gate acceptance behavior. This is a rollout lever, not a wire-format change: it may only
|
||||
/// be enabled **after** the v1-fallback counter
|
||||
/// (`rustfs_system_network_internode_signature_v1_fallback_total`) has read zero across a release
|
||||
/// window fleet-wide, confirming every peer already sends v2 authentication on every internode gRPC
|
||||
/// request. Single-env rollback. Requests that do carry v2 headers are unaffected by this switch:
|
||||
/// they are always verified as v2 with no downgrade, strict or not.
|
||||
pub const ENV_INTERNODE_RPC_SIGNATURE_STRICT: &str = "RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT";
|
||||
pub const DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT: bool = false;
|
||||
|
||||
// Compile-time invariant: fail-open by default so legacy-only peers keep authenticating during
|
||||
// rolling upgrades until the fleet-wide v1-fallback counter reads zero.
|
||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT);
|
||||
|
||||
/// Require a signature-bound canonical body digest on every mutating internode disk RPC
|
||||
/// (RenameData, DeleteVersion, DeleteVersions, WriteMetadata, UpdateMetadata, WriteAll, Delete,
|
||||
/// DeletePaths, RenameFile, RenamePart, DeleteVolume, MakeVolume, MakeVolumes), rejecting requests
|
||||
/// that authenticate without one (<https://github.com/rustfs/backlog/issues/1327>).
|
||||
///
|
||||
/// Defaults to `false` (fail-open): a mutating request without a body digest keeps authenticating
|
||||
/// through the method-bound v2 (or legacy) signature, so peers from releases that predate
|
||||
/// body-digest signing survive rolling upgrades unchanged. Requests that do carry a digest are
|
||||
/// always verified with no downgrade, strict or not — the digest value is part of the signed v2
|
||||
/// scope, so an on-path attacker cannot strip it without invalidating the signature. This is a
|
||||
/// rollout lever gated on the body-digest fallback counter
|
||||
/// (`rustfs_system_network_internode_body_digest_fallback_total`) reading zero across a release
|
||||
/// window fleet-wide. Single-env rollback. It is deliberately separate from
|
||||
/// [`ENV_INTERNODE_RPC_SIGNATURE_STRICT`]: the two enforcement flips converge on different
|
||||
/// counters and must not gate each other.
|
||||
pub const ENV_INTERNODE_RPC_BODY_DIGEST_STRICT: &str = "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT";
|
||||
pub const DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT: bool = false;
|
||||
|
||||
// Compile-time invariant: fail-open by default so digestless peers keep authenticating during
|
||||
// rolling upgrades until the fleet-wide body-digest fallback counter reads zero.
|
||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT);
|
||||
|
||||
/// Capacity (distinct nonces) of the process-local internode RPC replay cache that enforces
|
||||
/// one-time consumption of body-bound v2 signatures.
|
||||
///
|
||||
/// The cache retains each nonce for the ~10-minute signature freshness envelope, so the steady
|
||||
/// state holds roughly `mutating RPS x 601s` entries; the default sustains ~1,700 body-bound
|
||||
/// mutating RPCs per second (about 120 MiB worst case, allocated only under sustained load).
|
||||
/// 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
|
||||
/// counter means this capacity is undersized for the node's peak mutation rate.
|
||||
pub const ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: &str = "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY";
|
||||
pub const DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: usize = 1_048_576;
|
||||
|
||||
/// Consecutive-failure threshold after which an internode peer is marked offline (grpc-optimization
|
||||
/// P3 observability).
|
||||
@@ -273,8 +334,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn internode_msgpack_only_env_name_is_stable() {
|
||||
// The dual-write-by-default invariant is asserted at compile time next to the definition.
|
||||
// The dual-write-by-default invariants are asserted at compile time next to the definitions.
|
||||
assert_eq!(ENV_INTERNODE_RPC_MSGPACK_ONLY, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY");
|
||||
assert_eq!(
|
||||
ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED,
|
||||
"RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internode_signature_strict_env_name_is_stable() {
|
||||
// The fail-open default invariant is asserted at compile time next to the definition.
|
||||
assert_eq!(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internode_body_digest_strict_env_name_is_stable() {
|
||||
// The fail-open default invariant is asserted at compile time next to the definition.
|
||||
assert_eq!(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internode_replay_cache_capacity_defaults_and_env_name() {
|
||||
assert_eq!(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY");
|
||||
assert_eq!(DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, 1_048_576);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -116,6 +116,39 @@ pub const ENV_OBJECT_GET_SKIP_BITROT_VERIFY: &str = "RUSTFS_OBJECT_GET_SKIP_BITR
|
||||
/// Default: bitrot verification is enabled on GetObject reads (do not skip).
|
||||
pub const DEFAULT_OBJECT_GET_SKIP_BITROT_VERIFY: bool = false;
|
||||
|
||||
/// Request writing the complete remote-tier version state into object metadata.
|
||||
///
|
||||
/// This remains ineffective until
|
||||
/// [`ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED`] is also enabled.
|
||||
pub const ENV_TIER_REMOTE_VERSION_STATE_WRITE: &str = "RUSTFS_TIER_REMOTE_VERSION_STATE_WRITE";
|
||||
pub const DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE: bool = false;
|
||||
|
||||
/// Operator-attested fleet-wide confirmation for
|
||||
/// [`ENV_TIER_REMOTE_VERSION_STATE_WRITE`].
|
||||
///
|
||||
/// This flag is an operational contract, not automatic capability discovery.
|
||||
/// Operators may enable it only after every node that can write or read
|
||||
/// transitioned object metadata supports the remote version-state schema and
|
||||
/// semantics. Keeping the confirmation separate makes a single-node request or
|
||||
/// a writer whose local opt-in is removed fail closed.
|
||||
pub const ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: &str = "RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED";
|
||||
pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
|
||||
|
||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
||||
|
||||
#[cfg(test)]
|
||||
mod remote_version_state_tests {
|
||||
#[test]
|
||||
fn remote_version_state_gate_uses_stable_environment_names() {
|
||||
assert_eq!(super::ENV_TIER_REMOTE_VERSION_STATE_WRITE, "RUSTFS_TIER_REMOTE_VERSION_STATE_WRITE");
|
||||
assert_eq!(
|
||||
super::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
|
||||
"RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||
// =============================================================================
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
// OIDC configuration field keys (used in KVS)
|
||||
pub const OIDC_CONFIG_URL: &str = "config_url";
|
||||
pub const OIDC_ISSUER: &str = "issuer";
|
||||
pub const OIDC_CLIENT_ID: &str = "client_id";
|
||||
pub const OIDC_CLIENT_SECRET: &str = "client_secret";
|
||||
pub const OIDC_SCOPES: &str = "scopes";
|
||||
@@ -33,6 +34,7 @@ pub const OIDC_HIDE_FROM_UI: &str = "hide_from_ui";
|
||||
// Environment variable names for OIDC
|
||||
pub const ENV_IDENTITY_OPENID_ENABLE: &str = "RUSTFS_IDENTITY_OPENID_ENABLE";
|
||||
pub const ENV_IDENTITY_OPENID_CONFIG_URL: &str = "RUSTFS_IDENTITY_OPENID_CONFIG_URL";
|
||||
pub const ENV_IDENTITY_OPENID_ISSUER: &str = "RUSTFS_IDENTITY_OPENID_ISSUER";
|
||||
pub const ENV_IDENTITY_OPENID_CLIENT_ID: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_ID";
|
||||
pub const ENV_IDENTITY_OPENID_CLIENT_SECRET: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_SECRET";
|
||||
pub const ENV_IDENTITY_OPENID_SCOPES: &str = "RUSTFS_IDENTITY_OPENID_SCOPES";
|
||||
@@ -50,9 +52,10 @@ pub const ENV_IDENTITY_OPENID_USERNAME_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_USE
|
||||
pub const ENV_IDENTITY_OPENID_HIDE_FROM_UI: &str = "RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI";
|
||||
|
||||
/// List of all environment variable keys for an OIDC provider.
|
||||
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
|
||||
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 18] = &[
|
||||
ENV_IDENTITY_OPENID_ENABLE,
|
||||
ENV_IDENTITY_OPENID_CONFIG_URL,
|
||||
ENV_IDENTITY_OPENID_ISSUER,
|
||||
ENV_IDENTITY_OPENID_CLIENT_ID,
|
||||
ENV_IDENTITY_OPENID_CLIENT_SECRET,
|
||||
ENV_IDENTITY_OPENID_SCOPES,
|
||||
@@ -74,6 +77,7 @@ pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
|
||||
pub const IDENTITY_OPENID_KEYS: &[&str] = &[
|
||||
crate::ENABLE_KEY,
|
||||
OIDC_CONFIG_URL,
|
||||
OIDC_ISSUER,
|
||||
OIDC_CLIENT_ID,
|
||||
OIDC_CLIENT_SECRET,
|
||||
OIDC_SCOPES,
|
||||
|
||||
@@ -57,6 +57,7 @@ pub const ENV_WEBDAV_CERTS_DIR: &str = "RUSTFS_WEBDAV_CERTS_DIR";
|
||||
pub const ENV_WEBDAV_CA_FILE: &str = "RUSTFS_WEBDAV_CA_FILE";
|
||||
pub const ENV_WEBDAV_MAX_BODY_SIZE: &str = "RUSTFS_WEBDAV_MAX_BODY_SIZE";
|
||||
pub const ENV_WEBDAV_REQUEST_TIMEOUT: &str = "RUSTFS_WEBDAV_REQUEST_TIMEOUT";
|
||||
pub const ENV_WEBDAV_MAX_CONNECTIONS: &str = "RUSTFS_WEBDAV_MAX_CONNECTIONS";
|
||||
|
||||
/// Default SFTP server bind address.
|
||||
pub const DEFAULT_SFTP_ADDRESS: &str = "0.0.0.0:2222";
|
||||
|
||||
@@ -220,12 +220,10 @@ pub const ENV_SCANNER_YIELD_EVERY_N_OBJECTS: &str = "RUSTFS_SCANNER_YIELD_EVERY_
|
||||
pub const DEFAULT_SCANNER_IDLE_MODE: bool = true;
|
||||
|
||||
/// Default set scan concurrency budget.
|
||||
/// `0` means no additional limit beyond deployment topology.
|
||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 0;
|
||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 4;
|
||||
|
||||
/// Default disk scan concurrency budget.
|
||||
/// `0` means no additional limit beyond available disks in the set.
|
||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 0;
|
||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
|
||||
|
||||
/// Default object interval for cooperative scanner yields.
|
||||
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
|
||||
|
||||
@@ -142,6 +142,10 @@ pub const DEFAULT_H2_KEEP_ALIVE_TIMEOUT: u64 = 10;
|
||||
/// proxy's upstream idle-keepalive, or lower the proxy's keepalive below this
|
||||
/// value. Environments that expose RustFS directly to untrusted slow clients and
|
||||
/// want tighter slowloris protection can lower it via the env var below.
|
||||
///
|
||||
/// The same budget bounds the TLS handshake on the listener, so an unauthenticated
|
||||
/// peer cannot park an accept task and its socket indefinitely by opening a
|
||||
/// connection and then stalling the handshake.
|
||||
pub const ENV_HTTP1_HEADER_READ_TIMEOUT: &str = "RUSTFS_HTTP1_HEADER_READ_TIMEOUT";
|
||||
pub const DEFAULT_HTTP1_HEADER_READ_TIMEOUT: u64 = 75;
|
||||
|
||||
|
||||
@@ -56,3 +56,33 @@ pub const DEFAULT_OBJECT_MMAP_READ_ENABLE: bool = true;
|
||||
///
|
||||
/// Prefer [`DEFAULT_OBJECT_MMAP_READ_ENABLE`].
|
||||
pub const DEFAULT_OBJECT_ZERO_COPY_ENABLE: bool = DEFAULT_OBJECT_MMAP_READ_ENABLE;
|
||||
|
||||
/// Environment variable capping the byte length a single mmap-copy read may
|
||||
/// materialize in memory.
|
||||
///
|
||||
/// The mmap-copy read path returns the whole requested range as one owned
|
||||
/// allocation before the first byte is served. GET/heal shard reads request
|
||||
/// the entire part span in one call, so for a large single-part object
|
||||
/// (e.g. a multi-gigabyte non-multipart upload) an uncapped mmap-copy read
|
||||
/// allocates the whole shard in memory — stalling first-byte latency past the
|
||||
/// disk-read timeout and OOM-killing memory-limited deployments
|
||||
/// (<https://github.com/rustfs/rustfs/issues/5123>). Reads longer than this
|
||||
/// cap fall back to the bounded streaming reader instead.
|
||||
///
|
||||
/// - Purpose: Bound per-shard-read memory for mmap-based reads
|
||||
/// - Acceptable values: byte count as an unsigned integer; `0` disables
|
||||
/// mmap-copy for all non-empty reads (every read streams)
|
||||
/// - Example: `export RUSTFS_OBJECT_MMAP_READ_MAX_LENGTH=8388608`
|
||||
pub const ENV_OBJECT_MMAP_READ_MAX_LENGTH: &str = "RUSTFS_OBJECT_MMAP_READ_MAX_LENGTH";
|
||||
|
||||
/// Default mmap-copy read length cap: 32 MiB per shard read.
|
||||
///
|
||||
/// Large enough that typical multipart part shards (parts up to a few hundred
|
||||
/// megabytes across the erasure set) keep the mmap fast path, small enough
|
||||
/// that whole-part reads of huge single-part objects stream instead of
|
||||
/// materializing gigabytes per shard.
|
||||
///
|
||||
/// The cap bounds memory per shard reader, so a single part read can still
|
||||
/// materialize up to `data_shards x cap` bytes; raising the cap raises that
|
||||
/// per-request bound proportionally.
|
||||
pub const DEFAULT_OBJECT_MMAP_READ_MAX_LENGTH: usize = 32 * 1024 * 1024;
|
||||
|
||||
@@ -32,6 +32,20 @@ use std::{
|
||||
/// save forever and freeze admin usage stats; callers must bypass the skip instead.
|
||||
pub const USAGE_LAST_UPDATE_FUTURE_TOLERANCE: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// Cluster-wide usage snapshot written by coordinated scanners.
|
||||
///
|
||||
/// `usage_snapshot_complete` is an additive JSON field: older readers ignore
|
||||
/// it, while current readers treat snapshots from older writers as unknown.
|
||||
/// 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";
|
||||
|
||||
/// Usage snapshot written by scanner implementations predating distributed
|
||||
/// leadership fencing. It is read only when neither authoritative snapshot
|
||||
/// copy exists.
|
||||
// RUSTFS_COMPAT_TODO(scanner-usage-v2): keep .usage.json readable and removable during rolling upgrades from pre-v2 scanners. Remove after supported direct-upgrade sources all write .usage.v2.json.
|
||||
pub const LEGACY_DATA_USAGE_OBJECT_NAME: &str = ".usage.json";
|
||||
|
||||
/// Returns true when `existing_last_update` is ahead of `now` by more than
|
||||
/// [`USAGE_LAST_UPDATE_FUTURE_TOLERANCE`], i.e. the persisted timestamp cannot be
|
||||
/// trusted for staleness comparisons and a fresh snapshot save must be allowed.
|
||||
@@ -95,7 +109,7 @@ impl AllTierStats {
|
||||
}
|
||||
|
||||
/// Bucket target usage info provides replication statistics
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BucketTargetUsageInfo {
|
||||
pub replication_pending_size: u64,
|
||||
pub replication_failed_size: u64,
|
||||
@@ -107,7 +121,7 @@ pub struct BucketTargetUsageInfo {
|
||||
}
|
||||
|
||||
/// Bucket usage info provides bucket-level statistics
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BucketUsageInfo {
|
||||
pub size: u64,
|
||||
// Following five fields suffixed with V1 are here for backward compatibility
|
||||
@@ -133,7 +147,7 @@ pub struct BucketUsageInfo {
|
||||
}
|
||||
|
||||
/// DataUsageInfo represents data usage stats of the underlying storage
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DataUsageInfo {
|
||||
/// Total capacity
|
||||
pub total_capacity: u64,
|
||||
@@ -145,6 +159,22 @@ pub struct DataUsageInfo {
|
||||
/// LastUpdate is the timestamp of when the data usage info was last updated
|
||||
pub last_update: Option<SystemTime>,
|
||||
|
||||
/// Monotonic scanner cycle that produced this complete snapshot.
|
||||
///
|
||||
/// Older snapshots omit this field and continue to use `last_update` for
|
||||
/// compatibility. New scanner snapshots use the cycle to fence stale
|
||||
/// leaders independently of wall-clock skew.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scanner_cycle: Option<u64>,
|
||||
|
||||
/// Persisted scanner leadership epoch that produced this snapshot.
|
||||
///
|
||||
/// The epoch is claimed through the cycle-state CAS before scanning. It
|
||||
/// orders snapshots from different leaders even when their wall clocks or
|
||||
/// cycle counters coincide.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scanner_epoch: Option<u64>,
|
||||
|
||||
/// Objects total count across all buckets
|
||||
pub objects_total_count: u64,
|
||||
/// Versions total count across all buckets
|
||||
@@ -160,6 +190,12 @@ pub struct DataUsageInfo {
|
||||
pub buckets_count: u64,
|
||||
/// Buckets usage info provides following information across all buckets
|
||||
pub buckets_usage: HashMap<String, BucketUsageInfo>,
|
||||
/// Whether this snapshot covers the complete bucket namespace.
|
||||
///
|
||||
/// Legacy snapshots default to `false`. A complete snapshot contains an
|
||||
/// explicit entry for every bucket, including confirmed-empty buckets.
|
||||
#[serde(default)]
|
||||
pub usage_snapshot_complete: bool,
|
||||
/// Deprecated kept here for backward compatibility reasons
|
||||
pub bucket_sizes: HashMap<String, u64>,
|
||||
/// Per-disk snapshot information when available
|
||||
@@ -168,7 +204,7 @@ pub struct DataUsageInfo {
|
||||
}
|
||||
|
||||
/// Metadata describing the status of a disk-level data usage snapshot.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DiskUsageStatus {
|
||||
pub disk_id: String,
|
||||
pub pool_index: Option<usize>,
|
||||
@@ -268,12 +304,30 @@ impl DataUsageHash {
|
||||
pub type DataUsageHashMap = HashSet<String>;
|
||||
|
||||
/// Size histogram for object size distribution
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
const SIZE_HISTOGRAM_LEN: usize = 11;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SizeHistogram(Vec<u64>);
|
||||
|
||||
impl Default for SizeHistogram {
|
||||
fn default() -> Self {
|
||||
Self(vec![0; 11]) // DATA_USAGE_BUCKET_LEN = 11
|
||||
Self(vec![0; SIZE_HISTOGRAM_LEN])
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SizeHistogram {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let values = Vec::<u64>::deserialize(deserializer)?;
|
||||
if values.len() != SIZE_HISTOGRAM_LEN {
|
||||
return Err(serde::de::Error::invalid_length(
|
||||
values.len(),
|
||||
&"exactly 11 object-size histogram buckets",
|
||||
));
|
||||
}
|
||||
Ok(Self(values))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +397,7 @@ impl SizeHistogram {
|
||||
.zip(names.iter())
|
||||
.filter(|((_, (start, end)), name)| name != &&"BETWEEN_1024B_AND_1_MB" && *start >= 1024 && *end < ONE_MIB)
|
||||
.map(|((count, _), _)| *count)
|
||||
.sum();
|
||||
.fold(0, u64::saturating_add);
|
||||
|
||||
let mut res = HashMap::new();
|
||||
for (count, name) in self.0.iter().zip(names.iter()) {
|
||||
@@ -364,12 +418,30 @@ impl SizeHistogram {
|
||||
}
|
||||
|
||||
/// Versions histogram for version count distribution
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
const VERSIONS_HISTOGRAM_LEN: usize = 7;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct VersionsHistogram(Vec<u64>);
|
||||
|
||||
impl Default for VersionsHistogram {
|
||||
fn default() -> Self {
|
||||
Self(vec![0; 7]) // DATA_USAGE_VERSION_LEN = 7
|
||||
Self(vec![0; VERSIONS_HISTOGRAM_LEN])
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for VersionsHistogram {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let values = Vec::<u64>::deserialize(deserializer)?;
|
||||
if values.len() != VERSIONS_HISTOGRAM_LEN {
|
||||
return Err(serde::de::Error::invalid_length(
|
||||
values.len(),
|
||||
&"exactly 7 object-version histogram buckets",
|
||||
));
|
||||
}
|
||||
Ok(Self(values))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,13 +607,74 @@ impl DataUsageEntry {
|
||||
}
|
||||
}
|
||||
|
||||
for (i, v) in other.obj_sizes.0.iter().enumerate() {
|
||||
self.obj_sizes.0[i] += v;
|
||||
}
|
||||
self.obj_sizes.merge_from(&other.obj_sizes);
|
||||
self.obj_versions.merge_from(&other.obj_versions);
|
||||
}
|
||||
|
||||
for (i, v) in other.obj_versions.0.iter().enumerate() {
|
||||
self.obj_versions.0[i] += v;
|
||||
pub fn checked_merge(&mut self, other: &DataUsageEntry) -> bool {
|
||||
let scalar_counts_fit = self.objects.checked_add(other.objects).is_some()
|
||||
&& self.versions.checked_add(other.versions).is_some()
|
||||
&& self.delete_markers.checked_add(other.delete_markers).is_some()
|
||||
&& self.size.checked_add(other.size).is_some()
|
||||
&& self.failed_objects.checked_add(other.failed_objects).is_some();
|
||||
let histograms_fit = self.obj_sizes.0.len() == SIZE_HISTOGRAM_LEN
|
||||
&& other.obj_sizes.0.len() == SIZE_HISTOGRAM_LEN
|
||||
&& self.obj_versions.0.len() == VERSIONS_HISTOGRAM_LEN
|
||||
&& other.obj_versions.0.len() == VERSIONS_HISTOGRAM_LEN
|
||||
&& self
|
||||
.obj_sizes
|
||||
.0
|
||||
.iter()
|
||||
.zip(other.obj_sizes.0.iter())
|
||||
.all(|(left, right)| left.checked_add(*right).is_some())
|
||||
&& self
|
||||
.obj_versions
|
||||
.0
|
||||
.iter()
|
||||
.zip(other.obj_versions.0.iter())
|
||||
.all(|(left, right)| left.checked_add(*right).is_some());
|
||||
let replication_fits = match (&self.replication_stats, &other.replication_stats) {
|
||||
(_, None) | (None, Some(_)) => true,
|
||||
(Some(left), Some(right)) => {
|
||||
left.replica_size.checked_add(right.replica_size).is_some()
|
||||
&& left.replica_count.checked_add(right.replica_count).is_some()
|
||||
&& right.targets.iter().all(|(target, right_stats)| {
|
||||
left.targets.get(target).is_none_or(|left_stats| {
|
||||
left_stats.pending_size.checked_add(right_stats.pending_size).is_some()
|
||||
&& left_stats.replicated_size.checked_add(right_stats.replicated_size).is_some()
|
||||
&& left_stats.failed_size.checked_add(right_stats.failed_size).is_some()
|
||||
&& left_stats.failed_count.checked_add(right_stats.failed_count).is_some()
|
||||
&& left_stats.pending_count.checked_add(right_stats.pending_count).is_some()
|
||||
&& left_stats
|
||||
.missed_threshold_size
|
||||
.checked_add(right_stats.missed_threshold_size)
|
||||
.is_some()
|
||||
&& left_stats
|
||||
.after_threshold_size
|
||||
.checked_add(right_stats.after_threshold_size)
|
||||
.is_some()
|
||||
&& left_stats
|
||||
.missed_threshold_count
|
||||
.checked_add(right_stats.missed_threshold_count)
|
||||
.is_some()
|
||||
&& left_stats
|
||||
.after_threshold_count
|
||||
.checked_add(right_stats.after_threshold_count)
|
||||
.is_some()
|
||||
&& left_stats
|
||||
.replicated_count
|
||||
.checked_add(right_stats.replicated_count)
|
||||
.is_some()
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
if !scalar_counts_fit || !histograms_fit || !replication_fits {
|
||||
return false;
|
||||
}
|
||||
self.merge(other);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +687,12 @@ pub struct DataUsageCacheInfo {
|
||||
pub skip_healing: bool,
|
||||
#[serde(default)]
|
||||
pub failed_objects: HashMap<String, u64>,
|
||||
/// Whether this per-set cache was produced by a completed scanner pass.
|
||||
///
|
||||
/// Older cache writers omit this field and therefore deserialize as
|
||||
/// incomplete instead of exposing partial set totals as confirmed zeros.
|
||||
#[serde(default)]
|
||||
pub snapshot_complete: bool,
|
||||
}
|
||||
|
||||
/// Data usage cache
|
||||
@@ -873,6 +1012,7 @@ impl DataUsageCache {
|
||||
objects_total_size: flat.size as u64,
|
||||
buckets_count: u64::try_from(buckets.len()).unwrap_or(u64::MAX),
|
||||
buckets_usage,
|
||||
usage_snapshot_complete: self.info.snapshot_complete,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -951,6 +1091,13 @@ impl DataUsageInfo {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Whether this snapshot authoritatively covers every reported bucket.
|
||||
pub fn is_complete_bucket_usage_snapshot(&self) -> bool {
|
||||
self.usage_snapshot_complete
|
||||
&& self.last_update.is_some()
|
||||
&& u64::try_from(self.buckets_usage.len()).ok() == Some(self.buckets_count)
|
||||
}
|
||||
|
||||
/// Add object metadata to data usage statistics
|
||||
pub fn add_object(&mut self, object_path: &str, meta_object: &rustfs_filemeta::MetaObject) {
|
||||
// This method is kept for backward compatibility
|
||||
@@ -1315,6 +1462,35 @@ pub struct CompressionTotalInfo {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LegacyUsageReader {
|
||||
buckets_count: u64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completeness_marker_is_additive_for_legacy_named_readers() {
|
||||
let current = DataUsageInfo {
|
||||
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(¤t).expect("encode current data usage snapshot");
|
||||
let legacy: LegacyUsageReader = rmp_serde::from_slice(&encoded).expect("legacy reader should ignore additive fields");
|
||||
|
||||
assert_eq!(legacy.buckets_count, 0);
|
||||
assert!(current.is_complete_bucket_usage_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completeness_marker_requires_a_snapshot_timestamp() {
|
||||
let untimestamped = DataUsageInfo {
|
||||
usage_snapshot_complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!untimestamped.is_complete_bucket_usage_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_last_update_future_tolerance_boundary() {
|
||||
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
@@ -1395,6 +1571,17 @@ mod tests {
|
||||
assert_eq!(map["BETWEEN_512_KB_AND_1_MB"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_size_histogram_compat_rollup_saturates_on_corrupt_counts() {
|
||||
let mut hist = SizeHistogram::default();
|
||||
hist.0[1] = u64::MAX;
|
||||
hist.0[2] = 1;
|
||||
|
||||
let map = hist.to_map();
|
||||
|
||||
assert_eq!(map["BETWEEN_1024B_AND_1_MB"], u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_usage_cache_merge_adds_missing_child() {
|
||||
let mut base = DataUsageCache::default();
|
||||
@@ -1708,4 +1895,86 @@ mod tests {
|
||||
assert!(cache.find("bucket/large/a").is_some());
|
||||
assert!(cache.find("bucket/large/b").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_merge_rejects_scalar_and_replication_overflow_without_mutation() {
|
||||
let mut entry = DataUsageEntry {
|
||||
objects: usize::MAX,
|
||||
replication_stats: Some(ReplicationAllStats {
|
||||
replica_size: 7,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let other = DataUsageEntry {
|
||||
objects: 1,
|
||||
replication_stats: Some(ReplicationAllStats {
|
||||
replica_size: u64::MAX,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!entry.checked_merge(&other));
|
||||
assert_eq!(entry.objects, usize::MAX);
|
||||
assert_eq!(entry.replication_stats.as_ref().map(|stats| stats.replica_size), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_merge_accepts_valid_usage() {
|
||||
let mut entry = DataUsageEntry {
|
||||
objects: 2,
|
||||
size: 20,
|
||||
..Default::default()
|
||||
};
|
||||
let other = DataUsageEntry {
|
||||
objects: 3,
|
||||
size: 30,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(entry.checked_merge(&other));
|
||||
assert_eq!(entry.objects, 5);
|
||||
assert_eq!(entry.size, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_deserialization_rejects_noncanonical_lengths() {
|
||||
let invalid_sizes =
|
||||
rmp_serde::to_vec(&vec![0_u64; SIZE_HISTOGRAM_LEN + 1]).expect("encode invalid object-size histogram fixture");
|
||||
let invalid_versions =
|
||||
rmp_serde::to_vec(&vec![0_u64; VERSIONS_HISTOGRAM_LEN - 1]).expect("encode invalid object-version histogram fixture");
|
||||
|
||||
assert!(rmp_serde::from_slice::<SizeHistogram>(&invalid_sizes).is_err());
|
||||
assert!(rmp_serde::from_slice::<VersionsHistogram>(&invalid_versions).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_target_deserialization_preserves_large_historical_maps() {
|
||||
let mut stats = ReplicationAllStats::default();
|
||||
for index in 0..=1024 {
|
||||
stats.targets.insert(format!("target-{index}"), ReplicationStats::default());
|
||||
}
|
||||
let encoded = rmp_serde::to_vec_named(&stats).expect("large replication target fixture should encode");
|
||||
let decoded = rmp_serde::from_slice::<ReplicationAllStats>(&encoded)
|
||||
.expect("historical replication target maps must remain readable");
|
||||
|
||||
assert_eq!(decoded.targets.len(), stats.targets.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_merge_rejects_noncanonical_histograms_without_mutation() {
|
||||
let mut entry = DataUsageEntry {
|
||||
objects: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let other = DataUsageEntry {
|
||||
objects: 3,
|
||||
obj_sizes: SizeHistogram(vec![0; SIZE_HISTOGRAM_LEN + 1]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!entry.checked_merge(&other));
|
||||
assert_eq!(entry.objects, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,11 @@ sftp = []
|
||||
|
||||
[dependencies]
|
||||
rustfs-config = { workspace = true, features = ["constants"] }
|
||||
rustfs-credentials.workspace = true
|
||||
rustfs-ecstore.workspace = true
|
||||
rustfs-data-usage.workspace = true
|
||||
rustfs-rio.workspace = true
|
||||
rustfs-utils = { workspace = true, features = ["egress"] }
|
||||
flatbuffers.workspace = true
|
||||
futures.workspace = true
|
||||
rustfs-lock.workspace = true
|
||||
@@ -48,6 +50,7 @@ rustfs-filemeta.workspace = true
|
||||
bytes = { workspace = true, features = ["serde"] }
|
||||
serial_test = { workspace = true }
|
||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||
aws-config = { workspace = true }
|
||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
||||
async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
|
||||
@@ -68,6 +71,8 @@ base64 = { workspace = true }
|
||||
rand = { workspace = true, features = ["serde"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
md5 = { workspace = true }
|
||||
opentelemetry-proto = { workspace = true }
|
||||
prost.workspace = true
|
||||
sha2 = { workspace = true }
|
||||
astral-tokio-tar = { workspace = true }
|
||||
s3s = { workspace = true, features = ["minio"] }
|
||||
|
||||
@@ -46,6 +46,45 @@ mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info";
|
||||
const ADMIN_MANUAL_TRANSITION_BUCKET: &str = "auth-deny-manual-transition";
|
||||
const ADMIN_MANUAL_TRANSITION_PATH: &str =
|
||||
"/rustfs/admin/v3/ilm/transition/run?bucket=auth-deny-manual-transition&maxObjects=1&mode=async";
|
||||
|
||||
fn assert_no_raw_manual_transition_markers(body: &str, context: &str) {
|
||||
assert!(
|
||||
!body.contains("\"marker\"") && !body.contains("\"versionMarker\"") && !body.contains("\"version_marker\""),
|
||||
"{context} must not expose raw manual transition resume markers, body: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_terminal_manual_transition_job(
|
||||
env: &RustFSTestEnvironment,
|
||||
status_endpoint: &str,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let (status, body) =
|
||||
signed_request(&env.url, http::Method::GET, status_endpoint, None, &env.access_key, &env.secret_key).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::OK,
|
||||
"root credential must query manual transition job status, body: {body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&body, "manual transition status response");
|
||||
let value: serde_json::Value = serde_json::from_str(&body)?;
|
||||
let job_status = value
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("manual transition job status response must include status")?;
|
||||
if matches!(job_status, "completed" | "partial" | "cancelled" | "failed" | "unknown") {
|
||||
return Ok(body);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("manual transition job did not reach terminal status within 30s; last={body}").into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a SigV4-signed request to `path` (optionally with a JSON `body`) and
|
||||
/// return `(status, body)`. Uses the `UNSIGNED_PAYLOAD` content hash so a
|
||||
@@ -158,6 +197,130 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn non_admin_credential_denied_on_manual_transition_run() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let user_ak = "ilmtransitionlimited";
|
||||
let user_sk = "ilmtransitionlimitedsecret";
|
||||
create_limited_user(&env, user_ak, user_sk).await?;
|
||||
env.create_s3_client()
|
||||
.create_bucket()
|
||||
.bucket(ADMIN_MANUAL_TRANSITION_BUCKET)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let (root_status, root_body) = signed_request(
|
||||
&env.url,
|
||||
http::Method::POST,
|
||||
ADMIN_MANUAL_TRANSITION_PATH,
|
||||
None,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
root_status,
|
||||
reqwest::StatusCode::ACCEPTED,
|
||||
"root credential must reach the manual transition handler, body: {root_body}"
|
||||
);
|
||||
assert!(
|
||||
root_body.contains("\"mode\":\"durable_job\""),
|
||||
"root response should be the durable manual transition JSON contract, body: {root_body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&root_body, "manual transition run response");
|
||||
let root_value: serde_json::Value = serde_json::from_str(&root_body)?;
|
||||
let job_id = root_value
|
||||
.get("job_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("manual transition async response must include job_id")?;
|
||||
let status_endpoint = root_value
|
||||
.get("status_endpoint")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("manual transition async response must include status_endpoint")?;
|
||||
let cancel_endpoint = root_value
|
||||
.get("cancel_endpoint")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("manual transition async response must include cancel_endpoint")?;
|
||||
assert_eq!(
|
||||
cancel_endpoint, status_endpoint,
|
||||
"manual transition durable jobs currently use the same status/cancel endpoint"
|
||||
);
|
||||
assert!(
|
||||
status_endpoint.ends_with(job_id),
|
||||
"status endpoint must address the returned job id, job_id={job_id}, status_endpoint={status_endpoint}"
|
||||
);
|
||||
|
||||
let terminal_body = wait_for_terminal_manual_transition_job(&env, status_endpoint).await?;
|
||||
let terminal: serde_json::Value = serde_json::from_str(&terminal_body)?;
|
||||
assert_eq!(terminal.get("job_id").and_then(serde_json::Value::as_str), Some(job_id));
|
||||
assert_eq!(
|
||||
terminal
|
||||
.get("report")
|
||||
.and_then(|report| report.get("bucket"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some(ADMIN_MANUAL_TRANSITION_BUCKET)
|
||||
);
|
||||
|
||||
let (root_status, root_body) =
|
||||
signed_request(&env.url, http::Method::DELETE, status_endpoint, None, &env.access_key, &env.secret_key).await?;
|
||||
assert_eq!(
|
||||
root_status,
|
||||
reqwest::StatusCode::OK,
|
||||
"root credential must cancel/query a terminal manual transition job idempotently, body: {root_body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&root_body, "manual transition cancel response");
|
||||
let root_cancel: serde_json::Value = serde_json::from_str(&root_body)?;
|
||||
assert_eq!(root_cancel.get("job_id").and_then(serde_json::Value::as_str), Some(job_id));
|
||||
assert!(
|
||||
matches!(
|
||||
root_cancel.get("status").and_then(serde_json::Value::as_str),
|
||||
Some("completed" | "partial" | "failed" | "unknown")
|
||||
),
|
||||
"terminal cancel must not rewrite the job into cancelled state, body: {root_body}"
|
||||
);
|
||||
|
||||
let (status, body) =
|
||||
signed_request(&env.url, http::Method::POST, ADMIN_MANUAL_TRANSITION_PATH, None, user_ak, user_sk).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"non-admin credential must get 403 on manual transition run, body: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("AccessDenied"),
|
||||
"manual transition rejection must carry the AccessDenied S3 error code, body: {body}"
|
||||
);
|
||||
let (status, body) = signed_request(&env.url, http::Method::GET, status_endpoint, None, user_ak, user_sk).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"non-admin credential must get 403 on manual transition status, body: {body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&body, "manual transition status rejection");
|
||||
assert!(
|
||||
body.contains("AccessDenied"),
|
||||
"manual transition status rejection must carry the AccessDenied S3 error code, body: {body}"
|
||||
);
|
||||
let (status, body) = signed_request(&env.url, http::Method::DELETE, status_endpoint, None, user_ak, user_sk).await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"non-admin credential must get 403 on manual transition cancel, body: {body}"
|
||||
);
|
||||
assert_no_raw_manual_transition_markers(&body, "manual transition cancel rejection");
|
||||
assert!(
|
||||
body.contains("AccessDenied"),
|
||||
"manual transition cancel rejection must carry the AccessDenied S3 error code, body: {body}"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rotating the root credentials (restart with new `--access-key` /
|
||||
/// `--secret-key` on the same data directory) takes effect: the new
|
||||
/// credential is accepted and the old one is rejected, on both the S3 data
|
||||
|
||||
@@ -170,3 +170,81 @@ async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
|
||||
info!("Test passed: anonymous access allowed with RestrictPublicBuckets=false");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A policy granting anonymous `s3:ListBucket` also permits ListObjectVersions.
|
||||
/// That grant must still be subject to RestrictPublicBuckets: the versions listing
|
||||
/// reaches authorization through a fallback branch, and that branch has to apply the
|
||||
/// same public-access gate as a direct grant.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn ghsa_x298_anonymous_list_object_versions_denied_when_restrict_public_buckets_enabled()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
info!("Starting test: anonymous ListObjectVersions denied with RestrictPublicBuckets=true...");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let bucket_name = "anon-test-restrict-versions";
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client.create_bucket().bucket(bucket_name).send().await?;
|
||||
|
||||
let policy_json = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowAnonymousListBucket",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:ListBucket"],
|
||||
"Resource": [format!("arn:aws:s3:::{}", bucket_name)]
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
admin_client
|
||||
.put_bucket_policy()
|
||||
.bucket(bucket_name)
|
||||
.policy(&policy_json)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
admin_client
|
||||
.put_object()
|
||||
.bucket(bucket_name)
|
||||
.key("test.txt")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"hello anonymous"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Without the public-access block the fallback grant is expected to work.
|
||||
let versions_url = format!("{}/{}?versions=", env.url, bucket_name);
|
||||
let resp = local_http_client().get(&versions_url).send().await?;
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
200,
|
||||
"Anonymous ListObjectVersions should succeed via the s3:ListBucket grant"
|
||||
);
|
||||
|
||||
admin_client
|
||||
.put_public_access_block()
|
||||
.bucket(bucket_name)
|
||||
.public_access_block_configuration(
|
||||
PublicAccessBlockConfiguration::builder()
|
||||
.restrict_public_buckets(true)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let resp = local_http_client().get(&versions_url).send().await?;
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
403,
|
||||
"Anonymous ListObjectVersions must be denied when RestrictPublicBuckets is true"
|
||||
);
|
||||
|
||||
info!("Test passed: anonymous ListObjectVersions denied with RestrictPublicBuckets=true");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -892,6 +892,7 @@ pub struct RustFSTestClusterEnvironment {
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
pub extra_env: Vec<(String, String)>,
|
||||
pub node_extra_env: Vec<Vec<(String, String)>>,
|
||||
pub topology: ClusterTopology,
|
||||
}
|
||||
|
||||
@@ -990,6 +991,7 @@ impl RustFSTestClusterEnvironment {
|
||||
access_key: "rustfs-cluster-test-access".to_string(),
|
||||
secret_key: "rustfs-cluster-test-secret".to_string(),
|
||||
extra_env,
|
||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||
topology,
|
||||
})
|
||||
}
|
||||
@@ -1003,6 +1005,22 @@ impl RustFSTestClusterEnvironment {
|
||||
self.extra_env.push((key.into(), value.into()));
|
||||
}
|
||||
|
||||
/// Add an extra environment variable applied to a single cluster node.
|
||||
pub fn set_node_env<K, V>(
|
||||
&mut self,
|
||||
node_idx: usize,
|
||||
key: K,
|
||||
value: V,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
where
|
||||
K: Into<String>,
|
||||
V: Into<String>,
|
||||
{
|
||||
self.ensure_node_index(node_idx)?;
|
||||
self.node_extra_env[node_idx].push((key.into(), value.into()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_node_index(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if node_idx >= self.nodes.len() {
|
||||
return Err(format!("node_idx {node_idx} is invalid").into());
|
||||
@@ -1089,6 +1107,9 @@ impl RustFSTestClusterEnvironment {
|
||||
for (key, value) in &self.extra_env {
|
||||
command.env(key, value);
|
||||
}
|
||||
for (key, value) in &self.node_extra_env[i] {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||
|
||||
@@ -1130,6 +1151,9 @@ impl RustFSTestClusterEnvironment {
|
||||
for (key, value) in &self.extra_env {
|
||||
command.env(key, value);
|
||||
}
|
||||
for (key, value) in &self.node_extra_env[node_idx] {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||
node.process = Some(process);
|
||||
@@ -1371,6 +1395,7 @@ mod tests {
|
||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||
extra_env: Vec::new(),
|
||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||
topology,
|
||||
}
|
||||
}
|
||||
@@ -1455,4 +1480,24 @@ mod tests {
|
||||
assert!(ClusterTopology::single_pool_multidrive(4, 4).validate().is_ok());
|
||||
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_node_env_supports_per_node_overrides() {
|
||||
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||
env.set_node_env(2, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true").unwrap();
|
||||
assert_eq!(
|
||||
env.node_extra_env[2].as_slice(),
|
||||
[("RUSTFS_INTERNODE_RPC_MSGPACK_ONLY".to_string(), "true".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_node_env_rejects_invalid_index() {
|
||||
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||
let err = env
|
||||
.set_node_env(4, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("invalid"), "unexpected error: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,25 @@ mod tests {
|
||||
assert_eq!(head_resp.content_encoding(), Some("zstd"), "HEAD should return Content-Encoding: zstd");
|
||||
assert_eq!(head_resp.content_type(), Some("text/plain"), "HEAD should return correct Content-Type");
|
||||
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("DELETE object failed");
|
||||
client
|
||||
.delete_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("DELETE bucket failed");
|
||||
client
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await
|
||||
.expect("RustFS must remain available after deleting a bucket");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,18 +12,24 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression test for Issue #4996: CopyObject must return the destination object's
|
||||
//! checksum in `CopyObjectResult` and persist it so a later checksum-mode HEAD/GET
|
||||
//! returns the same value. Covers both the requested-algorithm case (compute fresh)
|
||||
//! and the no-algorithm case (preserve the source object's existing checksum).
|
||||
//! CopyObject checksum compatibility tests. Covers all supported algorithms,
|
||||
//! source-checksum preservation, explicit override, and fail-closed handling of
|
||||
//! unsupported algorithms before destination mutation.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, ChecksumAlgorithm, ChecksumMode, VersioningConfiguration};
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, ChecksumAlgorithm, ChecksumMode, ChecksumType, CompletedMultipartUpload, CompletedPart,
|
||||
VersioningConfiguration,
|
||||
};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||
use serial_test::serial;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::info;
|
||||
@@ -48,6 +54,401 @@ mod tests {
|
||||
.expect("Failed to enable versioning");
|
||||
}
|
||||
|
||||
fn create_s3_client_no_auto_checksum(env: &RustFSTestEnvironment) -> aws_sdk_s3::Client {
|
||||
let credentials = Credentials::new(&env.access_key, &env.secret_key, None, None, "copy-checksum-e2e");
|
||||
let config = aws_sdk_s3::Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(format!("http://{}", env.address))
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
|
||||
.http_client(SmithyHttpClientBuilder::new().build_http())
|
||||
.build();
|
||||
aws_sdk_s3::Client::from_conf(config)
|
||||
}
|
||||
|
||||
fn algorithms() -> [(ChecksumAlgorithm, RioChecksumType); 10] {
|
||||
[
|
||||
(ChecksumAlgorithm::Crc32, RioChecksumType::CRC32),
|
||||
(ChecksumAlgorithm::Crc32C, RioChecksumType::CRC32C),
|
||||
(ChecksumAlgorithm::Crc64Nvme, RioChecksumType::CRC64_NVME),
|
||||
(ChecksumAlgorithm::Sha1, RioChecksumType::SHA1),
|
||||
(ChecksumAlgorithm::Sha256, RioChecksumType::SHA256),
|
||||
(ChecksumAlgorithm::Md5, RioChecksumType::MD5),
|
||||
(ChecksumAlgorithm::Sha512, RioChecksumType::SHA512),
|
||||
(ChecksumAlgorithm::Xxhash3, RioChecksumType::XXHASH3),
|
||||
(ChecksumAlgorithm::Xxhash64, RioChecksumType::XXHASH64),
|
||||
(ChecksumAlgorithm::Xxhash128, RioChecksumType::XXHASH128),
|
||||
]
|
||||
}
|
||||
|
||||
fn result_checksums(result: &aws_sdk_s3::types::CopyObjectResult) -> [Option<&str>; 10] {
|
||||
[
|
||||
result.checksum_crc32(),
|
||||
result.checksum_crc32_c(),
|
||||
result.checksum_crc64_nvme(),
|
||||
result.checksum_sha1(),
|
||||
result.checksum_sha256(),
|
||||
result.checksum_md5(),
|
||||
result.checksum_sha512(),
|
||||
result.checksum_xxhash3(),
|
||||
result.checksum_xxhash64(),
|
||||
result.checksum_xxhash128(),
|
||||
]
|
||||
}
|
||||
|
||||
fn head_checksums(output: &aws_sdk_s3::operation::head_object::HeadObjectOutput) -> [Option<&str>; 10] {
|
||||
[
|
||||
output.checksum_crc32(),
|
||||
output.checksum_crc32_c(),
|
||||
output.checksum_crc64_nvme(),
|
||||
output.checksum_sha1(),
|
||||
output.checksum_sha256(),
|
||||
output.checksum_md5(),
|
||||
output.checksum_sha512(),
|
||||
output.checksum_xxhash3(),
|
||||
output.checksum_xxhash64(),
|
||||
output.checksum_xxhash128(),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_supports_all_checksum_algorithms() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client_no_auto_checksum(&env);
|
||||
let src_bucket = "copy-all-checksums-src";
|
||||
let dst_bucket = "copy-all-checksums-dst";
|
||||
let src_key = "objects/source.bin";
|
||||
let content = b"deterministic CopyObject payload for all ten checksum algorithms";
|
||||
|
||||
create_versioned_bucket(&client, src_bucket).await;
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source failed");
|
||||
|
||||
for (index, (sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
|
||||
let expected = Checksum::new_from_data(rio_algorithm, content)
|
||||
.expect("supported checksum must be computable")
|
||||
.encoded;
|
||||
let dst_key = format!("objects/destination-{index}.bin");
|
||||
let copy = client
|
||||
.copy_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||
.checksum_algorithm(sdk_algorithm)
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject with supported checksum must succeed");
|
||||
let result = copy.copy_object_result().expect("CopyObject result");
|
||||
let checksums = result_checksums(result);
|
||||
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: response checksum");
|
||||
assert_eq!(
|
||||
checksums.iter().filter(|checksum| checksum.is_some()).count(),
|
||||
1,
|
||||
"{rio_algorithm}: only the requested checksum may be returned"
|
||||
);
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD destination failed");
|
||||
let checksums = head_checksums(&head);
|
||||
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: persisted checksum");
|
||||
assert_eq!(
|
||||
checksums.iter().filter(|checksum| checksum.is_some()).count(),
|
||||
1,
|
||||
"{rio_algorithm}: destination must persist only the requested checksum"
|
||||
);
|
||||
|
||||
let body = client
|
||||
.get_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET destination failed")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("collect destination body")
|
||||
.into_bytes();
|
||||
assert_eq!(body.as_ref(), content, "{rio_algorithm}: full copied body");
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_without_algorithm_preserves_every_supported_source_checksum() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client_no_auto_checksum(&env);
|
||||
let src_bucket = "copy-preserve-all-src";
|
||||
let dst_bucket = "copy-preserve-all-dst";
|
||||
let content = b"source checksum preservation payload for all ten algorithms";
|
||||
|
||||
create_versioned_bucket(&client, src_bucket).await;
|
||||
create_versioned_bucket(&client, dst_bucket).await;
|
||||
|
||||
for (index, (_sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
|
||||
let expected = Checksum::new_from_data(rio_algorithm, content)
|
||||
.expect("supported checksum must be computable")
|
||||
.encoded;
|
||||
let checksum_header = rio_algorithm.key().expect("supported checksum header");
|
||||
let request_checksum = expected.clone();
|
||||
let src_key = format!("objects/source-{index}.bin");
|
||||
let dst_key = format!("objects/destination-{index}.bin");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(&src_key)
|
||||
.body(ByteStream::from_static(content))
|
||||
.customize()
|
||||
.mutate_request(move |request| {
|
||||
request.headers_mut().insert(checksum_header, request_checksum.clone());
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT checksummed source failed");
|
||||
|
||||
let copy = client
|
||||
.copy_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject without algorithm must succeed");
|
||||
let result = copy.copy_object_result().expect("CopyObject result");
|
||||
let checksums = result_checksums(result);
|
||||
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved response checksum");
|
||||
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(&dst_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD destination failed");
|
||||
let checksums = head_checksums(&head);
|
||||
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved stored checksum");
|
||||
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_without_algorithm_preserves_composite_checksum_type() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client_no_auto_checksum(&env);
|
||||
let bucket = "copy-preserve-composite";
|
||||
let source_key = "objects/multipart-source.bin";
|
||||
let destination_key = "objects/copied-multipart.bin";
|
||||
let content = b"multipart source checksum must remain composite";
|
||||
|
||||
create_versioned_bucket(&client, bucket).await;
|
||||
let created = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||
.send()
|
||||
.await
|
||||
.expect("CreateMultipartUpload failed");
|
||||
let upload_id = created.upload_id().expect("multipart upload ID");
|
||||
let checksum = Checksum::new_from_data(RioChecksumType::SHA256, content)
|
||||
.expect("SHA256 checksum")
|
||||
.encoded;
|
||||
let uploaded = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.checksum_sha256(&checksum)
|
||||
.body(ByteStream::from_static(content))
|
||||
.send()
|
||||
.await
|
||||
.expect("UploadPart failed");
|
||||
let completed_part = CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(uploaded.e_tag().expect("part ETag"))
|
||||
.checksum_sha256(uploaded.checksum_sha256().expect("part checksum"))
|
||||
.build();
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
|
||||
.send()
|
||||
.await
|
||||
.expect("CompleteMultipartUpload failed");
|
||||
|
||||
let source_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD multipart source failed");
|
||||
let source_checksum = source_head.checksum_sha256().expect("multipart source checksum");
|
||||
assert_eq!(source_head.checksum_type(), Some(&ChecksumType::Composite));
|
||||
|
||||
let copied = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(destination_key)
|
||||
.copy_source(format!("{bucket}/{source_key}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject without algorithm failed");
|
||||
let result = copied.copy_object_result().expect("CopyObject result");
|
||||
assert_eq!(result.checksum_sha256(), Some(source_checksum));
|
||||
assert_eq!(result.checksum_type(), Some(&ChecksumType::Composite));
|
||||
|
||||
let destination_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(destination_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD copied multipart object failed");
|
||||
assert_eq!(destination_head.checksum_sha256(), Some(source_checksum));
|
||||
assert_eq!(destination_head.checksum_type(), Some(&ChecksumType::Composite));
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_rejects_unknown_algorithm_without_destination_mutation() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-reject-unknown-checksum";
|
||||
let src_key = "objects/source.bin";
|
||||
let dst_key = "objects/destination.bin";
|
||||
let source = b"source must never replace destination";
|
||||
let destination = b"pre-existing destination must remain byte-for-byte unchanged";
|
||||
let expected = Checksum::new_from_data(RioChecksumType::SHA256, destination)
|
||||
.expect("SHA256 checksum")
|
||||
.encoded;
|
||||
|
||||
create_versioned_bucket(&client, bucket).await;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from_static(source))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source failed");
|
||||
let original = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.metadata("state", "original")
|
||||
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||
.body(ByteStream::from_static(destination))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT destination failed");
|
||||
let original_version = original.version_id().expect("versioned PUT must return a version id");
|
||||
|
||||
let missing_source_error = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{bucket}/objects/missing-source.bin"))
|
||||
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("checksum validation must precede source lookup");
|
||||
assert_eq!(
|
||||
missing_source_error.as_service_error().and_then(|value| value.code()),
|
||||
Some("InvalidArgument")
|
||||
);
|
||||
assert_eq!(missing_source_error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||
|
||||
let error = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{bucket}/{src_key}"))
|
||||
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("unsupported checksum algorithm must fail");
|
||||
assert_eq!(error.as_service_error().and_then(|value| value.code()), Some("InvalidArgument"));
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD unchanged destination");
|
||||
assert_eq!(head.version_id(), Some(original_version));
|
||||
assert_eq!(
|
||||
head.metadata().and_then(|metadata| metadata.get("state").map(String::as_str)),
|
||||
Some("original")
|
||||
);
|
||||
assert_eq!(head.checksum_sha256(), Some(expected.as_str()));
|
||||
|
||||
let body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(dst_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET unchanged destination")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("collect unchanged destination")
|
||||
.into_bytes();
|
||||
assert_eq!(body.as_ref(), destination);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// Requested algorithm: a CopyObject asking for SHA256 must compute it over the copied
|
||||
/// bytes, return it in `CopyObjectResult.ChecksumSHA256`, and persist it so a checksum-mode
|
||||
/// HEAD on the destination returns the identical value.
|
||||
|
||||
@@ -17,14 +17,17 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::MetadataDirective;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTime, DateTimeFormat};
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, MetadataDirective, StorageClass, VersioningConfiguration,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_self_copy_replace_metadata_preserves_readable_object() {
|
||||
async fn copy_object_standard_metadata_copy_replace_and_clear() {
|
||||
init_logging();
|
||||
info!("Issue #2789: self-copy metadata replacement must preserve object data");
|
||||
|
||||
@@ -35,6 +38,14 @@ mod tests {
|
||||
let bucket = "self-copy-metadata-replace-test";
|
||||
let key = "assets/chunk-2F3R7JUG.js";
|
||||
let content = b"console.log('metadata replacement should keep object data readable');";
|
||||
let source_expires = DateTime::from_secs(1_893_456_000);
|
||||
let source_expires_http_date = source_expires
|
||||
.fmt(DateTimeFormat::HttpDate)
|
||||
.expect("Test timestamp should format as an HTTP date");
|
||||
let replacement_expires = DateTime::from_secs(1_924_992_000);
|
||||
let replacement_expires_http_date = replacement_expires
|
||||
.fmt(DateTimeFormat::HttpDate)
|
||||
.expect("Test timestamp should format as an HTTP date");
|
||||
|
||||
client
|
||||
.create_bucket()
|
||||
@@ -47,7 +58,14 @@ mod tests {
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.cache_control("max-age=60")
|
||||
.content_disposition("inline; filename=source.js")
|
||||
.content_encoding("br")
|
||||
.content_language("en-US")
|
||||
.content_type("text/javascript; charset=utf-8")
|
||||
.expires(source_expires)
|
||||
.website_redirect_location("/source.html")
|
||||
.storage_class(StorageClass::ReducedRedundancy)
|
||||
.metadata("mtime", "1777992333")
|
||||
.metadata("stale", "must-be-removed")
|
||||
.body(ByteStream::from_static(content))
|
||||
@@ -55,13 +73,120 @@ mod tests {
|
||||
.await
|
||||
.expect("PUT failed");
|
||||
|
||||
let copied_key = "assets/default-copy.js";
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(copied_key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("default CopyObject failed");
|
||||
|
||||
let copied_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(copied_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after default copy");
|
||||
assert_eq!(copied_head.cache_control(), Some("max-age=60"));
|
||||
assert_eq!(copied_head.content_disposition(), Some("inline; filename=source.js"));
|
||||
assert_eq!(copied_head.content_encoding(), Some("br"));
|
||||
assert_eq!(copied_head.content_language(), Some("en-US"));
|
||||
assert_eq!(copied_head.content_type(), Some("text/javascript; charset=utf-8"));
|
||||
assert_eq!(copied_head.expires_string(), Some(source_expires_http_date.as_str()));
|
||||
assert_eq!(
|
||||
copied_head.storage_class(),
|
||||
None,
|
||||
"CopyObject without a storage class should write STANDARD"
|
||||
);
|
||||
assert_eq!(
|
||||
copied_head.website_redirect_location(),
|
||||
Some("/source.html"),
|
||||
"default CopyObject should preserve source metadata"
|
||||
);
|
||||
assert_eq!(
|
||||
copied_head.metadata().and_then(|metadata| metadata.get("stale")),
|
||||
Some(&"must-be-removed".to_string())
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-copy.js")
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Copy)
|
||||
.send()
|
||||
.await
|
||||
.expect("explicit COPY directive failed");
|
||||
let explicit_copy_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-copy.js")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after explicit COPY");
|
||||
assert_eq!(explicit_copy_head.cache_control(), Some("max-age=60"));
|
||||
assert_eq!(
|
||||
explicit_copy_head.website_redirect_location(),
|
||||
None,
|
||||
"explicit COPY does not inherit website redirect metadata"
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-copy-redirect.js")
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Copy)
|
||||
.website_redirect_location("/explicit-copy.html")
|
||||
.send()
|
||||
.await
|
||||
.expect("explicit COPY with redirect failed");
|
||||
let explicit_redirect_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-copy-redirect.js")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after explicit COPY with redirect");
|
||||
assert_eq!(explicit_redirect_head.website_redirect_location(), Some("/explicit-copy.html"));
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-storage-class.js")
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.storage_class(StorageClass::ReducedRedundancy)
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject with an explicit storage class failed");
|
||||
let explicit_storage_class_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("assets/explicit-storage-class.js")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed after explicit storage class copy");
|
||||
assert_eq!(
|
||||
explicit_storage_class_head.storage_class().map(StorageClass::as_str),
|
||||
Some("REDUCED_REDUNDANCY")
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.content_type("text/javascript; charset=utf-8")
|
||||
.cache_control("no-cache")
|
||||
.content_disposition("attachment; filename=replaced.js")
|
||||
.content_encoding("gzip")
|
||||
.content_language("fr-FR")
|
||||
.content_type("application/javascript")
|
||||
.expires(replacement_expires)
|
||||
.website_redirect_location("/replaced.html")
|
||||
.metadata("mtime", "1777992348")
|
||||
.send()
|
||||
.await
|
||||
@@ -85,6 +210,14 @@ mod tests {
|
||||
None,
|
||||
"HEAD should not return metadata omitted by REPLACE"
|
||||
);
|
||||
assert_eq!(head_resp.cache_control(), Some("no-cache"));
|
||||
assert_eq!(head_resp.content_disposition(), Some("attachment; filename=replaced.js"));
|
||||
assert_eq!(head_resp.content_encoding(), Some("gzip"));
|
||||
assert_eq!(head_resp.content_language(), Some("fr-FR"));
|
||||
assert_eq!(head_resp.content_type(), Some("application/javascript"));
|
||||
assert_eq!(head_resp.expires_string(), Some(replacement_expires_http_date.as_str()));
|
||||
assert_eq!(head_resp.website_redirect_location(), Some("/replaced.html"));
|
||||
assert_eq!(head_resp.storage_class(), None, "REPLACE without a storage class should write STANDARD");
|
||||
|
||||
let get_resp = client
|
||||
.get_object()
|
||||
@@ -123,6 +256,13 @@ mod tests {
|
||||
None,
|
||||
"HEAD should not return metadata omitted by empty REPLACE"
|
||||
);
|
||||
assert_eq!(empty_head_resp.cache_control(), None);
|
||||
assert_eq!(empty_head_resp.content_disposition(), None);
|
||||
assert_eq!(empty_head_resp.content_encoding(), None);
|
||||
assert_eq!(empty_head_resp.content_language(), None);
|
||||
assert_eq!(empty_head_resp.content_type(), None);
|
||||
assert_eq!(empty_head_resp.expires_string(), None);
|
||||
assert_eq!(empty_head_resp.website_redirect_location(), None);
|
||||
|
||||
let empty_get_resp = client
|
||||
.get_object()
|
||||
@@ -141,4 +281,333 @@ mod tests {
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn copy_object_replace_accepts_each_standard_field_independently() {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-object-metadata-fields";
|
||||
let source = "source.txt";
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.cache_control("source-cache")
|
||||
.content_disposition("inline")
|
||||
.content_encoding("br")
|
||||
.content_language("en")
|
||||
.content_type("text/source")
|
||||
.expires(DateTime::from_secs(1_893_456_000))
|
||||
.body(ByteStream::from_static(b"field-by-field"))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT failed");
|
||||
let replacement_expires = DateTime::from_secs(1_924_992_000);
|
||||
let replacement_expires_http_date = replacement_expires
|
||||
.fmt(DateTimeFormat::HttpDate)
|
||||
.expect("Test timestamp should format as an HTTP date");
|
||||
|
||||
for field in [
|
||||
"cache-control",
|
||||
"content-disposition",
|
||||
"content-encoding",
|
||||
"content-language",
|
||||
"content-type",
|
||||
"expires",
|
||||
"website-redirect",
|
||||
] {
|
||||
let destination = format!("{field}.txt");
|
||||
let request = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(&destination)
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.metadata_directive(MetadataDirective::Replace);
|
||||
let request = match field {
|
||||
"cache-control" => request.cache_control("field-cache"),
|
||||
"content-disposition" => request.content_disposition("attachment"),
|
||||
"content-encoding" => request.content_encoding("gzip"),
|
||||
"content-language" => request.content_language("de"),
|
||||
"content-type" => request.content_type("text/field"),
|
||||
"expires" => request.expires(replacement_expires),
|
||||
"website-redirect" => request.website_redirect_location("/field.html"),
|
||||
_ => unreachable!("field table contains only supported entries"),
|
||||
};
|
||||
request.send().await.expect("field-specific CopyObject failed");
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(&destination)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed");
|
||||
assert_eq!(head.cache_control(), (field == "cache-control").then_some("field-cache"));
|
||||
assert_eq!(head.content_disposition(), (field == "content-disposition").then_some("attachment"));
|
||||
assert_eq!(head.content_encoding(), (field == "content-encoding").then_some("gzip"));
|
||||
assert_eq!(head.content_language(), (field == "content-language").then_some("de"));
|
||||
assert_eq!(head.content_type(), (field == "content-type").then_some("text/field"));
|
||||
assert_eq!(
|
||||
head.expires_string(),
|
||||
(field == "expires").then_some(replacement_expires_http_date.as_str())
|
||||
);
|
||||
assert_eq!(head.website_redirect_location(), (field == "website-redirect").then_some("/field.html"));
|
||||
}
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("user-metadata-collision.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.metadata("content-type", "user-content-type")
|
||||
.metadata("content-encoding", "user-content-encoding")
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject should preserve user metadata namespaces");
|
||||
let collision_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("user-metadata-collision.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD failed for metadata collision case");
|
||||
assert_eq!(collision_head.content_type(), None);
|
||||
assert_eq!(collision_head.content_encoding(), None);
|
||||
assert_eq!(
|
||||
collision_head.metadata().and_then(|metadata| metadata.get("content-type")),
|
||||
Some(&"user-content-type".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
collision_head
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.get("content-encoding")),
|
||||
Some(&"user-content-encoding".to_string())
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn copy_object_replace_handles_versioned_multipart_source() {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-object-metadata-multipart";
|
||||
let source = "source.bin";
|
||||
let multipart_body = b"multipart historical source";
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to enable versioning");
|
||||
|
||||
let upload = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.content_type("application/source")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
let upload_id = upload.upload_id().expect("Multipart upload should return an ID");
|
||||
let part = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from_static(multipart_body))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to upload multipart part");
|
||||
let completed = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(part.e_tag().expect("Uploaded part should return an ETag"))
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to complete multipart upload");
|
||||
let historical_version = completed
|
||||
.version_id()
|
||||
.expect("Versioned multipart upload should return a version ID")
|
||||
.to_string();
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.body(ByteStream::from_static(b"new current version"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to write current version");
|
||||
|
||||
let copy = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("restored.bin")
|
||||
.copy_source(format!("{bucket}/{source}?versionId={historical_version}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.content_type("application/replaced")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to copy historical multipart version");
|
||||
assert_eq!(copy.copy_source_version_id(), Some(historical_version.as_str()));
|
||||
|
||||
let restored = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("restored.bin")
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to read copied multipart source");
|
||||
assert_eq!(restored.content_type(), Some("application/replaced"));
|
||||
assert_eq!(
|
||||
restored
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("Failed to collect restored body")
|
||||
.into_bytes()
|
||||
.as_ref(),
|
||||
multipart_body
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn invalid_replacement_metadata_does_not_mutate_destination() {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_REJECT_ARCHIVE_CONTENT_ENCODING", "true")])
|
||||
.await
|
||||
.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-object-invalid-metadata";
|
||||
let key = "destination.zip";
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.content_type("application/zip")
|
||||
.metadata("state", "original")
|
||||
.body(ByteStream::from_static(b"original destination"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to write destination");
|
||||
|
||||
let error = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.content_type("application/zip")
|
||||
.content_encoding("gzip")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Invalid replacement metadata should be rejected");
|
||||
assert_eq!(error.as_service_error().and_then(|err| err.code()), Some("InvalidArgument"));
|
||||
|
||||
let invalid_directive = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("x-amz-metadata-directive", "UNKNOWN");
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Unknown metadata directives should be rejected");
|
||||
assert_eq!(
|
||||
invalid_directive.as_service_error().and_then(|error| error.code()),
|
||||
Some("InvalidArgument")
|
||||
);
|
||||
|
||||
let ignored_replacement = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.content_type("application/ignored")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Replacement fields without REPLACE should be rejected");
|
||||
assert_eq!(
|
||||
ignored_replacement.as_service_error().and_then(|error| error.code()),
|
||||
Some("InvalidRequest")
|
||||
);
|
||||
|
||||
let unchanged = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("Destination should remain readable");
|
||||
assert_eq!(unchanged.content_type(), Some("application/zip"));
|
||||
assert_eq!(
|
||||
unchanged.metadata().and_then(|metadata| metadata.get("state")),
|
||||
Some(&"original".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
unchanged
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("Failed to collect destination body")
|
||||
.into_bytes()
|
||||
.as_ref(),
|
||||
b"original destination"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
// 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.
|
||||
|
||||
//! CopyObject tagging directive regression tests.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, MetadataDirective, TaggingDirective, VersioningConfiguration};
|
||||
use serial_test::serial;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
async fn object_tags(client: &Client, bucket: &str, key: &str) -> BTreeMap<String, String> {
|
||||
client
|
||||
.get_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("GetObjectTagging should succeed")
|
||||
.tag_set()
|
||||
.iter()
|
||||
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn copy_object_applies_copy_replace_and_empty_tagging_directives() {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new()
|
||||
.await
|
||||
.expect("test environment should initialize");
|
||||
env.start_rustfs_server(vec![]).await.expect("RustFS should start");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "copy-object-tagging-directive";
|
||||
let source = "source.txt";
|
||||
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("bucket creation should succeed");
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("versioning should be enabled");
|
||||
|
||||
let first_version = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.tagging("project=rustfs&stage=first")
|
||||
.body(ByteStream::from_static(b"first"))
|
||||
.send()
|
||||
.await
|
||||
.expect("first source version should be written")
|
||||
.version_id()
|
||||
.expect("versioned PUT should return a version ID")
|
||||
.to_string();
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.tagging("project=rustfs&stage=current")
|
||||
.body(ByteStream::from_static(b"current"))
|
||||
.send()
|
||||
.await
|
||||
.expect("current source version should be written");
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("default-copy.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("default CopyObject should preserve current source tags");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "default-copy.txt").await,
|
||||
BTreeMap::from([
|
||||
("project".to_string(), "rustfs".to_string()),
|
||||
("stage".to_string(), "current".to_string()),
|
||||
])
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("explicit-copy.txt")
|
||||
.copy_source(format!("{bucket}/{source}?versionId={first_version}"))
|
||||
.tagging_directive(TaggingDirective::Copy)
|
||||
.send()
|
||||
.await
|
||||
.expect("COPY should preserve the selected historical version's tags");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "explicit-copy.txt").await,
|
||||
BTreeMap::from([
|
||||
("project".to_string(), "rustfs".to_string()),
|
||||
("stage".to_string(), "first".to_string()),
|
||||
])
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("replace.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.tagging("project=cli&label=copy%20test")
|
||||
.send()
|
||||
.await
|
||||
.expect("REPLACE should atomically apply requested tags");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "replace.txt").await,
|
||||
BTreeMap::from([
|
||||
("label".to_string(), "copy test".to_string()),
|
||||
("project".to_string(), "cli".to_string()),
|
||||
])
|
||||
);
|
||||
let replace_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("replace.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD should succeed after tag replacement");
|
||||
assert_eq!(replace_head.tag_count(), Some(2));
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("empty-replace.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.send()
|
||||
.await
|
||||
.expect("REPLACE without Tagging should clear the destination tag set");
|
||||
assert!(object_tags(&client, bucket, "empty-replace.txt").await.is_empty());
|
||||
let empty_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("empty-replace.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD should succeed after empty tag replacement");
|
||||
assert_eq!(empty_head.tag_count(), None);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("metadata-replace-tag-copy.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.metadata("updated", "true")
|
||||
.send()
|
||||
.await
|
||||
.expect("metadata REPLACE must preserve tags under the default COPY directive");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "metadata-replace-tag-copy.txt").await,
|
||||
BTreeMap::from([
|
||||
("project".to_string(), "rustfs".to_string()),
|
||||
("stage".to_string(), "current".to_string()),
|
||||
])
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("combined-replace.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.metadata_directive(MetadataDirective::Replace)
|
||||
.metadata("updated", "true")
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.tagging("project=combined")
|
||||
.send()
|
||||
.await
|
||||
.expect("metadata and tagging REPLACE directives must be independent");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "combined-replace.txt").await,
|
||||
BTreeMap::from([("project".to_string(), "combined".to_string())])
|
||||
);
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.tagging("project=self-copy")
|
||||
.send()
|
||||
.await
|
||||
.expect("self-copy with tag replacement should update tags atomically");
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, source).await,
|
||||
BTreeMap::from([("project".to_string(), "self-copy".to_string())])
|
||||
);
|
||||
let self_copy_body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.send()
|
||||
.await
|
||||
.expect("self-copy destination should remain readable")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("self-copy body should be complete")
|
||||
.into_bytes();
|
||||
assert_eq!(self_copy_body.as_ref(), b"current", "tag-only self-copy must preserve the object body");
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("malformed.txt")
|
||||
.tagging("state=original")
|
||||
.body(ByteStream::from_static(b"original destination"))
|
||||
.send()
|
||||
.await
|
||||
.expect("preexisting malformed-test destination should be written");
|
||||
|
||||
let malformed = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("malformed.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::Replace)
|
||||
.tagging("project=rustfs%ZZ")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("malformed tags must fail CopyObject");
|
||||
assert_eq!(malformed.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidTag"));
|
||||
assert_eq!(
|
||||
object_tags(&client, bucket, "malformed.txt").await,
|
||||
BTreeMap::from([("state".to_string(), "original".to_string())])
|
||||
);
|
||||
let preserved_body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("malformed.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect("malformed tags must not replace an existing destination")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("preserved destination body should be readable")
|
||||
.into_bytes();
|
||||
assert_eq!(
|
||||
preserved_body.as_ref(),
|
||||
b"original destination",
|
||||
"malformed tags must leave destination data unchanged"
|
||||
);
|
||||
|
||||
let discarded = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("discarded.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging("project=must-not-be-discarded")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Tagging without REPLACE must fail instead of discarding requested tags");
|
||||
assert_eq!(discarded.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidRequest"));
|
||||
|
||||
let invalid_directive = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("invalid-directive.txt")
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.tagging_directive(TaggingDirective::from("UNKNOWN"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an unknown TaggingDirective must fail");
|
||||
assert_eq!(
|
||||
invalid_directive.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidArgument")
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn copy_object_tag_replacement_honors_request_tag_policy_denial() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
let source_bucket = "copy-tags-policy-source";
|
||||
let destination_bucket = "copy-tags-policy-destination";
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let admin = env.create_s3_client();
|
||||
admin.create_bucket().bucket(source_bucket).send().await?;
|
||||
admin.create_bucket().bucket(destination_bucket).send().await?;
|
||||
admin
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("source.txt")
|
||||
.tagging("source=allowed")
|
||||
.body(ByteStream::from_static(b"source"))
|
||||
.send()
|
||||
.await?;
|
||||
admin
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key("conditioned.txt")
|
||||
.body(ByteStream::from_static(b"conditioned source"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let source_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{source_bucket}/source.txt")]
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{source_bucket}/conditioned.txt")],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:RequestObjectTag/classification": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
admin
|
||||
.put_bucket_policy()
|
||||
.bucket(source_bucket)
|
||||
.policy(source_policy)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let destination_policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{destination_bucket}/*")]
|
||||
},
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Principal": "*",
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [format!("arn:aws:s3:::{destination_bucket}/*")],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:RequestObjectTag/classification": "restricted"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
admin
|
||||
.put_bucket_policy()
|
||||
.bucket(destination_bucket)
|
||||
.policy(destination_policy)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let copy_source = format!("/{source_bucket}/source.txt");
|
||||
let allowed = local_http_client()
|
||||
.put(format!("{}/{destination_bucket}/allowed.txt", env.url))
|
||||
.header("x-amz-copy-source", ©_source)
|
||||
.header("x-amz-tagging-directive", "REPLACE")
|
||||
.header("x-amz-tagging", "classification=public")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
allowed.status(),
|
||||
reqwest::StatusCode::OK,
|
||||
"a tag set allowed by the request-tag policy should copy successfully"
|
||||
);
|
||||
assert_eq!(
|
||||
object_tags(&admin, destination_bucket, "allowed.txt").await,
|
||||
BTreeMap::from([("classification".to_string(), "public".to_string())])
|
||||
);
|
||||
|
||||
let denied = local_http_client()
|
||||
.put(format!("{}/{destination_bucket}/denied.txt", env.url))
|
||||
.header("x-amz-copy-source", copy_source)
|
||||
.header("x-amz-tagging-directive", "REPLACE")
|
||||
.header("x-amz-tagging", "classification=restricted")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
denied.status(),
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"CopyObject must honor a request-tag policy Deny"
|
||||
);
|
||||
|
||||
let source_condition_bypass = local_http_client()
|
||||
.put(format!("{}/{destination_bucket}/source-condition.txt", env.url))
|
||||
.header("x-amz-copy-source", format!("/{source_bucket}/conditioned.txt"))
|
||||
.header("x-amz-tagging-directive", "REPLACE")
|
||||
.header("x-amz-tagging", "classification=public")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
source_condition_bypass.status(),
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"destination request tags must not satisfy source GetObject policy conditions"
|
||||
);
|
||||
|
||||
let missing_destination = admin
|
||||
.head_object()
|
||||
.bucket(destination_bucket)
|
||||
.key("denied.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an access-denied copy must not create a destination object");
|
||||
assert_eq!(
|
||||
missing_destination.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("NotFound")
|
||||
);
|
||||
let missing_bypass_destination = admin
|
||||
.head_object()
|
||||
.bucket(destination_bucket)
|
||||
.key("source-condition.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a source authorization denial must not create a destination object");
|
||||
assert_eq!(
|
||||
missing_bypass_destination
|
||||
.as_service_error()
|
||||
.and_then(ProvideErrorMetadata::code),
|
||||
Some("NotFound")
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,10 @@
|
||||
//! * Parity reconstruction: one data disk is taken offline
|
||||
//! (`take_disk_offline`) and the SAME object matrix is GET both ways while
|
||||
//! the EC 2+2 set rebuilds each large object from the surviving shards. The
|
||||
//! codec-streaming reader gate never inspects drive health, so the codec
|
||||
//! fast path is exercised end-to-end through reconstruction; the test
|
||||
//! asserts byte- and header-equality vs the legacy path AND that the codec
|
||||
//! phase never fell back to a duplex pipe while reconstructing.
|
||||
//! eager first/single-part setup may keep its conservative whole-request
|
||||
//! fallback when shard placement makes codec streaming unsafe, so this phase
|
||||
//! asserts byte- and header-equality vs the legacy path rather than requiring
|
||||
//! zero duplex fallbacks under degraded drive health.
|
||||
//! * Missing object: a GET for an absent key is compared across both phases
|
||||
//! to prove the error semantics (HTTP status + S3 error code) are identical
|
||||
//! — the codec env must not perturb the NoSuchKey negative path.
|
||||
@@ -475,15 +475,14 @@ mod tests {
|
||||
"ranged GET length diverged with codec streaming enabled"
|
||||
);
|
||||
|
||||
// ---- Phase B degraded: the same reconstruction, now on the codec path ----
|
||||
// Re-run the reconstruction A/B with the codec-streaming gates still
|
||||
// open. The reader gate decision is independent of drive health (it
|
||||
// never inspects disk state), so the codec fast path is exercised
|
||||
// end-to-end while the EC set rebuilds each large object from the
|
||||
// surviving shards — this is a real codec-vs-legacy reconstruction test,
|
||||
// not legacy-vs-legacy. Snapshot the duplex count first (the range GET
|
||||
// above already used the duplex path) so we can measure only the markers
|
||||
// these degraded codec GETs add.
|
||||
// ---- Phase B degraded: the same reconstruction, with codec gates open ----
|
||||
// Re-run the reconstruction A/B with codec-streaming enabled. If eager
|
||||
// first/single-part setup cannot prove the codec path is safe for the
|
||||
// surviving shards, the implementation intentionally preserves the
|
||||
// whole-request legacy fallback; later multipart parts can degrade in
|
||||
// place. This phase verifies parity-reconstructed bytes and headers,
|
||||
// while the healthy phase above remains the strict zero-duplex path
|
||||
// confirmation.
|
||||
let dup_codec_before_degraded = count_marker(&codec_log, DUPLEX_MARKER);
|
||||
harness.take_disk_offline(0)?;
|
||||
let mut codec_degraded: BTreeMap<String, GetView> = BTreeMap::new();
|
||||
@@ -511,16 +510,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Path confirmation under reconstruction: the codec fast path must have
|
||||
// served the reconstructed large objects without ever falling back to
|
||||
// the legacy duplex pipe. Without this, the equivalence above could be
|
||||
// legacy-vs-legacy and prove nothing about codec reconstruction.
|
||||
// Keep degraded duplex markers as diagnostic evidence only: eager setup
|
||||
// may fall back before streaming when shard safety cannot be proven.
|
||||
sleep(Duration::from_millis(300)).await;
|
||||
let dup_codec_degraded = count_marker(&codec_log, DUPLEX_MARKER).saturating_sub(dup_codec_before_degraded);
|
||||
assert_eq!(
|
||||
dup_codec_degraded, 0,
|
||||
"codec phase created {dup_codec_degraded} duplex pipe(s) while reconstructing large objects with disk0 offline; the codec fast path was not exercised under degraded reads (see {codec_log})"
|
||||
);
|
||||
info!(dup_codec_degraded, "codec phase degraded-read legacy duplex marker count");
|
||||
|
||||
info!(
|
||||
objects = baseline.len(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,573 @@
|
||||
#![cfg(test)]
|
||||
// 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.
|
||||
|
||||
//! Cross-process replay / tamper acceptance for the internode NodeService v2 RPC
|
||||
//! signature (<https://github.com/rustfs/backlog/issues/1327>).
|
||||
//!
|
||||
//! # Why this exists on top of the in-process tests
|
||||
//!
|
||||
//! `http_auth.rs` unit-tests the signature algebra by calling the verifier
|
||||
//! directly. That proves the crypto, but it cannot prove that a *deployed*
|
||||
//! server actually reaches it: the request has to survive the hybrid HTTP/gRPC
|
||||
//! router, `check_auth`, tonic's own metadata handling, and finally the
|
||||
//! per-handler body-digest gate. A handler that forgets its
|
||||
//! `verify_disk_mutation_digest` call, or a router change that bypasses
|
||||
//! `check_auth`, is invisible in-process and wide open in production. These
|
||||
//! tests drive a real `rustfs` child process over a real TCP socket, so every
|
||||
//! one of those layers is in the path.
|
||||
//!
|
||||
//! # Attacker model
|
||||
//!
|
||||
//! The adversary is on-path: it observed one legitimately signed request and
|
||||
//! can resend, retarget, or edit those bytes — including individual headers.
|
||||
//! It does **not** hold the RPC secret. The test process does hold the secret,
|
||||
//! but uses it for exactly one purpose: minting the request that stands in for
|
||||
//! the captured one. Every attack then only *reuses or edits* an already-minted
|
||||
//! header set; no attack step ever re-signs. If any of these tests could pass
|
||||
//! by re-signing, it would be testing nothing.
|
||||
//!
|
||||
//! # Isolating one variable at a time
|
||||
//!
|
||||
//! Each rejection is paired with an acceptance that differs in exactly one
|
||||
//! respect, because a misconfigured harness (wrong audience, dead server,
|
||||
//! ambient strict env) would otherwise make every "rejected" assertion pass
|
||||
//! vacuously. Two pairings carry most of the weight:
|
||||
//!
|
||||
//! - Editing the body alone is caught by the *handler* (`PermissionDenied`);
|
||||
//! editing the body **and** repairing the digest header to match is caught by
|
||||
//! the *signature* (`Unauthenticated`). The second only fails closed if the
|
||||
//! digest is genuinely inside the signed scope, so the pair pins both layers.
|
||||
//! - Replaying a captured nonce is caught by the replay cache; swapping in a
|
||||
//! fresh nonce is caught by the signature. Again, only the pair proves the
|
||||
//! nonce is signed rather than merely cached.
|
||||
//!
|
||||
//! # Why `MakeVolume` against a non-existent disk
|
||||
//!
|
||||
//! Every covered handler checks the digest before touching storage, and
|
||||
//! `MakeVolume` resolves its disk *after* that check. Aiming at a disk that
|
||||
//! cannot exist gives three cleanly separable outcomes with zero side effects
|
||||
//! on the server's real data:
|
||||
//!
|
||||
//! - `Err(Unauthenticated)` — rejected by `check_auth` (signature layer).
|
||||
//! - `Err(PermissionDenied)` — rejected by the handler's body-digest gate.
|
||||
//! - `Ok(success: false)` — **authentication passed**; the request reached
|
||||
//! handler logic and only then failed on the bogus disk.
|
||||
//!
|
||||
//! # Coverage of the issue's acceptance matrix
|
||||
//!
|
||||
//! | Acceptance item | Test |
|
||||
//! |---|---|
|
||||
//! | replay a signature onto another method → reject | [`cross_method_signature_transplant_is_rejected`] |
|
||||
//! | replay same method + body after nonce consumed → reject | [`nonce_replay_of_a_captured_mutation_is_rejected`] |
|
||||
//! | nonce is signed, not just cached → reject a swapped nonce | [`swapping_in_a_fresh_nonce_is_rejected`] |
|
||||
//! | tamper one byte of the body → reject | [`tampered_mutation_body_is_rejected`] |
|
||||
//! | body digest is inside the signed scope → reject a repaired digest | [`rewriting_the_digest_to_match_a_tampered_body_is_rejected`] |
|
||||
//! | wrong destination node identity → reject | [`signature_minted_for_another_node_is_rejected`] |
|
||||
//! | mixed version: legacy-only still served, not blocked | [`legacy_only_signature_is_accepted_in_default_posture`] |
|
||||
//! | strict flip closes the signature downgrade | [`signature_strict_rejects_legacy_only_downgrade`] |
|
||||
//! | strict flip closes the body-digest downgrade, incl. v1 | [`body_digest_strict_rejects_digestless_mutation`] |
|
||||
//!
|
||||
//! Two acceptance items are deliberately left to the in-process tests. A stale
|
||||
//! timestamp cannot be forged from outside — it is inside the HMAC — so
|
||||
//! observing it would mean idling out the full freshness window. And the
|
||||
//! `signature_v1_fallback_total` / `body_digest_fallback_total` counter deltas
|
||||
//! that gate the strict flips are asserted directly in `http_auth.rs`; the
|
||||
//! legacy test below proves only the *accepted* half of that behaviour.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use crate::storage_api::internode_rpc_signature::{
|
||||
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers, node_service_time_out_client_no_auth,
|
||||
};
|
||||
use http::{HeaderMap, Method};
|
||||
use rustfs_config::{
|
||||
ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, ENV_INTERNODE_RPC_SIGNATURE_STRICT,
|
||||
};
|
||||
use rustfs_protos::canonical_make_volume_request_body;
|
||||
use rustfs_protos::proto_gen::node_service::{MakeVolumeRequest, MakeVolumeResponse};
|
||||
use serial_test::serial;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::error::Error;
|
||||
use tonic::{Code, Request, Status};
|
||||
use uuid::Uuid;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// Shared internode secret handed to both the child server and this process.
|
||||
///
|
||||
/// Must not be the default credential: `resolve_rpc_secret` fails closed on
|
||||
/// defaults (GHSA-r5qv), so a default here would break every request rather
|
||||
/// than test anything.
|
||||
const TEST_RPC_SECRET: &str = "rustfs-internode-signature-e2e-secret";
|
||||
|
||||
/// A disk path the server cannot possibly have configured, so a request that
|
||||
/// clears authentication stops harmlessly at `find_disk`.
|
||||
const ABSENT_DISK: &str = "/nonexistent/rustfs-signature-e2e-disk";
|
||||
|
||||
/// Wire names of the two v2 headers these tests edit. They are `pub(crate)` in
|
||||
/// ecstore, so they are repeated here rather than imported — [`overwrite_header`]
|
||||
/// asserts the header it replaces was actually present, which turns a rename
|
||||
/// into a loud failure instead of silently reducing an attack to a no-op.
|
||||
const CONTENT_SHA256_HEADER: &str = "x-rustfs-content-sha256";
|
||||
const NONCE_HEADER: &str = "x-rustfs-rpc-nonce";
|
||||
|
||||
/// gRPC service name carried in the signed scope, i.e. `TONIC_RPC_PREFIX`
|
||||
/// without its leading `/`.
|
||||
fn node_service_name() -> &'static str {
|
||||
TONIC_RPC_PREFIX.trim_start_matches('/')
|
||||
}
|
||||
|
||||
/// Make the RPC secret of this test process match the child server's.
|
||||
///
|
||||
/// The secret lands in a process-wide `OnceLock`, so the first writer wins for
|
||||
/// the whole test binary. Every test here uses the same constant, and the
|
||||
/// assertion turns a cross-test collision into an explicit failure instead of a
|
||||
/// confusing wall of signature rejections.
|
||||
fn align_rpc_secret_with_server() {
|
||||
let _ = rustfs_credentials::set_global_rpc_secret(TEST_RPC_SECRET.to_string());
|
||||
let effective = rustfs_credentials::try_get_rpc_token().expect("RPC secret must resolve in the test process");
|
||||
assert_eq!(
|
||||
effective, TEST_RPC_SECRET,
|
||||
"another test in this binary already fixed a different process-wide RPC secret; \
|
||||
the signature tests cannot mint requests the child server will accept"
|
||||
);
|
||||
}
|
||||
|
||||
/// Start a `rustfs` child process sharing [`TEST_RPC_SECRET`], with the rollout
|
||||
/// posture pinned explicitly.
|
||||
///
|
||||
/// The child inherits the ambient environment, so the strict gates and the
|
||||
/// replay-cache capacity are set here rather than assumed: a developer or CI
|
||||
/// runner exporting `RUSTFS_INTERNODE_RPC_*` would otherwise silently flip the
|
||||
/// posture and fail these tests for a non-security reason. `extra_env` is
|
||||
/// applied last so the strict tests can still override.
|
||||
///
|
||||
/// Uses the no-cleanup spawn so a `pkill` pattern cannot reap servers belonging
|
||||
/// to other tests running in the same binary.
|
||||
async fn start_server(extra_env: &[(&str, &str)]) -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let mut child_env = vec![
|
||||
("RUSTFS_RPC_SECRET", TEST_RPC_SECRET),
|
||||
(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "false"),
|
||||
(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "false"),
|
||||
(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "1048576"),
|
||||
];
|
||||
child_env.extend_from_slice(extra_env);
|
||||
env.start_rustfs_server_without_cleanup_with_env(&child_env).await?;
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
/// Stop the child and drop the cached gRPC channel for its address.
|
||||
///
|
||||
/// `node_service_time_out_client_no_auth` memoises channels in a process-global
|
||||
/// map keyed by URL, and ports handed out by `find_available_port` can recur
|
||||
/// within one test binary. Evicting here keeps a later test from inheriting a
|
||||
/// channel aimed at this test's dead server.
|
||||
async fn stop_server(mut env: RustFSTestEnvironment, url: &str) {
|
||||
env.stop_server();
|
||||
rustfs_protos::evict_failed_connection(url).await;
|
||||
}
|
||||
|
||||
/// The audience the server binds into the v2 signature: its own node authority.
|
||||
///
|
||||
/// A single-node server started with `--address 127.0.0.1:PORT` over filesystem
|
||||
/// endpoints has no URL peer set, so `init_local_peer` falls back to
|
||||
/// `host:port` — exactly the address we dialed. The positive controls below
|
||||
/// fail loudly if that ever stops holding.
|
||||
fn audience_of(env: &RustFSTestEnvironment) -> String {
|
||||
env.address.clone()
|
||||
}
|
||||
|
||||
fn hex_sha256(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().fold(String::new(), |mut acc, byte| {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(acc, "{byte:02x}");
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
fn make_volume_request(volume: &str) -> MakeVolumeRequest {
|
||||
MakeVolumeRequest {
|
||||
disk: ABSENT_DISK.to_string(),
|
||||
volume: volume.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_digest(request: &MakeVolumeRequest) -> String {
|
||||
hex_sha256(&canonical_make_volume_request_body(request).expect("canonical body must encode"))
|
||||
}
|
||||
|
||||
/// Mint a full v2 header set for `(audience, rpc_method, content_sha256)`.
|
||||
///
|
||||
/// This is the only place a signature is produced. Tests treat the returned map
|
||||
/// as an opaque captured artifact.
|
||||
fn mint_v2_headers(audience: &str, rpc_method: &str, content_sha256: Option<&str>) -> HeaderMap {
|
||||
gen_tonic_signature_headers(audience, node_service_name(), rpc_method, content_sha256)
|
||||
.expect("minting a v2 signature must succeed once the RPC secret is aligned")
|
||||
}
|
||||
|
||||
/// Mint the pre-v2 header set: a signature over the fixed
|
||||
/// `TONIC_RPC_PREFIX|GET|timestamp` constant, with no v2 headers at all. This is
|
||||
/// both what an un-upgraded peer sends and what an attacker sends to force a
|
||||
/// downgrade.
|
||||
fn mint_legacy_only_headers() -> HeaderMap {
|
||||
gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET).expect("minting a legacy signature must succeed")
|
||||
}
|
||||
|
||||
/// Replace one header of a captured set, asserting it was there to begin with.
|
||||
fn overwrite_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
|
||||
assert!(
|
||||
headers.contains_key(name),
|
||||
"minted headers must carry {name}; the wire contract changed and this attack would edit nothing"
|
||||
);
|
||||
headers.insert(name, value.parse().expect("header value must be valid"));
|
||||
}
|
||||
|
||||
/// Send `request` to the server's NodeService with exactly `headers` attached
|
||||
/// and nothing else — no interceptor adds or rewrites auth metadata, so the
|
||||
/// bytes on the wire are the ones the test chose.
|
||||
async fn call_make_volume(url: &str, request: MakeVolumeRequest, headers: HeaderMap) -> Result<MakeVolumeResponse, Status> {
|
||||
let mut client = node_service_time_out_client_no_auth(&url.to_string())
|
||||
.await
|
||||
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
||||
let mut rpc_request = Request::new(request);
|
||||
rpc_request.metadata_mut().as_mut().extend(headers);
|
||||
client.make_volume(rpc_request).await.map(|response| response.into_inner())
|
||||
}
|
||||
|
||||
/// Assert a call cleared authentication.
|
||||
///
|
||||
/// Receiving *any* `Ok` response is the load-bearing signal: both auth layers
|
||||
/// reject with a `Status`, so an `Ok` means the request reached handler logic.
|
||||
/// The failed disk lookup underneath is what keeps it side-effect free.
|
||||
fn assert_authenticated(result: Result<MakeVolumeResponse, Status>, context: &str) {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
assert!(
|
||||
!response.success,
|
||||
"{context}: the absent disk {ABSENT_DISK} must not yield a successful volume creation"
|
||||
);
|
||||
assert!(
|
||||
response.error.is_some(),
|
||||
"{context}: expected the request to reach disk lookup and fail there, got no error"
|
||||
);
|
||||
}
|
||||
Err(status) => panic!(
|
||||
"{context}: the request must clear authentication, but was rejected with {:?}: {}",
|
||||
status.code(),
|
||||
status.message()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert a call was rejected, optionally pinning which check spoke.
|
||||
///
|
||||
/// `PermissionDenied` responses carry the reason on the wire, so the digest
|
||||
/// tests pin it and cannot be satisfied by an unrelated digest-gate failure.
|
||||
/// `Unauthenticated` is deliberately generic on the wire; those tests pin their
|
||||
/// cause structurally instead, by differing from a passing request in exactly
|
||||
/// one respect.
|
||||
fn assert_rejected(result: Result<MakeVolumeResponse, Status>, expected: Code, expected_message: Option<&str>, context: &str) {
|
||||
match result {
|
||||
Ok(response) => panic!(
|
||||
"{context}: the request must be rejected, but the server accepted it and ran the handler \
|
||||
(success={}, error={:?})",
|
||||
response.success, response.error
|
||||
),
|
||||
Err(status) => {
|
||||
assert_eq!(
|
||||
status.code(),
|
||||
expected,
|
||||
"{context}: expected {expected:?}, got {:?}: {}",
|
||||
status.code(),
|
||||
status.message()
|
||||
);
|
||||
if let Some(needle) = expected_message {
|
||||
assert!(
|
||||
status.message().contains(needle),
|
||||
"{context}: expected the rejection to cite {needle:?}, got {:?}",
|
||||
status.message()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default posture (both strict gates off): the protections that hold without
|
||||
/// any operator flip.
|
||||
///
|
||||
/// Grouped into one server start because each case is independent and spawning
|
||||
/// a `rustfs` process per assertion would dominate the runtime.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn internode_rpc_signature_default_posture_e2e() -> TestResult {
|
||||
init_logging();
|
||||
align_rpc_secret_with_server();
|
||||
let env = start_server(&[]).await?;
|
||||
let url = env.url.clone();
|
||||
let audience = audience_of(&env);
|
||||
|
||||
signed_mutations_are_accepted(&url, &audience).await;
|
||||
unsigned_request_is_rejected(&url).await;
|
||||
cross_method_signature_transplant_is_rejected(&url, &audience).await;
|
||||
nonce_replay_of_a_captured_mutation_is_rejected(&url, &audience).await;
|
||||
swapping_in_a_fresh_nonce_is_rejected(&url, &audience).await;
|
||||
tampered_mutation_body_is_rejected(&url, &audience).await;
|
||||
rewriting_the_digest_to_match_a_tampered_body_is_rejected(&url, &audience).await;
|
||||
signature_minted_for_another_node_is_rejected(&url).await;
|
||||
legacy_only_signature_is_accepted_in_default_posture(&url).await;
|
||||
|
||||
stop_server(env, &url).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Baseline: correctly signed mutations are accepted, both with and without a
|
||||
/// body digest.
|
||||
///
|
||||
/// These anchor every rejection below. The body-bound case proves the audience
|
||||
/// the server verifies against really is the address we dialed. The digestless
|
||||
/// case is the control the transplant test needs: without it, a regression that
|
||||
/// rejected every `UNSIGNED-PAYLOAD` request would make the transplant
|
||||
/// assertion pass for entirely the wrong reason. It also documents that the
|
||||
/// default posture still serves digestless mutations.
|
||||
async fn signed_mutations_are_accepted(url: &str, audience: &str) {
|
||||
let bound = make_volume_request("signature-e2e-control-bound");
|
||||
let bound_headers = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&bound)));
|
||||
assert_authenticated(
|
||||
call_make_volume(url, bound, bound_headers).await,
|
||||
"a correctly signed body-bound mutation",
|
||||
);
|
||||
|
||||
let digestless = make_volume_request("signature-e2e-control-digestless");
|
||||
let digestless_headers = mint_v2_headers(audience, "MakeVolume", None);
|
||||
assert_authenticated(
|
||||
call_make_volume(url, digestless, digestless_headers).await,
|
||||
"a correctly signed digestless mutation in the default posture",
|
||||
);
|
||||
}
|
||||
|
||||
/// A request with no auth metadata at all must never reach a handler.
|
||||
async fn unsigned_request_is_rejected(url: &str) {
|
||||
let result = call_make_volume(url, make_volume_request("signature-e2e-unsigned"), HeaderMap::new()).await;
|
||||
assert_rejected(result, Code::Unauthenticated, None, "an entirely unsigned mutation");
|
||||
}
|
||||
|
||||
/// GHSA-c667 class: a signature captured from one gRPC method must not be
|
||||
/// replayable onto another.
|
||||
///
|
||||
/// Before method-path binding every NodeService call signed the same constant,
|
||||
/// so a captured `Ping` — the cheapest, least privileged call on the service —
|
||||
/// authenticated a `MakeVolume` just as well. The captured `Ping` signature is
|
||||
/// transplanted verbatim; the server recomputes the scope with
|
||||
/// `rpc_method = MakeVolume` and the HMAC no longer matches. It differs from the
|
||||
/// accepted digestless control above only in the method it was minted for.
|
||||
async fn cross_method_signature_transplant_is_rejected(url: &str, audience: &str) {
|
||||
let captured_ping = mint_v2_headers(audience, "Ping", None);
|
||||
let result = call_make_volume(url, make_volume_request("signature-e2e-transplant"), captured_ping).await;
|
||||
assert_rejected(
|
||||
result,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"a Ping signature transplanted onto a MakeVolume mutation",
|
||||
);
|
||||
}
|
||||
|
||||
/// A body-bound mutation must be consumable exactly once.
|
||||
///
|
||||
/// The first send establishes that the captured artifact is genuinely valid —
|
||||
/// without it, the second rejection could just mean the headers were malformed
|
||||
/// all along. The replay reuses the identical `(signature, timestamp, nonce)`
|
||||
/// well inside the freshness window, so only the server's replay cache can
|
||||
/// stop it.
|
||||
async fn nonce_replay_of_a_captured_mutation_is_rejected(url: &str, audience: &str) {
|
||||
let request = make_volume_request("signature-e2e-replay");
|
||||
let captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||
|
||||
let first = call_make_volume(url, request.clone(), captured.clone()).await;
|
||||
assert_authenticated(first, "the captured mutation on its first delivery");
|
||||
|
||||
let replayed = call_make_volume(url, request, captured).await;
|
||||
assert_rejected(
|
||||
replayed,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"the same captured mutation replayed after its nonce was consumed",
|
||||
);
|
||||
}
|
||||
|
||||
/// The nonce must be *signed*, not merely remembered.
|
||||
///
|
||||
/// A replay cache alone would be trivially defeated: swap in a fresh UUID and
|
||||
/// the cache has never seen it. This request is byte-identical to one the server
|
||||
/// would accept apart from that one header, so it can only be stopped by the
|
||||
/// nonce being inside the signed scope.
|
||||
async fn swapping_in_a_fresh_nonce_is_rejected(url: &str, audience: &str) {
|
||||
let request = make_volume_request("signature-e2e-nonce-swap");
|
||||
let mut captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||
overwrite_header(&mut captured, NONCE_HEADER, &Uuid::new_v4().to_string());
|
||||
|
||||
let result = call_make_volume(url, request, captured).await;
|
||||
assert_rejected(
|
||||
result,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"a captured mutation resent under a freshly minted nonce",
|
||||
);
|
||||
}
|
||||
|
||||
/// Editing the body of a captured request must invalidate it, in the default
|
||||
/// posture, with no operator flip required.
|
||||
///
|
||||
/// The headers are left byte-identical — including the signed digest of the
|
||||
/// original body — so `check_auth` still passes. Only the handler, recomputing
|
||||
/// the canonical body from the fields it actually received, can catch this. It
|
||||
/// is the test that fails if a handler ever loses its digest gate.
|
||||
async fn tampered_mutation_body_is_rejected(url: &str, audience: &str) {
|
||||
let signed = make_volume_request("signature-e2e-tamper-a");
|
||||
let captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&signed)));
|
||||
|
||||
// Exactly one byte of the volume name differs from what the digest covers.
|
||||
let tampered = make_volume_request("signature-e2e-tamper-b");
|
||||
let result = call_make_volume(url, tampered, captured).await;
|
||||
assert_rejected(
|
||||
result,
|
||||
Code::PermissionDenied,
|
||||
Some("RPC content SHA-256 mismatch"),
|
||||
"a mutation whose body was edited after signing",
|
||||
);
|
||||
}
|
||||
|
||||
/// The body digest must be *inside the signed scope*, not merely cross-checked
|
||||
/// by the handler.
|
||||
///
|
||||
/// This is the same tampered body as above, except the attacker also repairs the
|
||||
/// digest header so it matches what it sends — defeating the handler's
|
||||
/// comparison. The only thing left standing is the signature, which covers the
|
||||
/// digest header itself. Drop `content_sha256` from `update_signature_v2` and
|
||||
/// this is the test that goes green when it should not.
|
||||
async fn rewriting_the_digest_to_match_a_tampered_body_is_rejected(url: &str, audience: &str) {
|
||||
let signed = make_volume_request("signature-e2e-scope-a");
|
||||
let mut captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&signed)));
|
||||
|
||||
let tampered = make_volume_request("signature-e2e-scope-b");
|
||||
overwrite_header(&mut captured, CONTENT_SHA256_HEADER, &canonical_digest(&tampered));
|
||||
|
||||
let result = call_make_volume(url, tampered, captured).await;
|
||||
assert_rejected(
|
||||
result,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"a tampered mutation whose digest header was repaired to match",
|
||||
);
|
||||
}
|
||||
|
||||
/// A signature is bound to its destination node, so a request captured against
|
||||
/// one node cannot be aimed at another.
|
||||
///
|
||||
/// `127.0.0.1:1` stands in for a different peer; the audience is inside the
|
||||
/// HMAC, so the server's own authority no longer reproduces it.
|
||||
async fn signature_minted_for_another_node_is_rejected(url: &str) {
|
||||
let request = make_volume_request("signature-e2e-wrong-node");
|
||||
let headers = mint_v2_headers("127.0.0.1:1", "MakeVolume", Some(&canonical_digest(&request)));
|
||||
let result = call_make_volume(url, request, headers).await;
|
||||
assert_rejected(result, Code::Unauthenticated, None, "a signature minted for a different node");
|
||||
}
|
||||
|
||||
/// Rolling-upgrade compatibility: a peer that predates v2 must still be served
|
||||
/// while the strict gates are off.
|
||||
///
|
||||
/// This is the case the issue insists must not fail closed during an upgrade.
|
||||
/// It is also, honestly, the open downgrade window: an attacker can strip the
|
||||
/// v2 headers and land here too. That window is what
|
||||
/// [`signature_strict_rejects_legacy_only_downgrade`] closes.
|
||||
async fn legacy_only_signature_is_accepted_in_default_posture(url: &str) {
|
||||
let result = call_make_volume(url, make_volume_request("signature-e2e-legacy"), mint_legacy_only_headers()).await;
|
||||
assert_authenticated(result, "a legacy-only signature in the default posture");
|
||||
}
|
||||
|
||||
/// With `RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT` on, the legacy downgrade lane is
|
||||
/// closed: the exact request accepted in the default posture is now refused.
|
||||
///
|
||||
/// The paired v2 positive control rules out "strict simply breaks everything".
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn signature_strict_rejects_legacy_only_downgrade() -> TestResult {
|
||||
init_logging();
|
||||
align_rpc_secret_with_server();
|
||||
let env = start_server(&[(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "true")]).await?;
|
||||
let url = env.url.clone();
|
||||
let audience = audience_of(&env);
|
||||
|
||||
let downgraded = call_make_volume(&url, make_volume_request("signature-e2e-strict-legacy"), mint_legacy_only_headers()).await;
|
||||
assert_rejected(
|
||||
downgraded,
|
||||
Code::Unauthenticated,
|
||||
None,
|
||||
"a legacy-only signature once signature-strict is enabled",
|
||||
);
|
||||
|
||||
let request = make_volume_request("signature-e2e-strict-v2");
|
||||
let signed = mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||
assert_authenticated(
|
||||
call_make_volume(&url, request, signed).await,
|
||||
"a v2-signed mutation under signature-strict",
|
||||
);
|
||||
|
||||
stop_server(env, &url).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// With `RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT` on, any mutation that arrives
|
||||
/// without a body digest is refused — including one that downgraded all the way
|
||||
/// to the legacy signature.
|
||||
///
|
||||
/// This gate converges independently of the signature gate, so it is exercised
|
||||
/// on its own server with signature-strict left off. Both rejected requests
|
||||
/// clear `check_auth` on their own terms (one is properly v2-signed, the other
|
||||
/// takes the still-open legacy lane), which is what pins the rejection to the
|
||||
/// handler's digest gate; the cited message confirms which check spoke.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn body_digest_strict_rejects_digestless_mutation() -> TestResult {
|
||||
init_logging();
|
||||
align_rpc_secret_with_server();
|
||||
let env = start_server(&[(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "true")]).await?;
|
||||
let url = env.url.clone();
|
||||
let audience = audience_of(&env);
|
||||
|
||||
let digestless = mint_v2_headers(&audience, "MakeVolume", None);
|
||||
assert_rejected(
|
||||
call_make_volume(&url, make_volume_request("signature-e2e-digestless"), digestless).await,
|
||||
Code::PermissionDenied,
|
||||
Some("RPC mutation requires a body-bound v2 signature"),
|
||||
"a v2-signed but digestless mutation once body-digest-strict is enabled",
|
||||
);
|
||||
|
||||
assert_rejected(
|
||||
call_make_volume(&url, make_volume_request("signature-e2e-digestless-legacy"), mint_legacy_only_headers()).await,
|
||||
Code::PermissionDenied,
|
||||
Some("RPC mutation requires a body-bound v2 signature"),
|
||||
"a v1-downgraded mutation once body-digest-strict is enabled",
|
||||
);
|
||||
|
||||
let request = make_volume_request("signature-e2e-digest-bound");
|
||||
let bound = mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||
assert_authenticated(
|
||||
call_make_volume(&url, request, bound).await,
|
||||
"a body-bound mutation under body-digest-strict",
|
||||
);
|
||||
|
||||
stop_server(env, &url).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -29,6 +29,10 @@ use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::ServerSideEncryption;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use http::header::{CONTENT_TYPE, HOST};
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serde_json;
|
||||
use std::process::{Child, Command};
|
||||
use std::time::Duration;
|
||||
@@ -67,6 +71,49 @@ pub fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||
BASE64.encode(md5::compute(key).0)
|
||||
}
|
||||
|
||||
pub async fn kms_admin_request(
|
||||
base_url: &str,
|
||||
method: http::Method,
|
||||
path_and_query: &str,
|
||||
body: Option<&str>,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<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("KMS admin URL missing authority")?.to_string();
|
||||
let mut builder = http::Request::builder()
|
||||
.method(method.clone())
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||
if body.is_some() {
|
||||
builder = builder.header(CONTENT_TYPE, "application/json");
|
||||
}
|
||||
|
||||
let content_len = match body {
|
||||
Some(value) => i64::try_from(value.len())?,
|
||||
None => 0,
|
||||
};
|
||||
let signed = sign_v4(builder.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
|
||||
|
||||
let mut request = local_http_client().request(method.clone(), &url);
|
||||
for (name, value) in signed.headers() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
if let Some(value) = body {
|
||||
request = request.body(value.to_owned());
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
let status = response.status();
|
||||
let response_body = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("{method} {path_and_query} failed with {status}: {response_body}").into());
|
||||
}
|
||||
Ok(response_body)
|
||||
}
|
||||
|
||||
// KMS-specific helper functions
|
||||
/// Configure KMS backend via admin API
|
||||
pub async fn configure_kms(
|
||||
@@ -75,8 +122,19 @@ pub async fn configure_kms(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{base_url}/rustfs/admin/v3/kms/configure");
|
||||
awscurl_post(&url, config_json, access_key, secret_key).await?;
|
||||
let response = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/configure",
|
||||
Some(config_json),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||
if response["success"] != true {
|
||||
return Err(format!("KMS configuration failed: {}", response["message"].as_str().unwrap_or("unknown error")).into());
|
||||
}
|
||||
info!("KMS configured successfully");
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,8 +145,19 @@ pub async fn start_kms(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{base_url}/rustfs/admin/v3/kms/start");
|
||||
awscurl_post(&url, "{}", access_key, secret_key).await?;
|
||||
let response = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/start",
|
||||
Some("{}"),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||
if response["success"] != true {
|
||||
return Err(format!("KMS start failed: {}", response["message"].as_str().unwrap_or("unknown error")).into());
|
||||
}
|
||||
info!("KMS started successfully");
|
||||
Ok(())
|
||||
}
|
||||
@@ -99,8 +168,8 @@ pub async fn get_kms_status(
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{base_url}/rustfs/admin/v3/kms/status");
|
||||
let status = awscurl_get(&url, access_key, secret_key).await?;
|
||||
let status =
|
||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/status", None, access_key, secret_key).await?;
|
||||
info!("KMS status retrieved: {}", status);
|
||||
Ok(status)
|
||||
}
|
||||
@@ -508,7 +577,8 @@ impl VaultTestEnvironment {
|
||||
},
|
||||
"mount_path": VAULT_TRANSIT_PATH,
|
||||
"default_key_id": VAULT_KEY_NAME,
|
||||
"skip_tls_verify": true
|
||||
"skip_tls_verify": true,
|
||||
"allow_insecure_dev_defaults": true
|
||||
})
|
||||
.to_string();
|
||||
|
||||
@@ -657,14 +727,19 @@ pub async fn test_multipart_upload_with_config(
|
||||
.build();
|
||||
|
||||
info!("🔗 Completing multipart upload");
|
||||
let complete_output = s3_client
|
||||
let mut complete_request = s3_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&config.object_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(completed_multipart_upload)
|
||||
.send()
|
||||
.await?;
|
||||
.multipart_upload(completed_multipart_upload);
|
||||
if let EncryptionType::SSEC { .. } = &config.encryption_type {
|
||||
complete_request = complete_request
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(sse_c_key_b64.as_ref().unwrap())
|
||||
.sse_customer_key_md5(sse_c_key_md5.as_ref().unwrap());
|
||||
}
|
||||
let complete_output = complete_request.send().await?;
|
||||
|
||||
debug!("Multipart upload finalized with ETag {:?}", complete_output.e_tag());
|
||||
|
||||
@@ -796,7 +871,8 @@ impl LocalKMSTestEnvironment {
|
||||
"backend_type": "Local",
|
||||
"key_dir": self.kms_keys_dir,
|
||||
"file_permissions": 0o600,
|
||||
"default_key_id": default_key_id
|
||||
"default_key_id": default_key_id,
|
||||
"allow_insecure_dev_defaults": true
|
||||
})
|
||||
.to_string();
|
||||
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
// 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.
|
||||
|
||||
//! Configured-backend validation for the KMS admin and SSE-KMS round-trip
|
||||
//! contract tracked by rustfs/backlog#1378.
|
||||
|
||||
use super::common::{
|
||||
LocalKMSTestEnvironment, VAULT_KEY_NAME, VaultTestEnvironment, configure_kms, get_kms_status, kms_admin_request, start_kms,
|
||||
};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, ServerSideEncryption, VersioningConfiguration};
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
async fn assert_configured_status(
|
||||
base_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
expected_backend: &str,
|
||||
expected_default_key: &str,
|
||||
) -> TestResult {
|
||||
let body = get_kms_status(base_url, access_key, secret_key).await?;
|
||||
let status: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(status["backend_type"], expected_backend);
|
||||
assert_eq!(status["backend_status"], "healthy");
|
||||
assert_eq!(status["default_key_id"], expected_default_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_and_verify_key(
|
||||
base_url: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let create_body = serde_json::json!({
|
||||
"key_usage": "EncryptDecrypt",
|
||||
"description": "configured KMS round-trip e2e key",
|
||||
"tags": {
|
||||
"test": "backlog-1378"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let created = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/keys",
|
||||
Some(&create_body),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let created: serde_json::Value = serde_json::from_str(&created)?;
|
||||
assert_eq!(created["success"], true);
|
||||
let key_id = created["key_id"]
|
||||
.as_str()
|
||||
.ok_or("create KMS key response omitted key_id")?
|
||||
.to_string();
|
||||
|
||||
let described = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::GET,
|
||||
&format!("/rustfs/admin/v3/kms/keys/{key_id}"),
|
||||
None,
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let described: serde_json::Value = serde_json::from_str(&described)?;
|
||||
assert_eq!(described["success"], true);
|
||||
assert_eq!(described["key_metadata"]["key_id"], key_id);
|
||||
assert_eq!(described["key_metadata"]["key_state"], "Enabled");
|
||||
|
||||
let listed =
|
||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
|
||||
let listed: serde_json::Value = serde_json::from_str(&listed)?;
|
||||
let keys = listed["keys"].as_array().ok_or("list KMS keys response omitted keys")?;
|
||||
assert!(keys.iter().any(|key| key["key_id"] == key_id), "created KMS key must appear in list");
|
||||
Ok(key_id)
|
||||
}
|
||||
|
||||
async fn assert_key_deletion_lifecycle(base_url: &str, access_key: &str, secret_key: &str, key_id: &str) -> TestResult {
|
||||
let scheduled = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::DELETE,
|
||||
"/rustfs/admin/v3/kms/keys/delete",
|
||||
Some(
|
||||
&serde_json::json!({
|
||||
"key_id": key_id,
|
||||
"pending_window_in_days": 7,
|
||||
"force_immediate": false
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let scheduled: serde_json::Value = serde_json::from_str(&scheduled)?;
|
||||
assert_eq!(scheduled["success"], true);
|
||||
assert!(scheduled["deletion_date"].is_string());
|
||||
|
||||
let cancelled = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::POST,
|
||||
"/rustfs/admin/v3/kms/keys/cancel-deletion",
|
||||
Some(&serde_json::json!({ "key_id": key_id }).to_string()),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let cancelled: serde_json::Value = serde_json::from_str(&cancelled)?;
|
||||
assert_eq!(cancelled["success"], true);
|
||||
assert_eq!(cancelled["key_metadata"]["key_state"], "Enabled");
|
||||
|
||||
let removed = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::DELETE,
|
||||
"/rustfs/admin/v3/kms/keys/delete",
|
||||
Some(
|
||||
&serde_json::json!({
|
||||
"key_id": key_id,
|
||||
"force_immediate": true
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let removed: serde_json::Value = serde_json::from_str(&removed)?;
|
||||
assert_eq!(removed["success"], true);
|
||||
|
||||
let listed =
|
||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
|
||||
let listed: serde_json::Value = serde_json::from_str(&listed)?;
|
||||
assert_eq!(listed["success"], true);
|
||||
let keys = listed["keys"]
|
||||
.as_array()
|
||||
.ok_or("list KMS keys response omitted keys after deletion")?;
|
||||
if let Some(key) = keys.iter().find(|key| key["key_id"] == key_id) {
|
||||
assert_eq!(key["status"], "PendingDeletion", "a retained force-deleted key must be pending deletion");
|
||||
let removed = kms_admin_request(
|
||||
base_url,
|
||||
http::Method::DELETE,
|
||||
"/rustfs/admin/v3/kms/keys/delete",
|
||||
Some(
|
||||
&serde_json::json!({
|
||||
"key_id": key_id,
|
||||
"force_immediate": true
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
access_key,
|
||||
secret_key,
|
||||
)
|
||||
.await?;
|
||||
let removed: serde_json::Value = serde_json::from_str(&removed)?;
|
||||
assert_eq!(removed["success"], true);
|
||||
}
|
||||
|
||||
let listed =
|
||||
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/keys", None, access_key, secret_key).await?;
|
||||
let listed: serde_json::Value = serde_json::from_str(&listed)?;
|
||||
assert_eq!(listed["success"], true);
|
||||
let keys = listed["keys"]
|
||||
.as_array()
|
||||
.ok_or("final list KMS keys response omitted keys after deletion")?;
|
||||
assert!(
|
||||
keys.iter().all(|key| key["key_id"] != key_id),
|
||||
"force-deleted KMS key must no longer appear in list"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_versioned_sse_kms_roundtrip_and_cleanup(
|
||||
env: &crate::common::RustFSTestEnvironment,
|
||||
key_id: &str,
|
||||
bucket_prefix: &str,
|
||||
) -> TestResult {
|
||||
let client = env.create_s3_client();
|
||||
let bucket = format!("{bucket_prefix}-{}", Uuid::new_v4().simple());
|
||||
let object = format!("configured-kms-probe/{}/object", Uuid::new_v4().simple());
|
||||
let first_body = b"configured KMS version one";
|
||||
let second_body = b"configured KMS version two";
|
||||
|
||||
client.create_bucket().bucket(&bucket).send().await?;
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(&bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let first = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object)
|
||||
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||
.ssekms_key_id(key_id)
|
||||
.body(ByteStream::from_static(first_body))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(first.server_side_encryption(), Some(&ServerSideEncryption::AwsKms));
|
||||
assert_eq!(first.ssekms_key_id(), Some(key_id));
|
||||
let first_version = first.version_id().ok_or("first SSE-KMS PUT omitted version_id")?.to_string();
|
||||
|
||||
let second = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object)
|
||||
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||
.ssekms_key_id(key_id)
|
||||
.body(ByteStream::from_static(second_body))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(second.server_side_encryption(), Some(&ServerSideEncryption::AwsKms));
|
||||
assert_eq!(second.ssekms_key_id(), Some(key_id));
|
||||
let second_version = second
|
||||
.version_id()
|
||||
.ok_or("second SSE-KMS PUT omitted version_id")?
|
||||
.to_string();
|
||||
assert_ne!(first_version, second_version);
|
||||
let storage_root = std::path::Path::new(&env.temp_dir);
|
||||
super::encryption_metadata_test::assert_storage_encrypted(storage_root, &bucket, &object, first_body);
|
||||
super::encryption_metadata_test::assert_storage_encrypted(storage_root, &bucket, &object, second_body);
|
||||
|
||||
for (version_id, expected) in [
|
||||
(&first_version, first_body.as_slice()),
|
||||
(&second_version, second_body.as_slice()),
|
||||
] {
|
||||
let response = client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.server_side_encryption(), Some(&ServerSideEncryption::AwsKms));
|
||||
assert_eq!(response.ssekms_key_id(), Some(key_id));
|
||||
let actual = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
assert_eq!(actual.as_ref(), expected);
|
||||
}
|
||||
|
||||
let marker = client.delete_object().bucket(&bucket).key(&object).send().await?;
|
||||
assert_eq!(marker.delete_marker(), Some(true));
|
||||
assert!(marker.version_id().is_some(), "versioned delete must create a delete marker");
|
||||
|
||||
let before_cleanup = client.list_object_versions().bucket(&bucket).prefix(&object).send().await?;
|
||||
assert_eq!(
|
||||
before_cleanup
|
||||
.versions()
|
||||
.iter()
|
||||
.filter(|version| version.key() == Some(object.as_str()))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
before_cleanup
|
||||
.delete_markers()
|
||||
.iter()
|
||||
.filter(|delete_marker| delete_marker.key() == Some(object.as_str()))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(&bucket)
|
||||
.key(&object)
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("x-rustfs-force-delete", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let after_cleanup = client.list_object_versions().bucket(&bucket).prefix(&object).send().await?;
|
||||
assert!(
|
||||
after_cleanup
|
||||
.versions()
|
||||
.iter()
|
||||
.all(|version| version.key() != Some(object.as_str())),
|
||||
"force cleanup must remove every encrypted object version"
|
||||
);
|
||||
assert!(
|
||||
after_cleanup
|
||||
.delete_markers()
|
||||
.iter()
|
||||
.all(|delete_marker| delete_marker.key() != Some(object.as_str())),
|
||||
"force cleanup must remove the delete marker"
|
||||
);
|
||||
let head_error = match client.head_object().bucket(&bucket).key(&object).send().await {
|
||||
Ok(_) => return Err("force-cleaned probe object remained readable".into()),
|
||||
Err(error) => error,
|
||||
};
|
||||
let service_error = head_error
|
||||
.as_service_error()
|
||||
.ok_or_else(|| format!("force-cleaned HEAD failed with a non-service error: {head_error}"))?;
|
||||
assert!(
|
||||
service_error.is_not_found(),
|
||||
"force-cleaned HEAD returned the wrong service error: {service_error:?}"
|
||||
);
|
||||
|
||||
client.delete_bucket().bucket(&bucket).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_configured_local_kms_admin_and_versioned_cleanup() -> TestResult {
|
||||
let mut env = LocalKMSTestEnvironment::new().await?;
|
||||
env.base_env.start_rustfs_server(Vec::new()).await?;
|
||||
|
||||
let start_error = match start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await {
|
||||
Ok(()) => return Err("unconfigured KMS start unexpectedly succeeded".into()),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
start_error.to_string().contains("no configuration provided"),
|
||||
"unconfigured KMS start returned the wrong business error: {start_error}"
|
||||
);
|
||||
|
||||
let insecure_config = serde_json::json!({
|
||||
"backend_type": "Local",
|
||||
"key_dir": env.kms_keys_dir,
|
||||
"file_permissions": 0o600,
|
||||
"default_key_id": "rustfs-e2e-test-default-key"
|
||||
})
|
||||
.to_string();
|
||||
let configure_error =
|
||||
match configure_kms(&env.base_env.url, &insecure_config, &env.base_env.access_key, &env.base_env.secret_key).await {
|
||||
Ok(()) => return Err("insecure Local KMS configuration unexpectedly succeeded".into()),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(
|
||||
configure_error.to_string().contains("requires a master key"),
|
||||
"invalid Local KMS configuration returned the wrong business error: {configure_error}"
|
||||
);
|
||||
|
||||
let default_key_id = env.configure_local_kms().await?;
|
||||
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
assert_configured_status(
|
||||
&env.base_env.url,
|
||||
&env.base_env.access_key,
|
||||
&env.base_env.secret_key,
|
||||
"local",
|
||||
&default_key_id,
|
||||
)
|
||||
.await?;
|
||||
let key_id = create_and_verify_key(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
assert_versioned_sse_kms_roundtrip_and_cleanup(&env.base_env, &key_id, "kms-local-configured").await?;
|
||||
assert_key_deletion_lifecycle(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key, &key_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
#[ignore = "requires a Vault binary"]
|
||||
async fn test_configured_vault_kms_admin_and_versioned_cleanup() -> TestResult {
|
||||
let mut env = VaultTestEnvironment::new().await?;
|
||||
env.start_vault().await?;
|
||||
env.setup_vault_transit().await?;
|
||||
env.start_rustfs_for_vault().await?;
|
||||
env.configure_vault_transit_kms().await?;
|
||||
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
assert_configured_status(
|
||||
&env.base_env.url,
|
||||
&env.base_env.access_key,
|
||||
&env.base_env.secret_key,
|
||||
"vault-transit",
|
||||
VAULT_KEY_NAME,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let key_id = create_and_verify_key(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
|
||||
assert_ne!(key_id, VAULT_KEY_NAME, "key lifecycle test must create a distinct Vault key");
|
||||
assert_versioned_sse_kms_roundtrip_and_cleanup(&env.base_env, &key_id, "kms-vault-configured").await?;
|
||||
assert_key_deletion_lifecycle(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key, &key_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -42,7 +42,7 @@ fn assert_managed_encryption_metadata_hidden(metadata: Option<&HashMap<String, S
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_storage_encrypted(storage_root: &std::path::Path, bucket: &str, key: &str, plaintext: &[u8]) {
|
||||
pub(super) fn assert_storage_encrypted(storage_root: &std::path::Path, bucket: &str, key: &str, plaintext: &[u8]) {
|
||||
let mut stack = VecDeque::from([storage_root.to_path_buf()]);
|
||||
let mut scanned = 0;
|
||||
let mut plaintext_path: Option<std::path::PathBuf> = None;
|
||||
|
||||
@@ -625,6 +625,9 @@ async fn test_multipart_upload_with_sse_c(
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.upload_id(upload_id)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&key_b64)
|
||||
.sse_customer_key_md5(&key_md5)
|
||||
.multipart_upload(completed_multipart_upload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -50,3 +50,6 @@ mod encryption_metadata_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_version_restore_sse_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod configured_roundtrip_test;
|
||||
|
||||
@@ -566,14 +566,19 @@ async fn test_multipart_encryption_type(
|
||||
.set_parts(Some(completed_parts))
|
||||
.build();
|
||||
|
||||
let _complete_output = s3_client
|
||||
let mut complete_request = s3_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(completed_multipart_upload)
|
||||
.send()
|
||||
.await?;
|
||||
.multipart_upload(completed_multipart_upload);
|
||||
if matches!(encryption_type, EncryptionType::SSEC) {
|
||||
complete_request = complete_request
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(sse_c_key.as_ref().unwrap())
|
||||
.sse_customer_key_md5(sse_c_md5.as_ref().unwrap());
|
||||
}
|
||||
let _complete_output = complete_request.send().await?;
|
||||
|
||||
// Download and verify
|
||||
let mut get_request = s3_client.get_object().bucket(bucket).key(object_key);
|
||||
|
||||
@@ -79,6 +79,12 @@ mod bucket_policy_check_test;
|
||||
#[cfg(test)]
|
||||
mod security_boundary_test;
|
||||
|
||||
// Cross-process replay/tamper acceptance for the internode NodeService v2 RPC
|
||||
// signature (backlog#1327): method-path transplant, nonce replay, body tampering
|
||||
// and the two strict rollout flips, all against a real spawned server.
|
||||
#[cfg(test)]
|
||||
mod internode_rpc_signature_e2e_test;
|
||||
|
||||
// Opt-in per-client S3 API rate limiting (backlog#1191)
|
||||
#[cfg(test)]
|
||||
mod api_rate_limit_test;
|
||||
@@ -95,6 +101,9 @@ mod admin_auth_test;
|
||||
#[cfg(test)]
|
||||
mod existing_object_tag_policy_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod sts_query_compat_test;
|
||||
|
||||
// Regression tests for Issue #2036: anonymous access with PublicAccessBlock
|
||||
#[cfg(test)]
|
||||
mod anonymous_access_test;
|
||||
@@ -167,6 +176,10 @@ mod cluster_concurrency_test;
|
||||
#[cfg(test)]
|
||||
mod cluster_multidrive_pool_test;
|
||||
|
||||
// backlog#1433: real 4-node EC boundary gate for inline storage and GET paths.
|
||||
#[cfg(test)]
|
||||
mod inline_fast_path_cluster_test;
|
||||
|
||||
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
|
||||
#[cfg(test)]
|
||||
mod checksum_upload_test;
|
||||
@@ -187,12 +200,24 @@ mod heal_erasure_disk_rebuild_test;
|
||||
#[cfg(test)]
|
||||
mod copy_object_metadata_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_tagging_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_version_restore_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod copy_object_checksum_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod ssec_copy_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod multipart_storage_class_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod storage_class_capability_test;
|
||||
|
||||
// S3 dummy-compat bucket API tests
|
||||
#[cfg(test)]
|
||||
mod bucket_logging_test;
|
||||
|
||||
@@ -1267,7 +1267,7 @@ async fn test_anonymous_post_object_accepts_storage_class_exact_policy_match()
|
||||
let bucket = "anon-post-storage-class";
|
||||
let object_key = "post-storage-class-object.txt";
|
||||
let expected_body = b"post-storage-class-body".to_vec();
|
||||
let storage_class = "STANDARD_IA";
|
||||
let storage_class = "REDUCED_REDUNDANCY";
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
@@ -5138,7 +5138,7 @@ async fn test_signed_put_object_extract_preserves_storage_class() -> Result<(),
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
.storage_class(aws_sdk_s3::types::StorageClass::StandardIa)
|
||||
.storage_class(aws_sdk_s3::types::StorageClass::ReducedRedundancy)
|
||||
.body(ByteStream::from(tar_bytes))
|
||||
.customize()
|
||||
.mutate_request(move |req| {
|
||||
@@ -5155,7 +5155,7 @@ async fn test_signed_put_object_extract_preserves_storage_class() -> Result<(),
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert_eq!(head.storage_class().map(|value| value.as_str()), Some("STANDARD_IA"));
|
||||
assert_eq!(head.storage_class().map(|value| value.as_str()), Some("REDUCED_REDUNDANCY"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
// 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.
|
||||
|
||||
//! CreateMultipartUpload storage-class persistence regression tests.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, StorageClass};
|
||||
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
|
||||
async fn assert_completed_object(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
expected_storage_class: &str,
|
||||
expected_body: &[u8],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
let expected_head_class = (expected_storage_class != "STANDARD").then_some(expected_storage_class);
|
||||
assert_eq!(
|
||||
head.storage_class().map(StorageClass::as_str),
|
||||
expected_head_class,
|
||||
"HeadObject should use S3's implicit STANDARD representation"
|
||||
);
|
||||
|
||||
let listed = client.list_objects_v2().bucket(bucket).prefix(key).send().await?;
|
||||
let object = listed
|
||||
.contents()
|
||||
.iter()
|
||||
.find(|object| object.key() == Some(key))
|
||||
.ok_or("completed multipart object missing from ListObjectsV2")?;
|
||||
assert_eq!(
|
||||
object.storage_class().map(|storage_class| storage_class.as_str()),
|
||||
Some(expected_storage_class),
|
||||
"ListObjectsV2 should report the completed object's storage class"
|
||||
);
|
||||
|
||||
let body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(body.as_ref(), expected_body, "completed multipart body should be byte-exact");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_upload_preserves_standard_and_rrs_across_retry_and_resume()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "multipart-storage-class-retry";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] {
|
||||
let class_name = storage_class.as_str();
|
||||
let key = format!("retry-{class_name}.bin");
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.storage_class(storage_class.clone())
|
||||
.content_type("application/octet-stream")
|
||||
.metadata("content-type", "user-content-type")
|
||||
.metadata("x-amz-storage-class", "user-storage-class")
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
|
||||
let original_part = vec![b'a'; PART_SIZE];
|
||||
let first_attempt = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(original_part))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let resumed_client = env.create_s3_client();
|
||||
let before_retry = resumed_client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(before_retry.storage_class().map(StorageClass::as_str), Some(class_name));
|
||||
assert_eq!(before_retry.parts().len(), 1, "resume should find the previously uploaded part");
|
||||
|
||||
let retried_part = vec![b'b'; PART_SIZE];
|
||||
let retry = resumed_client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(retried_part.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
assert_ne!(
|
||||
first_attempt.e_tag(),
|
||||
retry.e_tag(),
|
||||
"retrying the same part number with different bytes should replace the part"
|
||||
);
|
||||
|
||||
let tail = format!("-tail-{class_name}").into_bytes();
|
||||
let second = resumed_client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(2)
|
||||
.body(ByteStream::from(tail.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let after_retry = resumed_client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(after_retry.storage_class().map(StorageClass::as_str), Some(class_name));
|
||||
assert_eq!(after_retry.parts().len(), 2);
|
||||
assert_eq!(after_retry.parts()[0].e_tag(), retry.e_tag());
|
||||
|
||||
resumed_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.set_e_tag(retry.e_tag().map(str::to_owned))
|
||||
.build(),
|
||||
)
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(2)
|
||||
.set_e_tag(second.e_tag().map(str::to_owned))
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let mut expected_body = retried_part;
|
||||
expected_body.extend_from_slice(&tail);
|
||||
assert_completed_object(&resumed_client, bucket, &key, class_name, &expected_body).await?;
|
||||
let metadata_head = resumed_client.head_object().bucket(bucket).key(&key).send().await?;
|
||||
assert_eq!(metadata_head.content_type(), Some("application/octet-stream"));
|
||||
assert_eq!(
|
||||
metadata_head.metadata().and_then(|metadata| metadata.get("content-type")),
|
||||
Some(&"user-content-type".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
metadata_head
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.get("x-amz-storage-class")),
|
||||
Some(&"user-storage-class".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_copy_preserves_standard_and_rrs() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "multipart-storage-class-copy";
|
||||
let source_key = "source.bin";
|
||||
let source_body = vec![b'c'; 1024 * 1024];
|
||||
env.create_test_bucket(bucket).await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source_key)
|
||||
.body(ByteStream::from(source_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] {
|
||||
let class_name = storage_class.as_str();
|
||||
let key = format!("copy-{class_name}.bin");
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.storage_class(storage_class.clone())
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
let copied = client
|
||||
.upload_part_copy()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.copy_source(format!("{bucket}/{source_key}"))
|
||||
.send()
|
||||
.await?;
|
||||
let e_tag = copied
|
||||
.copy_part_result()
|
||||
.and_then(|result| result.e_tag())
|
||||
.ok_or("UploadPartCopy returned no ETag")?;
|
||||
|
||||
let parts = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(parts.storage_class().map(StorageClass::as_str), Some(class_name));
|
||||
assert_eq!(parts.parts().len(), 1);
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(CompletedPart::builder().part_number(1).e_tag(e_tag).build())
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_completed_object(&client, bucket, &key, class_name, &source_body).await?;
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_and_aborted_uploads_leave_no_session_or_object() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "multipart-storage-class-errors";
|
||||
let invalid_key = "invalid.bin";
|
||||
let aborted_key = "aborted.bin";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
let invalid = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(invalid_key)
|
||||
.storage_class(StorageClass::from("INVALID"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("invalid storage class should be rejected");
|
||||
assert_eq!(
|
||||
invalid.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidStorageClass")
|
||||
);
|
||||
let after_invalid = client
|
||||
.list_multipart_uploads()
|
||||
.bucket(bucket)
|
||||
.prefix(invalid_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
after_invalid.uploads().is_empty(),
|
||||
"validation failure must not create a multipart session"
|
||||
);
|
||||
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.storage_class(StorageClass::ReducedRedundancy)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from_static(b"aborted multipart part"))
|
||||
.send()
|
||||
.await?;
|
||||
let before_abort = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(before_abort.storage_class().map(StorageClass::as_str), Some("REDUCED_REDUNDANCY"));
|
||||
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
let after_abort = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("aborted upload should not be resumable");
|
||||
assert_eq!(after_abort.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchUpload"));
|
||||
let remaining_uploads = client
|
||||
.list_multipart_uploads()
|
||||
.bucket(bucket)
|
||||
.prefix(aborted_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(remaining_uploads.uploads().is_empty(), "abort should remove the multipart session");
|
||||
let aborted_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(aborted_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("aborted upload should not create an object");
|
||||
assert_eq!(aborted_head.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ use local_ip_address::local_ip;
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||
use s3s::Body;
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
@@ -70,6 +71,11 @@ fn target_arn(target_name: &str) -> String {
|
||||
format!("arn:rustfs:sqs:{NOTIFY_REGION}:{target_name}:webhook")
|
||||
}
|
||||
|
||||
fn endpoint_origin(endpoint: &str) -> Result<String, BoxError> {
|
||||
let parsed = reqwest::Url::parse(endpoint)?;
|
||||
Ok(parsed.origin().ascii_serialization())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-test HTTP event receiver
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -153,10 +159,46 @@ async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Valu
|
||||
let endpoint_ip = local_ip()?;
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let handle = serve_event_collector(listener, tx);
|
||||
Ok((format!("http://{endpoint_ip}.nip.io:{port}/events"), rx, handle))
|
||||
Ok((format!("http://{}/events", std::net::SocketAddr::new(endpoint_ip, port)), rx, handle))
|
||||
}
|
||||
|
||||
fn spawn_https_event_collector(ca_path: &Path) -> Result<(String, Arc<AtomicBool>, thread::JoinHandle<()>), BoxError> {
|
||||
struct HttpsEventCollector {
|
||||
endpoint: String,
|
||||
running: Arc<AtomicBool>,
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
events: mpsc::UnboundedReceiver<Value>,
|
||||
}
|
||||
|
||||
impl HttpsEventCollector {
|
||||
fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
fn events_mut(&mut self) -> &mut mpsc::UnboundedReceiver<Value> {
|
||||
&mut self.events
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) -> TestResult {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
if let Ok(parsed) = self.endpoint.parse::<reqwest::Url>()
|
||||
&& let Some(port) = parsed.port()
|
||||
{
|
||||
let _ = std::net::TcpStream::connect(("127.0.0.1", port));
|
||||
}
|
||||
if let Some(handle) = self.handle.take() {
|
||||
handle.join().map_err(|_| "https event collector thread panicked")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HttpsEventCollector {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_https_event_collector(ca_path: &Path) -> Result<HttpsEventCollector, BoxError> {
|
||||
use rustls::{
|
||||
ServerConfig,
|
||||
pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},
|
||||
@@ -173,9 +215,9 @@ fn spawn_https_event_collector(ca_path: &Path) -> Result<(String, Arc<AtomicBool
|
||||
listener.set_nonblocking(true)?;
|
||||
let addr = listener.local_addr()?;
|
||||
let endpoint_ip = local_ip()?;
|
||||
let endpoint_host = format!("{endpoint_ip}.nip.io");
|
||||
let endpoint_host = endpoint_ip.to_string();
|
||||
|
||||
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![endpoint_host.clone()])?;
|
||||
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![endpoint_host])?;
|
||||
std::fs::write(ca_path, cert.pem())?;
|
||||
|
||||
let cert_chain = vec![cert.der().clone()];
|
||||
@@ -188,48 +230,103 @@ fn spawn_https_event_collector(ca_path: &Path) -> Result<(String, Arc<AtomicBool
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let server_running = Arc::clone(&running);
|
||||
let (tx, events) = mpsc::unbounded_channel();
|
||||
let handle = thread::spawn(move || {
|
||||
let mut connections = Vec::new();
|
||||
while server_running.load(Ordering::Relaxed) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
let config = Arc::clone(&server_config);
|
||||
handle_https_probe(stream, config);
|
||||
let tx = tx.clone();
|
||||
connections.push(thread::spawn(move || {
|
||||
let _ = handle_https_request(stream, config, tx);
|
||||
}));
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(20)),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
for connection in connections {
|
||||
let _ = connection.join();
|
||||
}
|
||||
});
|
||||
|
||||
Ok((format!("https://{endpoint_host}:{}/events", addr.port()), running, handle))
|
||||
Ok(HttpsEventCollector {
|
||||
endpoint: format!("https://{}/events", std::net::SocketAddr::new(endpoint_ip, addr.port())),
|
||||
running,
|
||||
handle: Some(handle),
|
||||
events,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_https_probe(stream: std::net::TcpStream, server_config: Arc<rustls::ServerConfig>) {
|
||||
use std::io::{Read, Write};
|
||||
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
|
||||
let Ok(connection) = rustls::ServerConnection::new(server_config) else {
|
||||
return;
|
||||
fn read_sync_http_message<R: std::io::Read>(stream: &mut R) -> Result<(String, Vec<u8>), BoxError> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 4096];
|
||||
let header_end = loop {
|
||||
let read = stream.read(&mut chunk)?;
|
||||
if read == 0 {
|
||||
return Err("connection closed before request headers were complete".into());
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if let Some(pos) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
break pos;
|
||||
}
|
||||
};
|
||||
let mut tls_stream = rustls::StreamOwned::new(connection, stream);
|
||||
let mut buf = [0u8; 1024];
|
||||
if tls_stream.read(&mut buf).is_err() {
|
||||
return;
|
||||
|
||||
let header_text = std::str::from_utf8(&buffer[..header_end])?;
|
||||
let mut lines = header_text.split("\r\n");
|
||||
let method = lines
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().next())
|
||||
.ok_or("missing request method")?
|
||||
.to_string();
|
||||
let mut content_length = 0usize;
|
||||
for line in lines {
|
||||
if let Some((name, value)) = line.split_once(':')
|
||||
&& name.trim().eq_ignore_ascii_case("content-length")
|
||||
{
|
||||
content_length = value.trim().parse()?;
|
||||
}
|
||||
}
|
||||
let response = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
|
||||
let _ = tls_stream.write_all(response.as_bytes());
|
||||
let _ = tls_stream.flush();
|
||||
|
||||
let body_offset = header_end + 4;
|
||||
while buffer.len().saturating_sub(body_offset) < content_length {
|
||||
let read = stream.read(&mut chunk)?;
|
||||
if read == 0 {
|
||||
return Err("connection closed before request body was complete".into());
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
Ok((method, buffer[body_offset..body_offset + content_length].to_vec()))
|
||||
}
|
||||
|
||||
fn stop_https_event_collector(endpoint: &str, running: Arc<AtomicBool>, handle: thread::JoinHandle<()>) -> TestResult {
|
||||
running.store(false, Ordering::Relaxed);
|
||||
if let Ok(parsed) = endpoint.parse::<reqwest::Url>()
|
||||
&& let Some(port) = parsed.port()
|
||||
{
|
||||
let _ = std::net::TcpStream::connect(("127.0.0.1", port));
|
||||
fn handle_https_request(
|
||||
stream: std::net::TcpStream,
|
||||
server_config: Arc<rustls::ServerConfig>,
|
||||
tx: mpsc::UnboundedSender<Value>,
|
||||
) -> Result<(), BoxError> {
|
||||
use std::io::Write;
|
||||
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
|
||||
let connection = rustls::ServerConnection::new(server_config)?;
|
||||
let mut tls_stream = rustls::StreamOwned::new(connection, stream);
|
||||
let (method, body) = read_sync_http_message(&mut tls_stream)?;
|
||||
let response = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
|
||||
tls_stream.write_all(response.as_bytes())?;
|
||||
tls_stream.flush()?;
|
||||
tls_stream.conn.send_close_notify();
|
||||
while tls_stream.conn.wants_write() {
|
||||
tls_stream.conn.write_tls(&mut tls_stream.sock)?;
|
||||
}
|
||||
let _ = tls_stream.sock.shutdown(std::net::Shutdown::Write);
|
||||
if method == "POST"
|
||||
&& !body.is_empty()
|
||||
&& let Ok(event) = serde_json::from_slice(&body)
|
||||
{
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
handle.join().map_err(|_| "https event collector thread panicked")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -415,17 +512,20 @@ async fn wait_for_target_registered(env: &RustFSTestEnvironment, target_name: &s
|
||||
Err(format!("target {target_name} was not registered in admin ARNs").into())
|
||||
}
|
||||
|
||||
async fn wait_for_target_listed(env: &RustFSTestEnvironment, target_name: &str) -> TestResult {
|
||||
async fn wait_for_target_online(env: &RustFSTestEnvironment, target_name: &str) -> TestResult {
|
||||
let url = format!("{}/rustfs/admin/v3/target/list", env.url);
|
||||
for _ in 0..40 {
|
||||
let response = signed_admin_request(env, http::Method::GET, &url, None).await?;
|
||||
if response.status() == StatusCode::OK {
|
||||
let body: Value = serde_json::from_slice(&response.bytes().await?)?;
|
||||
if body["notify_enabled"].as_bool() != Some(true) {
|
||||
return Err(format!("admin target list did not report notify_enabled=true: {body}").into());
|
||||
}
|
||||
let listed = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
|
||||
endpoints.iter().any(|endpoint| {
|
||||
endpoint["account_id"].as_str() == Some(target_name)
|
||||
&& endpoint["service"].as_str() == Some("webhook")
|
||||
&& endpoint["status"].as_str().is_some()
|
||||
&& endpoint["status"].as_str() == Some("online")
|
||||
})
|
||||
});
|
||||
if listed {
|
||||
@@ -434,7 +534,7 @@ async fn wait_for_target_listed(env: &RustFSTestEnvironment, target_name: &str)
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
Err(format!("target {target_name} was not listed in admin targets").into())
|
||||
Err(format!("target {target_name} did not become online in admin targets").into())
|
||||
}
|
||||
|
||||
/// Binds a bucket to a webhook target for ObjectCreated:*/ObjectRemoved:* events,
|
||||
@@ -484,34 +584,58 @@ fn trimmed_etag(value: Option<&str>) -> Option<String> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Regression for rustfs#5052: with the notify module enabled through
|
||||
/// RUSTFS_NOTIFY_ENABLE, an HTTPS webhook using a configured CA must be accepted
|
||||
/// and remain visible in the admin target list.
|
||||
/// RUSTFS_NOTIFY_ENABLE, an HTTPS webhook using a configured CA must become
|
||||
/// online and receive a real S3 event POST.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_https_webhook_target_lists_with_notify_env_enabled() -> TestResult {
|
||||
async fn test_https_webhook_target_delivers_event_with_notify_env_enabled() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_NOTIFY_ENABLE", "true")])
|
||||
.await?;
|
||||
|
||||
let ca_path = Path::new(&env.temp_dir).join("https-webhook-ca.pem");
|
||||
let (endpoint, running, handle) = spawn_https_event_collector(&ca_path)?;
|
||||
let mut collector = spawn_https_event_collector(&ca_path)?;
|
||||
let allowed_origin = endpoint_origin(collector.endpoint())?;
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_NOTIFY_ENABLE", "true"),
|
||||
(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let target = "peri1https";
|
||||
let bucket = "peri1-https-events";
|
||||
let key = "uploads/https.dat";
|
||||
let client = env.create_s3_client();
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
configure_webhook_target_with_key_values(
|
||||
&env,
|
||||
target,
|
||||
vec![
|
||||
("endpoint", endpoint.clone()),
|
||||
("endpoint", collector.endpoint().to_string()),
|
||||
("client_ca", ca_path.to_string_lossy().into_owned()),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
wait_for_target_listed(&env, target).await?;
|
||||
wait_for_target_online(&env, target).await?;
|
||||
wait_for_target_registered(&env, target).await?;
|
||||
put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"https webhook event body"))
|
||||
.send()
|
||||
.await?;
|
||||
let event = wait_for_event(collector.events_mut(), key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;
|
||||
assert_eq!(event["EventName"].as_str(), Some("s3:ObjectCreated:Put"));
|
||||
assert_eq!(event["Records"][0]["s3"]["bucket"]["name"].as_str(), Some(bucket));
|
||||
assert_eq!(event_key(&event).as_deref(), Some(key));
|
||||
|
||||
env.stop_server();
|
||||
stop_https_event_collector(&endpoint, running, handle)?;
|
||||
collector.shutdown()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -523,9 +647,11 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let (endpoint, mut rx, handle) = spawn_event_collector().await?;
|
||||
let allowed_origin = endpoint_origin(&endpoint)?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())])
|
||||
.await?;
|
||||
enable_notify_module(&env).await?;
|
||||
|
||||
let bucket = "peri1-events";
|
||||
@@ -677,15 +803,6 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
||||
async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
enable_notify_module(&env).await?;
|
||||
|
||||
let bucket = "peri1-redeliver";
|
||||
let target = "peri1redeliver";
|
||||
let client = env.create_s3_client();
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
// Configure the target while its endpoint is reachable so activation and
|
||||
// ARN registration complete deterministically. Registering against a dead
|
||||
// endpoint stalls behind the reachability probe's timeout and flakes the
|
||||
@@ -694,10 +811,21 @@ async fn test_webhook_redelivers_event_after_target_recovers() -> TestResult {
|
||||
let listener = TcpListener::bind("0.0.0.0:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let endpoint_ip = local_ip()?;
|
||||
let endpoint = format!("http://{endpoint_ip}.nip.io:{port}/events");
|
||||
let endpoint = format!("http://{}/events", std::net::SocketAddr::new(endpoint_ip, port));
|
||||
let allowed_origin = endpoint_origin(&endpoint)?;
|
||||
let (setup_tx, _setup_rx) = mpsc::unbounded_channel();
|
||||
let setup_handle = serve_event_collector(listener, setup_tx);
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())])
|
||||
.await?;
|
||||
enable_notify_module(&env).await?;
|
||||
|
||||
let bucket = "peri1-redeliver";
|
||||
let target = "peri1redeliver";
|
||||
let client = env.create_s3_client();
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
configure_webhook_target(&env, target, &endpoint).await?;
|
||||
wait_for_target_registered(&env, target).await?;
|
||||
put_notification_config(&client, bucket, target, "uploads/", ".dat").await?;
|
||||
|
||||
@@ -18,6 +18,7 @@ use http::header::{CONTENT_TYPE, HOST};
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::{pre_sign_v4, sign_v4};
|
||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||
use s3s::Body;
|
||||
use serial_test::serial;
|
||||
use std::collections::HashMap;
|
||||
@@ -49,7 +50,7 @@ fn find_header_terminator(buf: &[u8]) -> Option<usize> {
|
||||
|
||||
async fn read_http_request(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
) -> Result<(HashMap<String, String>, Vec<u8>), Box<dyn Error + Send + Sync>> {
|
||||
) -> Result<(String, HashMap<String, String>, Vec<u8>), Box<dyn Error + Send + Sync>> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 4096];
|
||||
|
||||
@@ -67,7 +68,7 @@ async fn read_http_request(
|
||||
let header_bytes = &buffer[..header_end];
|
||||
let header_text = std::str::from_utf8(header_bytes)?;
|
||||
let mut lines = header_text.split("\r\n");
|
||||
let _request_line = lines.next().ok_or("missing request line")?;
|
||||
let request_line = lines.next().ok_or("missing request line")?.to_string();
|
||||
let mut headers = HashMap::new();
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
@@ -79,8 +80,9 @@ async fn read_http_request(
|
||||
|
||||
let content_length = headers
|
||||
.get("content-length")
|
||||
.ok_or("missing content-length header")?
|
||||
.parse::<usize>()?;
|
||||
.map(|value| value.parse::<usize>())
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let body_offset = header_end + 4;
|
||||
while buffer.len().saturating_sub(body_offset) < content_length {
|
||||
let read = stream.read(&mut chunk).await?;
|
||||
@@ -90,7 +92,7 @@ async fn read_http_request(
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
|
||||
Ok((headers, buffer[body_offset..body_offset + content_length].to_vec()))
|
||||
Ok((request_line, headers, buffer[body_offset..body_offset + content_length].to_vec()))
|
||||
}
|
||||
|
||||
async fn spawn_object_lambda_webhook_server() -> Result<
|
||||
@@ -130,9 +132,17 @@ async fn spawn_object_lambda_webhook_server_with_response(
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().await?;
|
||||
let Ok(Ok((headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await else {
|
||||
let Ok(Ok((request_line, headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if request_line == "HEAD / HTTP/1.1" {
|
||||
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;
|
||||
}
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body)?;
|
||||
|
||||
let output_route = payload["getObjectContext"]["outputRoute"]
|
||||
@@ -412,9 +422,33 @@ async fn wait_for_target_absence(
|
||||
Err(format!("target {target_name} remained visible in admin APIs; targets={last_targets}, arns={last_arns:?}").into())
|
||||
}
|
||||
|
||||
async fn restart_rustfs_server(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
fn endpoint_origin(endpoint: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
Ok(reqwest::Url::parse(endpoint)?.origin().ascii_serialization())
|
||||
}
|
||||
|
||||
async fn start_rustfs_server_for_endpoint(
|
||||
env: &mut RustFSTestEnvironment,
|
||||
endpoint: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let origin = endpoint_origin(endpoint)?;
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
(ENV_OUTBOUND_ALLOW_ORIGINS, origin.as_str()),
|
||||
("RUSTFS_NOTIFY_ENABLE", "true"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restart_rustfs_server(env: &mut RustFSTestEnvironment, endpoint: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let origin = endpoint_origin(endpoint)?;
|
||||
env.stop_server();
|
||||
env.start_rustfs_server_without_cleanup(vec![]).await
|
||||
env.start_rustfs_server_without_cleanup_with_env(&[
|
||||
(ENV_OUTBOUND_ALLOW_ORIGINS, origin.as_str()),
|
||||
("RUSTFS_NOTIFY_ENABLE", "true"),
|
||||
])
|
||||
.await
|
||||
}
|
||||
|
||||
async fn spawn_http_origin_probe_server() -> Result<
|
||||
@@ -521,7 +555,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
|
||||
let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let target_name = "restart-target";
|
||||
configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?;
|
||||
@@ -535,7 +569,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
|
||||
"target ARN missing after initial configure: {visible_arns:?}"
|
||||
);
|
||||
|
||||
restart_rustfs_server(&mut env).await?;
|
||||
restart_rustfs_server(&mut env, &webhook_url).await?;
|
||||
|
||||
let (targets_after_restart, arns_after_restart) = wait_for_target_visibility(&env, target_name).await?;
|
||||
assert!(notification_target_is_listed(&targets_after_restart, target_name));
|
||||
@@ -556,7 +590,7 @@ async fn test_notification_target_persists_across_restart_and_delete() -> Result
|
||||
"target ARN still visible after delete: {arns_after_delete:?}"
|
||||
);
|
||||
|
||||
restart_rustfs_server(&mut env).await?;
|
||||
restart_rustfs_server(&mut env, &webhook_url).await?;
|
||||
|
||||
let (targets_after_delete_restart, arns_after_delete_restart) = wait_for_target_absence(&env, target_name).await?;
|
||||
assert!(!notification_target_is_listed(&targets_after_delete_restart, target_name));
|
||||
@@ -581,8 +615,7 @@ async fn test_notification_target_with_path_is_online_via_transport_probe() -> R
|
||||
let (webhook_url, mut probe_rx, probe_handle) = spawn_http_origin_probe_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_NOTIFY_ENABLE", "true")])
|
||||
.await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let target_name = "path-probe";
|
||||
configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?;
|
||||
@@ -615,7 +648,7 @@ async fn test_get_object_lambda_accepts_presigned_requests() -> Result<(), Box<d
|
||||
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-presigned";
|
||||
let key = "input.txt";
|
||||
@@ -656,7 +689,7 @@ async fn test_get_object_lambda_accepts_named_webhook_target_arn() -> Result<(),
|
||||
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-named-target";
|
||||
let key = "input.txt";
|
||||
@@ -696,7 +729,7 @@ async fn test_get_object_lambda_invokes_runtime_webhook_target() -> Result<(), B
|
||||
let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e";
|
||||
let key = "input.txt";
|
||||
@@ -777,7 +810,7 @@ async fn test_get_object_lambda_passthroughs_non_success_webhook_response() -> R
|
||||
.await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-failure";
|
||||
let key = "input.txt";
|
||||
@@ -832,7 +865,7 @@ async fn test_get_object_lambda_rejects_success_response_without_auth_headers()
|
||||
.await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-missing-auth";
|
||||
let key = "input.txt";
|
||||
@@ -879,7 +912,7 @@ async fn test_get_object_lambda_rejects_success_response_with_mismatched_auth_he
|
||||
.await?;
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
start_rustfs_server_for_endpoint(&mut env, &webhook_url).await?;
|
||||
|
||||
let bucket = "object-lambda-e2e-mismatched-auth";
|
||||
let key = "input.txt";
|
||||
|
||||
@@ -400,6 +400,20 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn prepare_part_transaction(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::PreparePartTransactionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::PreparePartTransactionResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn settle_part_transaction(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::SettlePartTransactionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::SettlePartTransactionResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_file(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameFileRequest>,
|
||||
|
||||
@@ -38,7 +38,7 @@ use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, BucketVersioningStatus, ExpirationStatus, LifecycleExpiration, LifecycleRule,
|
||||
LifecycleRuleFilter, VersioningConfiguration,
|
||||
LifecycleRuleFilter, NoncurrentVersionExpiration, VersioningConfiguration,
|
||||
};
|
||||
use std::time::Duration as StdDuration;
|
||||
use time::OffsetDateTime;
|
||||
@@ -98,7 +98,10 @@ async fn put_object_with_backdated_mtime(
|
||||
/// still succeeds. Any other error is surfaced.
|
||||
async fn object_is_gone(client: &Client, bucket: &str, key: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
match client.get_object().bucket(bucket).key(key).send().await {
|
||||
Ok(_) => Ok(false),
|
||||
Ok(output) => {
|
||||
output.body.collect().await?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(service_error) = e.as_service_error() {
|
||||
if service_error.is_no_such_key() {
|
||||
@@ -132,6 +135,39 @@ async fn wait_for_object_expired(client: &Client, bucket: &str, key: &str, deadl
|
||||
}
|
||||
}
|
||||
|
||||
async fn version_is_absent_from_listing(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let versions = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
||||
Ok(!versions.versions().iter().any(|v| v.version_id() == Some(version_id)))
|
||||
}
|
||||
|
||||
async fn wait_for_version_expired(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: &str,
|
||||
deadline: StdDuration,
|
||||
) -> TestResult {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if version_is_absent_from_listing(client, bucket, key, version_id).await? {
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() >= deadline {
|
||||
return Err(format!(
|
||||
"object version {bucket}/{key}?versionId={version_id} was not expired by the lifecycle scanner within {}s",
|
||||
deadline.as_secs()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(StdDuration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a prefix-scoped `Days`-based expiration rule.
|
||||
fn expiration_rule(id: &str, prefix: &str, days: i32) -> Result<LifecycleRule, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let rule = LifecycleRule::builder()
|
||||
@@ -143,6 +179,20 @@ fn expiration_rule(id: &str, prefix: &str, days: i32) -> Result<LifecycleRule, B
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
fn noncurrent_expiration_rule(
|
||||
id: &str,
|
||||
prefix: &str,
|
||||
days: i32,
|
||||
) -> Result<LifecycleRule, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let rule = LifecycleRule::builder()
|
||||
.id(id)
|
||||
.filter(LifecycleRuleFilter::builder().prefix(prefix).build())
|
||||
.noncurrent_version_expiration(NoncurrentVersionExpiration::builder().noncurrent_days(days).build())
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.build()?;
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
async fn put_expiration_config(client: &Client, bucket: &str, rule: LifecycleRule) -> TestResult {
|
||||
let lifecycle = BucketLifecycleConfiguration::builder().rules(rule).build()?;
|
||||
client
|
||||
@@ -277,9 +327,94 @@ async fn test_lifecycle_versioned_current_version_expiry_creates_delete_marker()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `NoncurrentVersionExpiration NoncurrentDays=1` on a versioned bucket,
|
||||
/// accelerated with `RUSTFS_ILM_DEBUG_DAY_SECS`. Proves the scanner purges the
|
||||
/// noncurrent data version from `ListObjectVersions` while preserving the
|
||||
/// latest version as the normal readable object and without creating a delete
|
||||
/// marker.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_lifecycle_noncurrent_version_expiry_removes_only_old_version() -> TestResult {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let mut extra_env = fast_lifecycle_env();
|
||||
extra_env.push(("RUSTFS_ILM_DEBUG_DAY_SECS", "2"));
|
||||
env.start_rustfs_server_with_env(vec![], &extra_env).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "ilm3-noncurrent";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let key = "versioned/noncurrent.txt";
|
||||
let first_put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"old payload"))
|
||||
.send()
|
||||
.await?;
|
||||
let old_version_id = first_put
|
||||
.version_id()
|
||||
.map(str::to_string)
|
||||
.expect("first versioned PUT returns a version id");
|
||||
|
||||
let second_put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"latest payload"))
|
||||
.send()
|
||||
.await?;
|
||||
let latest_version_id = second_put
|
||||
.version_id()
|
||||
.map(str::to_string)
|
||||
.expect("second versioned PUT returns a version id");
|
||||
|
||||
assert!(
|
||||
!version_is_absent_from_listing(&client, bucket, key, &old_version_id).await?,
|
||||
"old noncurrent version must be readable before lifecycle is installed"
|
||||
);
|
||||
|
||||
put_expiration_config(&client, bucket, noncurrent_expiration_rule("expire-noncurrent", "versioned/", 1)?).await?;
|
||||
|
||||
wait_for_version_expired(&client, bucket, key, &old_version_id, StdDuration::from_secs(90)).await?;
|
||||
|
||||
let latest = client.get_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(latest.version_id(), Some(latest_version_id.as_str()));
|
||||
assert_eq!(latest.body.collect().await?.into_bytes().as_ref(), b"latest payload");
|
||||
|
||||
let versions = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
||||
let data_versions = versions.versions();
|
||||
assert!(
|
||||
data_versions
|
||||
.iter()
|
||||
.any(|v| v.version_id() == Some(latest_version_id.as_str())),
|
||||
"latest data version {latest_version_id} must remain, got: {data_versions:?}"
|
||||
);
|
||||
assert!(
|
||||
!data_versions.iter().any(|v| v.version_id() == Some(old_version_id.as_str())),
|
||||
"old noncurrent version {old_version_id} must be removed, got: {data_versions:?}"
|
||||
);
|
||||
assert!(
|
||||
versions.delete_markers().is_empty(),
|
||||
"noncurrent version expiry must not create delete markers, got: {:?}",
|
||||
versions.delete_markers()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Days=0` expiration is invalid per S3 semantics (`Days` must be a positive
|
||||
/// integer >= 1). A `PutBucketLifecycleConfiguration` carrying a zero-day rule
|
||||
/// must be rejected with `InvalidArgument` (HTTP 400) — see crates/lifecycle
|
||||
/// must be rejected with `InvalidArgument` (HTTP 400) - see crates/lifecycle
|
||||
/// `validate()` and the PutBucketLifecycleConfiguration handler. This is the
|
||||
/// self-managed counterpart of the localhost-only
|
||||
/// `test_bucket_lifecycle_rejects_zero_days` unit test.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2113,11 +2113,31 @@ async fn build_replication_pair(
|
||||
async fn test_replication_check_succeeds_with_remote_target() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let (_source_env, _target_env, source_bucket) = build_replication_pair(true).await?;
|
||||
let response = run_replication_check(&_source_env, &source_bucket).await?;
|
||||
let (source_env, target_env, source_bucket) = build_replication_pair(true).await?;
|
||||
let response = run_replication_check(&source_env, &source_bucket).await?;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(response.text().await?.is_empty());
|
||||
let payload: serde_json::Value = response.json().await?;
|
||||
assert_eq!(payload["Status"], "OK");
|
||||
assert_eq!(payload["ActiveMutation"], true);
|
||||
assert_eq!(payload["Targets"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(payload["Targets"][0]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["Put"]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["DeleteMarker"]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["VersionDelete"]["Status"], "OK");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["Cleanup"]["Status"], "OK");
|
||||
|
||||
let target_client = target_env.create_s3_client();
|
||||
let versions = target_client
|
||||
.list_object_versions()
|
||||
.bucket("replication-check-dst")
|
||||
.prefix(".rustfs.sys/replication-check/")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
versions.versions().is_empty() && versions.delete_markers().is_empty(),
|
||||
"successful check must remove every probe version and delete marker"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2159,9 +2179,19 @@ async fn test_replication_check_rejects_target_without_object_lock() -> Result<(
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(body.contains("InvalidRequest"), "unexpected response: {body}");
|
||||
assert!(body.to_ascii_lowercase().contains("object lock"), "unexpected response: {body}");
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let payload: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(payload["Status"], "FAILED");
|
||||
assert_eq!(payload["Targets"][0]["Status"], "FAILED");
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["ObjectLock"]["Status"], "FAILED");
|
||||
assert!(
|
||||
payload["Targets"][0]["Phases"]["ObjectLock"]["Error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("object lock"),
|
||||
"unexpected response: {body}"
|
||||
);
|
||||
assert_eq!(payload["Targets"][0]["Phases"]["Put"]["Status"], "SKIPPED");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4048,17 +4078,23 @@ async fn test_site_replication_allows_private_ca_https_with_ca_cert_pem_real_dua
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
async fn test_site_replication_resync_lifecycle_survives_real_server_restart() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let resync_process_env = [
|
||||
("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true"),
|
||||
// Verbose server logging can block startup when this focused test is run
|
||||
// through a captured test process rather than nextest.
|
||||
("RUST_LOG", "error"),
|
||||
];
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
source_env
|
||||
.start_rustfs_server_with_env(vec![], LOOPBACK_REPLICATION_TARGET_ENV)
|
||||
.await?;
|
||||
source_env.capture_log_path = Some(format!("{}/server.log", source_env.temp_dir));
|
||||
source_env.start_rustfs_server_with_env(vec![], &resync_process_env).await?;
|
||||
|
||||
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||
target_env.capture_log_path = Some(format!("{}/server.log", target_env.temp_dir));
|
||||
target_env
|
||||
.start_rustfs_server_without_cleanup_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
|
||||
.start_rustfs_server_without_cleanup_with_env(&resync_process_env)
|
||||
.await?;
|
||||
|
||||
let source_bucket = "site-repl-resync-src";
|
||||
@@ -4106,12 +4142,12 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
|
||||
wait_for_bucket_on_target(&source_client, source_bucket).await?;
|
||||
let target_arn = wait_for_remote_target_arn(&source_env, source_bucket).await?;
|
||||
|
||||
for idx in 0..32 {
|
||||
for idx in 0..96 {
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(format!("resync-object-{idx:02}"))
|
||||
.body(ByteStream::from(vec![b'x'; 256 * 1024]))
|
||||
.body(ByteStream::from(vec![b'x'; 512 * 1024]))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
@@ -4119,18 +4155,31 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
|
||||
let started = site_replication_resync_op(&source_env, "start", &remote_peer).await?;
|
||||
assert_eq!(started.status, "success", "unexpected start result: {:?}", started);
|
||||
assert!(
|
||||
started
|
||||
.buckets
|
||||
.iter()
|
||||
.any(|bucket| bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "success")),
|
||||
started.buckets.iter().any(|bucket| {
|
||||
bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "running" | "completed" | "success")
|
||||
}),
|
||||
"source bucket start status missing: {:?}",
|
||||
started
|
||||
);
|
||||
assert!(!started.resync_id.is_empty(), "start response omitted the resync id: {:?}", started);
|
||||
let started_reset_id = started.resync_id.clone();
|
||||
|
||||
assert!(
|
||||
matches!(started.state.as_str(), "pending" | "running"),
|
||||
"the fixture must keep the first generation active long enough to test duplicate start: {:?}",
|
||||
started
|
||||
);
|
||||
let duplicate_err = site_replication_resync_op(&source_env, "start", &remote_peer)
|
||||
.await
|
||||
.expect_err("duplicate start must be rejected while a generation is active");
|
||||
assert!(
|
||||
duplicate_err.to_string().contains("already active"),
|
||||
"unexpected duplicate start error: {duplicate_err}"
|
||||
);
|
||||
|
||||
let canceled = site_replication_resync_op(&source_env, "cancel", &remote_peer).await?;
|
||||
assert_eq!(canceled.status, "success", "unexpected cancel result: {:?}", canceled);
|
||||
assert_eq!(canceled.state, "canceled");
|
||||
assert!(
|
||||
canceled
|
||||
.buckets
|
||||
@@ -4139,34 +4188,56 @@ async fn test_site_replication_resync_start_cancel_restart_real_dual_node() -> R
|
||||
"source bucket cancel status missing: {:?}",
|
||||
canceled
|
||||
);
|
||||
let canceled_again = site_replication_resync_op(&source_env, "cancel", &remote_peer).await?;
|
||||
assert_eq!(canceled_again.resync_id, canceled.resync_id, "repeated cancel must be idempotent");
|
||||
assert_eq!(canceled_again.state, "canceled");
|
||||
|
||||
let canceled_target =
|
||||
wait_for_replication_reset_target(&source_env, source_bucket, &target_arn, |target| target.status == "Canceled").await?;
|
||||
assert_eq!(canceled_target.status, "Canceled");
|
||||
assert_eq!(canceled_target.reset_id, started_reset_id);
|
||||
|
||||
let restarted = site_replication_resync_op(&source_env, "start", &remote_peer).await?;
|
||||
assert_eq!(restarted.status, "success", "unexpected restart result: {:?}", restarted);
|
||||
assert_ne!(restarted.resync_id, started_reset_id);
|
||||
assert!(
|
||||
matches!(restarted.state.as_str(), "pending" | "running"),
|
||||
"the second generation must be active before the process restart: {:?}",
|
||||
restarted
|
||||
.buckets
|
||||
.iter()
|
||||
.any(|bucket| bucket.bucket == source_bucket && matches!(bucket.status.as_str(), "started" | "success")),
|
||||
"source bucket restart status missing: {:?}",
|
||||
restarted
|
||||
);
|
||||
let restarted_reset_id = restarted.resync_id.clone();
|
||||
|
||||
source_env.restart_server_preserving_data(vec![], &resync_process_env).await?;
|
||||
wait_for_site_replication_enabled(&source_env, 2).await?;
|
||||
|
||||
let after_restart = site_replication_resync_op(&source_env, "status", &remote_peer).await?;
|
||||
assert_eq!(
|
||||
after_restart.resync_id, restarted_reset_id,
|
||||
"server restart changed the durable resync id"
|
||||
);
|
||||
assert_eq!(after_restart.generation, restarted.generation);
|
||||
assert_eq!(after_restart.created_at, restarted.created_at);
|
||||
assert!(
|
||||
matches!(after_restart.state.as_str(), "pending" | "running" | "completed" | "failed"),
|
||||
"unexpected recovered lifecycle state: {:?}",
|
||||
after_restart
|
||||
);
|
||||
assert!(
|
||||
after_restart.buckets.iter().any(|bucket| bucket.bucket == source_bucket),
|
||||
"durable status lost the source bucket after restart: {:?}",
|
||||
after_restart
|
||||
);
|
||||
let restart_snapshot = get_replication_reset_status(&source_env, source_bucket, &target_arn).await?;
|
||||
let restarted_target = wait_for_replication_reset_target(&source_env, source_bucket, &target_arn, |target| {
|
||||
!target.reset_id.is_empty() && target.reset_id != started_reset_id
|
||||
target.reset_id == restarted_reset_id
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"restart ids: start={} restart={} snapshot={:?}; {err}",
|
||||
started_reset_id, restarted.resync_id, restart_snapshot.targets
|
||||
started_reset_id, restarted_reset_id, restart_snapshot.targets
|
||||
)
|
||||
})?;
|
||||
assert_ne!(restarted_target.reset_id, started_reset_id);
|
||||
assert_eq!(restarted_target.reset_id, restarted_reset_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4732,6 +4803,153 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-applying a site's own replication config must not disable the peer's reverse direction.
|
||||
///
|
||||
/// `PutBucketReplication` broadcasts the config to every peer — the console's replication
|
||||
/// Save button, `mc replicate import`, and a bucket-metadata import all go through it. The
|
||||
/// receiver used to overwrite its rules with the sender's, whose destination ARN names the
|
||||
/// receiver itself. No bucket target can satisfy that ARN, so every object written on the
|
||||
/// receiver was dropped with only a debug line, while `replicate status` still reported
|
||||
/// "1/1 Buckets in sync" because both configs were byte-identical.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_config_broadcast_keeps_reverse_direction_real_dual_node() -> TestResult {
|
||||
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_with_env(LOOPBACK_REPLICATION_TARGET_ENV)
|
||||
.await?;
|
||||
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
let bucket = "site-repl-config-broadcast";
|
||||
|
||||
let add_status = site_replication_add(
|
||||
&source_env,
|
||||
&[
|
||||
PeerSite {
|
||||
name: "broadcast-source".to_string(),
|
||||
endpoint: source_env.url.clone(),
|
||||
access_key: source_env.access_key.clone(),
|
||||
secret_key: source_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
PeerSite {
|
||||
name: "broadcast-target".to_string(),
|
||||
endpoint: target_env.url.clone(),
|
||||
access_key: target_env.access_key.clone(),
|
||||
secret_key: target_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
assert!(add_status.success, "unexpected site add result: {add_status:?}");
|
||||
wait_for_site_replication_enabled(&source_env, 2).await?;
|
||||
wait_for_site_replication_enabled(&target_env, 2).await?;
|
||||
|
||||
source_client.create_bucket().bucket(bucket).send().await?;
|
||||
wait_for_bucket_on_target(&target_client, bucket).await?;
|
||||
|
||||
// Both directions work before the broadcast.
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("from-source.txt")
|
||||
.body(ByteStream::from_static(b"written on the initiating site"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
wait_for_object_on_target(&target_client, bucket, "from-source.txt").await?,
|
||||
b"written on the initiating site".to_vec(),
|
||||
);
|
||||
target_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("from-target.txt")
|
||||
.body(ByteStream::from_static(b"written on the joined site"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
wait_for_object_on_target(&source_client, bucket, "from-target.txt").await?,
|
||||
b"written on the joined site".to_vec(),
|
||||
);
|
||||
|
||||
// Round-trip the source's own config through PutBucketReplication, exactly what the
|
||||
// console does when an operator opens the bucket's replication page and saves it.
|
||||
let source_config = source_client
|
||||
.get_bucket_replication()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await?
|
||||
.replication_configuration
|
||||
.ok_or("source bucket has no replication configuration")?;
|
||||
source_client
|
||||
.put_bucket_replication()
|
||||
.bucket(bucket)
|
||||
.replication_configuration(source_config)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let target_config = wait_for_site_replication_rule(&target_client, bucket).await?;
|
||||
let target_deployment_id = site_replication_info(&target_env)
|
||||
.await?
|
||||
.sites
|
||||
.iter()
|
||||
.find(|peer| peer.endpoint == target_env.url)
|
||||
.map(|peer| peer.deployment_id.clone())
|
||||
.ok_or("joined site missing from its own replication info")?;
|
||||
for rule in &target_config.rules {
|
||||
let destination = rule
|
||||
.destination
|
||||
.as_ref()
|
||||
.map(|destination| destination.bucket.as_str())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!destination.contains(&target_deployment_id),
|
||||
"joined site adopted a rule pointing at itself: {destination}"
|
||||
);
|
||||
}
|
||||
|
||||
target_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("from-target-after-broadcast.txt")
|
||||
.body(ByteStream::from_static(b"written after the config broadcast"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
wait_for_object_on_target(&source_client, bucket, "from-target-after-broadcast.txt").await?,
|
||||
b"written after the config broadcast".to_vec(),
|
||||
"config broadcast made replication one-directional"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_site_replication_rule(
|
||||
client: &aws_sdk_s3::Client,
|
||||
bucket: &str,
|
||||
) -> Result<aws_sdk_s3::types::ReplicationConfiguration, Box<dyn Error + Send + Sync>> {
|
||||
for _ in 0..40 {
|
||||
if let Ok(response) = client.get_bucket_replication().bucket(bucket).send().await
|
||||
&& let Some(config) = response.replication_configuration
|
||||
&& !config.rules.is_empty()
|
||||
{
|
||||
return Ok(config);
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Err(format!("bucket {bucket} never reported a replication rule").into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_site_replication_active_active_converges_without_loops_real_dual_node() -> TestResult {
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
// 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.
|
||||
|
||||
//! Black-box SSE-C CopyObject and multipart-copy regression coverage (backlog#1467).
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::config::interceptors::{BeforeDeserializationInterceptorContextRef, BeforeTransmitInterceptorContextRef};
|
||||
use aws_sdk_s3::config::{ConfigBag, Credentials, Intercept, Region, RuntimeComponents};
|
||||
use aws_sdk_s3::error::BoxError;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, VersioningConfiguration};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use base64::Engine;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const SSE_CUSTOMER_ALGORITHM_HEADER: &str = "x-amz-server-side-encryption-customer-algorithm";
|
||||
const SSE_CUSTOMER_KEY_MD5_HEADER: &str = "x-amz-server-side-encryption-customer-key-md5";
|
||||
|
||||
struct CustomerKey {
|
||||
raw: String,
|
||||
encoded: String,
|
||||
md5: String,
|
||||
}
|
||||
|
||||
struct InvalidSsec<'a> {
|
||||
algorithm: Option<&'a str>,
|
||||
key: Option<&'a str>,
|
||||
md5: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct ResponseHeaderCapture {
|
||||
headers: Arc<Mutex<HashMap<String, String>>>,
|
||||
abort_attempts: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl ResponseHeaderCapture {
|
||||
fn snapshot(&self) -> Result<HashMap<String, String>, BoxError> {
|
||||
self.headers
|
||||
.lock()
|
||||
.map(|headers| headers.clone())
|
||||
.map_err(|_| std::io::Error::other("response header capture mutex was poisoned").into())
|
||||
}
|
||||
|
||||
fn abort_attempts(&self) -> usize {
|
||||
self.abort_attempts.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
impl Intercept for ResponseHeaderCapture {
|
||||
fn name(&self) -> &'static str {
|
||||
"ssec-copy-response-header-capture"
|
||||
}
|
||||
|
||||
fn read_before_deserialization(
|
||||
&self,
|
||||
context: &BeforeDeserializationInterceptorContextRef<'_>,
|
||||
_runtime_components: &RuntimeComponents,
|
||||
_cfg: &mut ConfigBag,
|
||||
) -> Result<(), BoxError> {
|
||||
let mut captured = self
|
||||
.headers
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("response header capture mutex was poisoned"))?;
|
||||
captured.clear();
|
||||
for name in [SSE_CUSTOMER_ALGORITHM_HEADER, SSE_CUSTOMER_KEY_MD5_HEADER] {
|
||||
if let Some(value) = context.response().headers().get(name) {
|
||||
captured.insert(name.to_owned(), value.to_owned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_before_transmit(
|
||||
&self,
|
||||
context: &BeforeTransmitInterceptorContextRef<'_>,
|
||||
_runtime_components: &RuntimeComponents,
|
||||
_cfg: &mut ConfigBag,
|
||||
) -> Result<(), BoxError> {
|
||||
let request = context.request();
|
||||
if request.method() == "DELETE" && request.uri().contains("uploadId=") {
|
||||
self.abort_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn customer_key(byte: u8) -> CustomerKey {
|
||||
let raw = [byte; 32];
|
||||
CustomerKey {
|
||||
raw: String::from_utf8_lossy(&raw).into_owned(),
|
||||
encoded: base64::engine::general_purpose::STANDARD.encode(raw),
|
||||
md5: base64::engine::general_purpose::STANDARD.encode(md5::compute(raw).0),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_secret_absent(error: &str, keys: &[&CustomerKey]) {
|
||||
for key in keys {
|
||||
assert!(!error.contains(&key.raw), "error exposed a raw SSE-C key");
|
||||
assert!(!error.contains(&key.encoded), "error exposed an encoded SSE-C key");
|
||||
assert!(!error.contains(&key.md5), "error exposed an SSE-C key MD5");
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_ssec_cases<'a>(correct_key: &'a CustomerKey, wrong_key: &'a CustomerKey) -> [InvalidSsec<'a>; 5] {
|
||||
[
|
||||
InvalidSsec {
|
||||
algorithm: None,
|
||||
key: Some(&correct_key.encoded),
|
||||
md5: Some(&correct_key.md5),
|
||||
},
|
||||
InvalidSsec {
|
||||
algorithm: Some("AES256"),
|
||||
key: None,
|
||||
md5: Some(&correct_key.md5),
|
||||
},
|
||||
InvalidSsec {
|
||||
algorithm: Some("AES256"),
|
||||
key: Some(&correct_key.encoded),
|
||||
md5: None,
|
||||
},
|
||||
InvalidSsec {
|
||||
algorithm: Some("AES256"),
|
||||
key: Some(&wrong_key.encoded),
|
||||
md5: Some(&wrong_key.md5),
|
||||
},
|
||||
InvalidSsec {
|
||||
algorithm: Some("AES256"),
|
||||
key: Some(&correct_key.encoded),
|
||||
md5: Some(&wrong_key.md5),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_object_rotates_ssec_key_and_drops_source_encryption_metadata() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "ssec-copy-object";
|
||||
let source = "source.bin";
|
||||
let plaintext_copy = "plaintext-copy.bin";
|
||||
let rotated_copy = "rotated-copy.bin";
|
||||
let source_key = customer_key(0x41);
|
||||
let destination_key = customer_key(0x42);
|
||||
let wrong_key = customer_key(0x43);
|
||||
let body = b"backlog-1467 versioned SSE-C copy payload";
|
||||
|
||||
env.create_test_bucket(bucket).await?;
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&source_key.encoded)
|
||||
.sse_customer_key_md5(&source_key.md5)
|
||||
.body(ByteStream::from_static(body))
|
||||
.send()
|
||||
.await?;
|
||||
let source_version = put.version_id().ok_or("versioned PUT returned no version ID")?;
|
||||
let copy_source = format!("{bucket}/{source}?versionId={source_version}");
|
||||
|
||||
let plaintext = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(plaintext_copy)
|
||||
.copy_source(©_source)
|
||||
.copy_source_sse_customer_algorithm("AES256")
|
||||
.copy_source_sse_customer_key(&source_key.encoded)
|
||||
.copy_source_sse_customer_key_md5(&source_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(plaintext.copy_source_version_id(), Some(source_version));
|
||||
let plaintext_body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(plaintext_copy)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(plaintext_body.as_ref(), body);
|
||||
|
||||
let rotated = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(rotated_copy)
|
||||
.copy_source(©_source)
|
||||
.copy_source_sse_customer_algorithm("AES256")
|
||||
.copy_source_sse_customer_key(&source_key.encoded)
|
||||
.copy_source_sse_customer_key_md5(&source_key.md5)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(rotated.sse_customer_algorithm(), Some("AES256"));
|
||||
assert_eq!(rotated.sse_customer_key_md5(), Some(destination_key.md5.as_str()));
|
||||
|
||||
let wrong_key_error = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(rotated_copy)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&source_key.encoded)
|
||||
.sse_customer_key_md5(&source_key.md5)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("the source key must not read a copy encrypted with the destination key");
|
||||
assert_secret_absent(&format!("{wrong_key_error:?}"), &[&source_key, &destination_key]);
|
||||
|
||||
let rotated_body = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(rotated_copy)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(rotated_body.as_ref(), body);
|
||||
|
||||
for (case_index, case) in invalid_ssec_cases(&source_key, &wrong_key).iter().enumerate() {
|
||||
let failed_target = format!("failed-copy-{case_index}.bin");
|
||||
let mut request = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(&failed_target)
|
||||
.copy_source(©_source);
|
||||
if let Some(algorithm) = case.algorithm {
|
||||
request = request.copy_source_sse_customer_algorithm(algorithm);
|
||||
}
|
||||
if let Some(key) = case.key {
|
||||
request = request.copy_source_sse_customer_key(key);
|
||||
}
|
||||
if let Some(md5) = case.md5 {
|
||||
request = request.copy_source_sse_customer_key_md5(md5);
|
||||
}
|
||||
let error = request
|
||||
.send()
|
||||
.await
|
||||
.expect_err("invalid source SSE-C parameters must reject CopyObject");
|
||||
assert_secret_absent(&format!("{error:?}"), &[&source_key, &wrong_key]);
|
||||
assert!(
|
||||
client.head_object().bucket(bucket).key(&failed_target).send().await.is_err(),
|
||||
"a rejected CopyObject must not create its target"
|
||||
);
|
||||
}
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_copy_requires_keys_on_every_stage_and_abort_leaves_no_object() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let response_headers = ResponseHeaderCapture::default();
|
||||
let credentials = Credentials::new(&env.access_key, &env.secret_key, None, None, "ssec-copy-e2e");
|
||||
let config = aws_sdk_s3::Config::builder()
|
||||
.credentials_provider(credentials)
|
||||
.region(Region::new("us-east-1"))
|
||||
.endpoint_url(&env.url)
|
||||
.force_path_style(true)
|
||||
.behavior_version_latest()
|
||||
.http_client(SmithyHttpClientBuilder::new().build_http())
|
||||
.interceptor(response_headers.clone())
|
||||
.build();
|
||||
let client = aws_sdk_s3::Client::from_conf(config);
|
||||
let bucket = "ssec-multipart-copy";
|
||||
let source = "source.bin";
|
||||
let destination = "destination.bin";
|
||||
let aborted_destination = "aborted.bin";
|
||||
let source_key = customer_key(0x51);
|
||||
let destination_key = customer_key(0x52);
|
||||
let wrong_key = customer_key(0x53);
|
||||
let part_size = 5 * 1024 * 1024;
|
||||
let body: Vec<u8> = (0..part_size * 2).map(|index| (index % 251) as u8).collect();
|
||||
|
||||
env.create_test_bucket(bucket).await?;
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let source_put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(source)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&source_key.encoded)
|
||||
.sse_customer_key_md5(&source_key.md5)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let source_version = source_put
|
||||
.version_id()
|
||||
.ok_or("versioned multipart-copy source returned no version ID")?;
|
||||
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(destination)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(create.sse_customer_algorithm(), Some("AES256"));
|
||||
assert_eq!(create.sse_customer_key_md5(), Some(destination_key.md5.as_str()));
|
||||
let upload_id = create.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
let mut completed = Vec::new();
|
||||
for part_number in 1..=2 {
|
||||
let first = (part_number - 1) * part_size;
|
||||
let last = part_number * part_size - 1;
|
||||
let copied = client
|
||||
.upload_part_copy()
|
||||
.bucket(bucket)
|
||||
.key(destination)
|
||||
.upload_id(upload_id)
|
||||
.part_number(part_number)
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.copy_source_range(format!("bytes={first}-{last}"))
|
||||
.copy_source_sse_customer_algorithm("AES256")
|
||||
.copy_source_sse_customer_key(&source_key.encoded)
|
||||
.copy_source_sse_customer_key_md5(&source_key.md5)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
copied.copy_source_version_id(),
|
||||
Some(source_version),
|
||||
"UploadPartCopy must return the actual latest source version"
|
||||
);
|
||||
let etag = copied
|
||||
.copy_part_result()
|
||||
.and_then(|result| result.e_tag())
|
||||
.ok_or("UploadPartCopy returned no ETag")?;
|
||||
completed.push(CompletedPart::builder().part_number(part_number).e_tag(etag).build());
|
||||
}
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(destination)
|
||||
.upload_id(upload_id)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
|
||||
.send()
|
||||
.await?;
|
||||
let completed_headers = response_headers.snapshot()?;
|
||||
assert_eq!(completed_headers.get(SSE_CUSTOMER_ALGORITHM_HEADER).map(String::as_str), Some("AES256"));
|
||||
assert_eq!(
|
||||
completed_headers.get(SSE_CUSTOMER_KEY_MD5_HEADER).map(String::as_str),
|
||||
Some(destination_key.md5.as_str())
|
||||
);
|
||||
let downloaded = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(destination)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(downloaded.as_ref(), body.as_slice());
|
||||
|
||||
for (case_index, case) in invalid_ssec_cases(&destination_key, &wrong_key).iter().enumerate() {
|
||||
let failed_target = format!("{aborted_destination}-{case_index}");
|
||||
let failed_create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&failed_target)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&destination_key.encoded)
|
||||
.sse_customer_key_md5(&destination_key.md5)
|
||||
.send()
|
||||
.await?;
|
||||
let failed_upload_id = failed_create
|
||||
.upload_id()
|
||||
.ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
let mut request = client
|
||||
.upload_part_copy()
|
||||
.bucket(bucket)
|
||||
.key(&failed_target)
|
||||
.upload_id(failed_upload_id)
|
||||
.part_number(1)
|
||||
.copy_source(format!("{bucket}/{source}"))
|
||||
.copy_source_sse_customer_algorithm("AES256")
|
||||
.copy_source_sse_customer_key(&source_key.encoded)
|
||||
.copy_source_sse_customer_key_md5(&source_key.md5);
|
||||
if let Some(algorithm) = case.algorithm {
|
||||
request = request.sse_customer_algorithm(algorithm);
|
||||
}
|
||||
if let Some(key) = case.key {
|
||||
request = request.sse_customer_key(key);
|
||||
}
|
||||
if let Some(md5) = case.md5 {
|
||||
request = request.sse_customer_key_md5(md5);
|
||||
}
|
||||
let error = request
|
||||
.send()
|
||||
.await
|
||||
.expect_err("invalid destination SSE-C parameters must reject UploadPartCopy");
|
||||
assert_secret_absent(&format!("{error:?}"), &[&source_key, &destination_key, &wrong_key]);
|
||||
|
||||
let abort_attempts_before = response_headers.abort_attempts();
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&failed_target)
|
||||
.upload_id(failed_upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
response_headers.abort_attempts(),
|
||||
abort_attempts_before + 1,
|
||||
"each failed multipart copy must issue exactly one wire-level abort attempt"
|
||||
);
|
||||
assert!(
|
||||
client.head_object().bucket(bucket).key(&failed_target).send().await.is_err(),
|
||||
"an aborted failed multipart copy must leave no completed object"
|
||||
);
|
||||
}
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers};
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
|
||||
@@ -31,6 +33,17 @@ pub(crate) mod grpc_lock {
|
||||
pub(crate) use super::{TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
}
|
||||
|
||||
/// Signing/transport surface used by the cross-process internode RPC signature
|
||||
/// acceptance tests (backlog#1327). The signing helpers are what let a test mint
|
||||
/// the one legitimately signed request an on-path attacker is assumed to have
|
||||
/// captured; every attack in that suite then only *reuses* those bytes.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod internode_rpc_signature {
|
||||
pub(crate) use super::{
|
||||
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers, node_service_time_out_client_no_auth,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod replication_extension {
|
||||
pub(crate) use super::BucketTargetSys;
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
// 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.
|
||||
|
||||
//! Truthful storage-class write and discovery contract regressions.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{ObjectAttributes, StorageClass};
|
||||
use http::header::HOST;
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serde_json::Value;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
|
||||
const UNSUPPORTED_AWS_CLASSES: [&str; 9] = [
|
||||
"DEEP_ARCHIVE",
|
||||
"EXPRESS_ONEZONE",
|
||||
"GLACIER",
|
||||
"GLACIER_IR",
|
||||
"INTELLIGENT_TIERING",
|
||||
"ONEZONE_IA",
|
||||
"OUTPOSTS",
|
||||
"SNOW",
|
||||
"STANDARD_IA",
|
||||
];
|
||||
|
||||
async fn assert_object_storage_class(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
expected: &str,
|
||||
body: &[u8],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
let expected_head = (expected != "STANDARD").then_some(expected);
|
||||
assert_eq!(
|
||||
head.storage_class().map(StorageClass::as_str),
|
||||
expected_head,
|
||||
"HeadObject must omit implicit STANDARD and report RRS"
|
||||
);
|
||||
|
||||
let listed = client.list_objects_v2().bucket(bucket).prefix(key).send().await?;
|
||||
let object = listed
|
||||
.contents()
|
||||
.iter()
|
||||
.find(|object| object.key() == Some(key))
|
||||
.ok_or("object missing from ListObjectsV2")?;
|
||||
assert_eq!(object.storage_class().map(|storage_class| storage_class.as_str()), Some(expected));
|
||||
|
||||
let get = client.get_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(
|
||||
get.storage_class().map(StorageClass::as_str),
|
||||
expected_head,
|
||||
"GetObject must report the same effective storage class as HeadObject"
|
||||
);
|
||||
let downloaded = get.body.collect().await?.into_bytes();
|
||||
assert_eq!(downloaded.as_ref(), body, "storage-class selection must not alter object bytes");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mutate_xl_meta(
|
||||
root: &str,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
mutate: impl FnOnce(&mut rustfs_filemeta::MetaObject),
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let path = Path::new(root).join(bucket).join(key).join("xl.meta");
|
||||
let bytes = tokio::fs::read(&path).await?;
|
||||
let mut file_meta = rustfs_filemeta::FileMeta::load(&bytes)?;
|
||||
let (index, mut version) = file_meta.find_version(None)?;
|
||||
let object = version.object.as_mut().ok_or("fixture version is not an object")?;
|
||||
mutate(object);
|
||||
file_meta.versions[index] = rustfs_filemeta::FileMetaShallowVersion::try_from(version)?;
|
||||
tokio::fs::write(path, file_meta.marshal_msg()?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn signed_admin_get(
|
||||
env: &RustFSTestEnvironment,
|
||||
path: &str,
|
||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}{path}", env.url);
|
||||
let uri = url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::GET)
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||
.body(Body::empty())?;
|
||||
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
|
||||
|
||||
let mut request = local_http_client().get(&url);
|
||||
for (name, value) in signed.headers() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
Ok(request.send().await?)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standard_and_rrs_are_supported_across_put_copy_and_multipart() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "storage-class-supported-contract";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("copy-source")
|
||||
.body(ByteStream::from_static(b"copy-source-body"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
for storage_class in [StorageClass::Standard, StorageClass::ReducedRedundancy] {
|
||||
let class_name = storage_class.as_str().to_string();
|
||||
let put_key = format!("put-{class_name}");
|
||||
let put_body = format!("put-body-{class_name}").into_bytes();
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&put_key)
|
||||
.storage_class(storage_class.clone())
|
||||
.body(ByteStream::from(put_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
assert_object_storage_class(&client, bucket, &put_key, &class_name, &put_body).await?;
|
||||
|
||||
let copy_key = format!("copy-{class_name}");
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(©_key)
|
||||
.copy_source(format!("{bucket}/copy-source"))
|
||||
.storage_class(storage_class.clone())
|
||||
.send()
|
||||
.await?;
|
||||
assert_object_storage_class(&client, bucket, ©_key, &class_name, b"copy-source-body").await?;
|
||||
|
||||
let multipart_key = format!("multipart-{class_name}");
|
||||
let created = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&multipart_key)
|
||||
.storage_class(storage_class)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = created.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
let parts = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(&multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(parts.storage_class().map(StorageClass::as_str), Some(class_name.as_str()));
|
||||
client
|
||||
.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn label_only_aws_classes_fail_before_put_copy_or_multipart_mutation() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "storage-class-unsupported-contract";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
for key in ["put-guard", "copy-source", "copy-guard"] {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(format!("original-{key}").into_bytes()))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
for unsupported in UNSUPPORTED_AWS_CLASSES {
|
||||
let put_error = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("put-guard")
|
||||
.storage_class(StorageClass::from(unsupported))
|
||||
.body(ByteStream::from(format!("rejected-put-{unsupported}").into_bytes()))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("label-only PUT storage class must be rejected");
|
||||
assert_eq!(
|
||||
put_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidStorageClass"),
|
||||
"PUT returned a different error for {unsupported}"
|
||||
);
|
||||
|
||||
let copy_error = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("copy-guard")
|
||||
.copy_source(format!("{bucket}/copy-source"))
|
||||
.storage_class(StorageClass::from(unsupported))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("label-only CopyObject storage class must be rejected");
|
||||
assert_eq!(
|
||||
copy_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidStorageClass"),
|
||||
"CopyObject returned a different error for {unsupported}"
|
||||
);
|
||||
|
||||
let multipart_key = format!("multipart-{unsupported}");
|
||||
let multipart_error = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(&multipart_key)
|
||||
.storage_class(StorageClass::from(unsupported))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("label-only CreateMultipartUpload storage class must be rejected");
|
||||
assert_eq!(
|
||||
multipart_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidStorageClass"),
|
||||
"CreateMultipartUpload returned a different error for {unsupported}"
|
||||
);
|
||||
}
|
||||
|
||||
let put_guard = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("put-guard")
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(put_guard.as_ref(), b"original-put-guard");
|
||||
|
||||
let copy_guard = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("copy-guard")
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(copy_guard.as_ref(), b"original-copy-guard");
|
||||
|
||||
let uploads = client.list_multipart_uploads().bucket(bucket).send().await?;
|
||||
assert!(uploads.uploads().is_empty(), "unsupported classes must not create multipart sessions");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn historical_label_only_metadata_is_standard_without_hiding_a_real_transition_tier()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "storage-class-historical-contract";
|
||||
let legacy_key = "legacy-label-only";
|
||||
let transitioned_key = "real-transition-tier";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
|
||||
for key in [legacy_key, transitioned_key] {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"fixture-body"))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
mutate_xl_meta(&env.temp_dir, bucket, legacy_key, |object| {
|
||||
object
|
||||
.meta_user
|
||||
.insert("x-amz-storage-class".to_string(), "STANDARD_IA".to_string());
|
||||
})
|
||||
.await?;
|
||||
mutate_xl_meta(&env.temp_dir, bucket, transitioned_key, |object| {
|
||||
object.set_transition(&rustfs_filemeta::FileInfo {
|
||||
transition_status: rustfs_filemeta::TRANSITION_COMPLETE.to_string(),
|
||||
transition_tier: "STANDARD_IA".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
})
|
||||
.await?;
|
||||
env.restart_server_preserving_data(Vec::new(), &[]).await?;
|
||||
|
||||
assert_object_storage_class(&client, bucket, legacy_key, "STANDARD", b"fixture-body").await?;
|
||||
|
||||
let legacy_attributes = client
|
||||
.get_object_attributes()
|
||||
.bucket(bucket)
|
||||
.key(legacy_key)
|
||||
.object_attributes(ObjectAttributes::StorageClass)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(legacy_attributes.storage_class().map(StorageClass::as_str), Some("STANDARD"));
|
||||
|
||||
let versions = client.list_object_versions().bucket(bucket).prefix(legacy_key).send().await?;
|
||||
let legacy_version = versions
|
||||
.versions()
|
||||
.iter()
|
||||
.find(|version| version.key() == Some(legacy_key))
|
||||
.ok_or("legacy fixture missing from ListObjectVersions")?;
|
||||
assert_eq!(legacy_version.storage_class().map(|class| class.as_str()), Some("STANDARD"));
|
||||
|
||||
let transitioned_head = client.head_object().bucket(bucket).key(transitioned_key).send().await?;
|
||||
assert_eq!(transitioned_head.storage_class().map(StorageClass::as_str), Some("STANDARD_IA"));
|
||||
let transitioned_attributes = client
|
||||
.get_object_attributes()
|
||||
.bucket(bucket)
|
||||
.key(transitioned_key)
|
||||
.object_attributes(ObjectAttributes::StorageClass)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(transitioned_attributes.storage_class().map(StorageClass::as_str), Some("STANDARD_IA"));
|
||||
let transitioned_list = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix(transitioned_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
transitioned_list.contents()[0].storage_class().map(|class| class.as_str()),
|
||||
Some("STANDARD_IA")
|
||||
);
|
||||
let transitioned_versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.prefix(transitioned_key)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
transitioned_versions.versions()[0]
|
||||
.storage_class()
|
||||
.map(|class| class.as_str()),
|
||||
Some("STANDARD_IA")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_runtime_capabilities_publish_the_versioned_storage_class_contract()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let path = "/rustfs/admin/v4/runtime/capabilities";
|
||||
|
||||
let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?;
|
||||
assert_eq!(unsigned.status(), StatusCode::FORBIDDEN);
|
||||
let unsigned_body = unsigned.text().await?;
|
||||
assert!(
|
||||
!unsigned_body.contains("supported_write_classes"),
|
||||
"the capability contract must not bypass admin authentication"
|
||||
);
|
||||
assert!(
|
||||
!unsigned_body.contains("manual_transition_jobs"),
|
||||
"manual transition job capabilities must not bypass admin authentication"
|
||||
);
|
||||
|
||||
let response = signed_admin_get(&env, path).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body: Value = response.json().await?;
|
||||
assert_eq!(body["storage_classes"]["contract_version"], 1);
|
||||
assert_eq!(
|
||||
body["storage_classes"]["supported_write_classes"],
|
||||
serde_json::json!(["STANDARD", "REDUCED_REDUNDANCY"])
|
||||
);
|
||||
assert_eq!(body["storage_classes"]["unsupported_write_error"], "InvalidStorageClass");
|
||||
assert_eq!(body["storage_classes"]["legacy_label_behavior"], "normalized_to_effective_class");
|
||||
assert_eq!(body["summary"]["manual_transition_jobs"]["state"], "supported");
|
||||
assert_eq!(body["manual_transition_jobs"]["contract_version"], 1);
|
||||
assert_eq!(body["manual_transition_jobs"]["status"]["state"], "supported");
|
||||
assert_eq!(body["manual_transition_jobs"]["modes"], serde_json::json!(["enqueue_only", "async"]));
|
||||
assert_eq!(body["manual_transition_jobs"]["run_route"], "/rustfs/admin/v3/ilm/transition/run");
|
||||
assert_eq!(
|
||||
body["manual_transition_jobs"]["status_route"],
|
||||
"/rustfs/admin/v3/ilm/transition/jobs/{job_id}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["manual_transition_jobs"]["cancel_route"],
|
||||
"/rustfs/admin/v3/ilm/transition/jobs/{job_id}"
|
||||
);
|
||||
assert_eq!(body["manual_transition_jobs"]["job_id_format"], "uuid");
|
||||
assert_eq!(body["manual_transition_jobs"]["admission_scope"], "bucket");
|
||||
assert_eq!(
|
||||
body["manual_transition_jobs"]["mixed_version_policy"],
|
||||
"fail_closed_when_capability_unknown_or_unsupported"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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, init_logging, local_http_client};
|
||||
use aws_sdk_sts::config::retry::RetryConfig;
|
||||
use aws_sdk_sts::config::{Credentials, Region};
|
||||
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 http::header::{CONTENT_TYPE, HOST};
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serial_test::serial;
|
||||
use std::error::Error;
|
||||
|
||||
type BoxError = Box<dyn Error + Send + Sync>;
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
async fn create_root_service_account(env: &RustFSTestEnvironment) -> Result<(String, String), BoxError> {
|
||||
let path = "/rustfs/admin/v3/add-service-accounts";
|
||||
let url = format!("{}{path}", env.url);
|
||||
let uri = url.parse::<http::Uri>()?;
|
||||
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
||||
let body = serde_json::json!({ "targetUser": env.access_key.clone() }).to_string();
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::PUT)
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||
.body(Body::empty())?;
|
||||
let content_length = i64::try_from(body.len()).map_err(|_| "service account request body is too large")?;
|
||||
let signed = sign_v4(request, content_length, &env.access_key, &env.secret_key, "", "us-east-1");
|
||||
let mut request = local_http_client().put(&url);
|
||||
for (name, value) in signed.headers() {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
let response = request.body(body).send().await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("create service account failed: {status} {body}").into());
|
||||
}
|
||||
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
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))
|
||||
}
|
||||
|
||||
async fn assert_chaining_denied(client: &Client, credential_kind: &str) -> TestResult {
|
||||
let error = client
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||
.role_session_name("sts-query-compat-e2e")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("credential chaining must be denied");
|
||||
let service_error = error
|
||||
.as_service_error()
|
||||
.ok_or_else(|| format!("{credential_kind} denial should deserialize as an STS service error: {error:?}"))?;
|
||||
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||
assert_eq!(service_error.code(), Some("AccessDenied"));
|
||||
assert_eq!(service_error.message(), Some("Access Denied"));
|
||||
assert!(
|
||||
error.request_id().is_some_and(|request_id| !request_id.is_empty()),
|
||||
"{credential_kind} denial should include a request ID"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let assumed = sts_client(&env.url, &env.access_key, &env.secret_key, None)
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||
.role_session_name("sts-query-compat-e2e")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
assumed.request_id().is_some_and(|request_id| !request_id.is_empty()),
|
||||
"successful AssumeRole should include a request ID"
|
||||
);
|
||||
let temporary = assumed
|
||||
.credentials()
|
||||
.ok_or("successful AssumeRole response should contain credentials")?;
|
||||
|
||||
let invalid_signature = sts_client(&env.url, &env.access_key, "incorrect-secret-key", None)
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||
.role_session_name("sts-query-invalid-signature")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an invalid signature must be rejected");
|
||||
assert_eq!(invalid_signature.raw_response().map(|response| response.status().as_u16()), Some(403));
|
||||
let invalid_signature_service_error = invalid_signature
|
||||
.as_service_error()
|
||||
.ok_or_else(|| format!("invalid signature should deserialize as an STS service error: {invalid_signature:?}"))?;
|
||||
assert_eq!(invalid_signature_service_error.code(), Some("SignatureDoesNotMatch"));
|
||||
assert!(
|
||||
invalid_signature_service_error
|
||||
.message()
|
||||
.is_some_and(|message| message.starts_with("The request signature we calculated does not match")),
|
||||
"signature rejection should preserve the canonical error message"
|
||||
);
|
||||
assert!(
|
||||
invalid_signature
|
||||
.request_id()
|
||||
.is_some_and(|request_id| !request_id.is_empty()),
|
||||
"signature rejection should include a request ID"
|
||||
);
|
||||
|
||||
assert_chaining_denied(
|
||||
&sts_client(
|
||||
&env.url,
|
||||
temporary.access_key_id(),
|
||||
temporary.secret_access_key(),
|
||||
Some(temporary.session_token()),
|
||||
),
|
||||
"temporary credential",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (service_access_key, service_secret_key) = create_root_service_account(&env).await?;
|
||||
assert_chaining_denied(&sts_client(&env.url, &service_access_key, &service_secret_key, None), "service account").await?;
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_sts_query_rate_limit_error_is_aws_sdk_compatible() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_API_RATE_LIMIT_ENABLE", "true"),
|
||||
("RUSTFS_API_RATE_LIMIT_RPM", "60"),
|
||||
("RUSTFS_API_RATE_LIMIT_BURST", "1"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client = sts_client(&env.url, &env.access_key, &env.secret_key, None);
|
||||
let mut throttled = None;
|
||||
let request = || {
|
||||
client
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||
.role_session_name("sts-query-rate-limit")
|
||||
.send()
|
||||
};
|
||||
let (first, second, third, fourth) = tokio::join!(request(), request(), request(), request());
|
||||
for result in [first, second, third, fourth] {
|
||||
if let Err(error) = result
|
||||
&& error.raw_response().map(|response| response.status().as_u16()) == Some(429)
|
||||
{
|
||||
throttled = Some(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let error = throttled.ok_or("at least one concurrent STS request should be throttled at burst one")?;
|
||||
let service_error = error
|
||||
.as_service_error()
|
||||
.ok_or_else(|| format!("rate limit response should deserialize as an STS service error: {error:?}"))?;
|
||||
assert_eq!(service_error.code(), Some("TooManyRequests"));
|
||||
assert!(
|
||||
service_error
|
||||
.message()
|
||||
.is_some_and(|message| message.starts_with("Request rate limit exceeded")),
|
||||
"rate limit response should preserve the server message"
|
||||
);
|
||||
assert!(
|
||||
error.request_id().is_some_and(|request_id| !request_id.is_empty()),
|
||||
"rate limit response should include a request ID"
|
||||
);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
@@ -43,10 +43,27 @@ pub mod bucket {
|
||||
|
||||
pub mod bucket_lifecycle_ops {
|
||||
pub use crate::bucket::lifecycle::bucket_lifecycle_ops::{
|
||||
ExpiryState, LifecycleOps, RestoreRequestOps, TransitionState, TransitionedObject, apply_expiry_rule,
|
||||
ExpiryState, LifecycleOps, ManualTransitionCancelCheck, ManualTransitionProgressSink,
|
||||
ManualTransitionQueueSnapshot, ManualTransitionRunExecution, ManualTransitionRunOptions,
|
||||
ManualTransitionRunReport, RestoreRequestOps, TransitionState, TransitionedObject, apply_expiry_rule,
|
||||
apply_transition_rule, enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
|
||||
enqueue_transition_for_existing_objects_scoped, enqueue_transition_for_existing_objects_scoped_with_cancel,
|
||||
enqueue_transition_immediate, expire_transitioned_object, get_global_expiry_state, get_global_transition_state,
|
||||
init_background_expiry, post_restore_opts, run_stale_multipart_upload_cleanup_once, validate_transition_tier,
|
||||
init_background_expiry, manual_transition_queue_snapshot, post_restore_opts,
|
||||
run_stale_multipart_upload_cleanup_once, validate_transition_tier,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod manual_transition_job {
|
||||
pub use crate::bucket::lifecycle::manual_transition_job::{
|
||||
ManualTransitionJobRecord, ManualTransitionJobState, ManualTransitionScopeAdmission,
|
||||
ManualTransitionScopeAdmissionClaim, claim_manual_transition_scope_admission,
|
||||
delete_manual_transition_scope_admission_if_current, load_manual_transition_job_record,
|
||||
load_manual_transition_job_record_with_etag, load_manual_transition_scope_admission,
|
||||
manual_transition_job_lease_expired, manual_transition_scope_admission_lease_expired,
|
||||
manual_transition_scope_key, persist_manual_transition_job_progress, renew_manual_transition_job_lease,
|
||||
request_manual_transition_job_cancel, save_manual_transition_job_record,
|
||||
save_manual_transition_job_record_if_current, save_manual_transition_scope_admission_if_absent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,12 +122,13 @@ pub mod bucket {
|
||||
|
||||
pub mod metadata_sys {
|
||||
pub use crate::bucket::metadata_sys::{
|
||||
BucketMetadataSys, delete, 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,
|
||||
BucketMetadataSys, acquire_bucket_targets_transaction_lock, delete, 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, remove_bucket_metadata, set_bucket_metadata, update,
|
||||
update_bucket_targets_under_transaction_lock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,18 +165,19 @@ pub mod bucket {
|
||||
|
||||
pub mod replication {
|
||||
pub use crate::bucket::replication::{
|
||||
BucketReplicationResyncStatus, BucketStats, DeletedObjectReplicationInfo, DynReplicationPool, MustReplicateOptions,
|
||||
ObjectOpts, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationConfig,
|
||||
ReplicationConfigurationExt, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource,
|
||||
ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO, ReplicationOperation, ReplicationPoolTrait,
|
||||
ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge, ReplicationState, ReplicationStats,
|
||||
ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError, ReplicationType, ResyncOpts,
|
||||
ResyncStatusType, TargetReplicationResyncStatus, VersionPurgeStatusType, delete_replication_state_from_config,
|
||||
delete_replication_version_id, get_global_replication_pool, get_global_replication_stats,
|
||||
init_background_replication, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
|
||||
replication_target_arns, should_remove_replication_target, should_schedule_delete_replication,
|
||||
should_use_existing_delete_replication_info, should_use_existing_delete_replication_source,
|
||||
validate_replication_config_target_arns, version_purge_status_to_filemeta,
|
||||
BucketReplicationResyncStatus, BucketStats, DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool,
|
||||
MrfOpKind, MrfReplicateEntry, MustReplicateOptions, ObjectOpts, REPLICATE_INCOMING_DELETE, ReplicateDecision,
|
||||
ReplicateObjectInfo, ReplicationConfig, ReplicationConfigurationExt, ReplicationDeleteScheduleInput,
|
||||
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
||||
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
||||
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
||||
ReplicationType, ResyncOpts, ResyncStatusType, TargetReplicationResyncStatus, VersionPurgeStatusType,
|
||||
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
|
||||
get_global_replication_stats, init_background_replication, 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, validate_replication_config_target_arns,
|
||||
version_purge_status_to_filemeta,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,19 +259,23 @@ pub mod config {
|
||||
pub mod com {
|
||||
pub use crate::config::com::{
|
||||
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
|
||||
ServerConfigCorruptError, delete_config, is_server_config_corrupt_error, lookup_configs, read_config,
|
||||
read_config_no_lock, read_config_with_metadata, read_config_without_migrate, save_config, save_config_with_opts,
|
||||
save_server_config, try_migrate_server_config,
|
||||
ServerConfigCorruptError, ServerConfigSnapshot, delete_config, is_server_config_corrupt_error, lookup_configs,
|
||||
read_config, read_config_no_lock, read_config_with_metadata, read_config_without_migrate,
|
||||
read_config_without_migrate_no_lock, read_existing_server_config_no_lock, read_server_config_snapshot, save_config,
|
||||
save_config_no_lock, save_config_with_opts, save_server_config, save_server_config_no_lock,
|
||||
save_server_config_snapshot, server_config_path, try_migrate_server_config, with_config_object_read_lock,
|
||||
with_config_object_write_lock, with_server_config_read_lock, with_server_config_write_lock,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod storageclass {
|
||||
pub use crate::config::storageclass::{
|
||||
CLASS_RRS, CLASS_STANDARD, Config, DEEP_ARCHIVE, DEFAULT_INLINE_BLOCK, DEFAULT_KVS, DEFAULT_RRS_PARITY,
|
||||
EXPRESS_ONEZONE, GLACIER, GLACIER_IR, INLINE_BLOCK, INLINE_BLOCK_ENV, INTELLIGENT_TIERING, MIN_PARITY_DRIVES,
|
||||
ONEZONE_IA, OPTIMIZE, OPTIMIZE_ENV, OUTPOSTS, RRS, RRS_ENV, SCHEME_PREFIX, SNOW, STANDARD, STANDARD_ENV, STANDARD_IA,
|
||||
StorageClass, default_parity_count, lookup_config, lookup_config_for_pools, parse_storage_class, validate_parity,
|
||||
validate_parity_inner,
|
||||
CAPABILITY_CONTRACT_VERSION, CLASS_RRS, CLASS_STANDARD, Config, DEEP_ARCHIVE, DEFAULT_INLINE_BLOCK, DEFAULT_KVS,
|
||||
DEFAULT_RRS_PARITY, EXPRESS_ONEZONE, GLACIER, GLACIER_IR, INLINE_BLOCK, INLINE_BLOCK_ENV, INTELLIGENT_TIERING,
|
||||
LEGACY_LABEL_BEHAVIOR, MIN_PARITY_DRIVES, ONEZONE_IA, OPTIMIZE, OPTIMIZE_ENV, OUTPOSTS, RRS, RRS_ENV, SCHEME_PREFIX,
|
||||
SNOW, STANDARD, STANDARD_ENV, STANDARD_IA, SUPPORTED_WRITE_CLASSES, StorageClass, UNSUPPORTED_WRITE_ERROR,
|
||||
default_parity_count, effective_class, is_supported_write_class, lookup_config, lookup_config_for_pools,
|
||||
parse_storage_class, validate_parity, validate_parity_inner,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,10 +287,10 @@ 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, live_bucket_usage_computations, 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,
|
||||
init_compression_total_memory_from_backend, invalidate_data_usage_snapshot_cache, live_bucket_usage_computations,
|
||||
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,
|
||||
refresh_bucket_usage_from_object_layer, refresh_versioned_bucket_usage_from_object_layer,
|
||||
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
|
||||
store_data_usage_in_backend,
|
||||
@@ -280,9 +303,9 @@ pub mod disk {
|
||||
pub use crate::disk::{
|
||||
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
||||
CheckPartsResp, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore,
|
||||
FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, OldCurrentSize, RUSTFS_META_BUCKET, ReadMultipleReq,
|
||||
ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
|
||||
new_disk, validate_batch_read_version_item_count,
|
||||
FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
|
||||
PartTransactionAction, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
|
||||
STORAGE_FORMAT_FILE, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, new_disk, validate_batch_read_version_item_count,
|
||||
};
|
||||
pub use bytes::Bytes;
|
||||
pub use endpoint::Endpoint;
|
||||
@@ -383,11 +406,12 @@ pub mod rio {
|
||||
|
||||
pub mod rpc {
|
||||
pub use crate::cluster::rpc::{
|
||||
LocalPeerS3Client, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client, SERVICE_SIGNAL_REFRESH_CONFIG,
|
||||
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity, TONIC_RPC_PREFIX, TonicInterceptor, gen_signature_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_tonic_rpc_response_proof, verify_rpc_signature, verify_tonic_canonical_body_digest, verify_tonic_rpc_response_proof,
|
||||
LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client, S3PeerSys,
|
||||
SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerBucketListing, ScannerPeerActivity,
|
||||
TONIC_RPC_PREFIX, TonicInterceptor, gen_signature_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, verify_rpc_signature,
|
||||
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof,
|
||||
verify_tonic_rpc_signature,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -807,15 +807,29 @@ impl BucketTargetSys {
|
||||
&& !new_targets.is_empty()
|
||||
{
|
||||
for target in &new_targets.targets {
|
||||
if let Ok(client) = self.get_remote_target_client_internal(target).await {
|
||||
arn_remotes_map.insert(
|
||||
target.arn.clone(),
|
||||
ArnTarget {
|
||||
client: Some(Arc::new(client)),
|
||||
last_refresh: OffsetDateTime::now_utc(),
|
||||
},
|
||||
);
|
||||
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
|
||||
match self.get_remote_target_client_internal(target).await {
|
||||
Ok(client) => {
|
||||
arn_remotes_map.insert(
|
||||
target.arn.clone(),
|
||||
ArnTarget {
|
||||
client: Some(Arc::new(client)),
|
||||
last_refresh: OffsetDateTime::now_utc(),
|
||||
},
|
||||
);
|
||||
self.update_bandwidth_limit(bucket, &target.arn, target.bandwidth_limit);
|
||||
}
|
||||
// The target stays in `targets_map`, so it keeps showing up in
|
||||
// `bucket remote ls` while no client exists to replicate through it —
|
||||
// replication then drops every object for this ARN. Without this the
|
||||
// rejection (loopback endpoint, bad CA, unparseable URL) left no trace
|
||||
// anywhere.
|
||||
Err(err) => warn!(
|
||||
bucket = %bucket,
|
||||
arn = %target.arn,
|
||||
endpoint = %target.endpoint,
|
||||
error = %err,
|
||||
"replication target client unavailable; objects for this ARN will not replicate"
|
||||
),
|
||||
}
|
||||
}
|
||||
targets_map.insert(bucket.to_string(), new_targets.targets.clone());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,11 @@ use http::HeaderMap;
|
||||
use rustfs_filemeta::FileInfo;
|
||||
|
||||
use crate::config::com;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
use crate::storage_api_contracts::{
|
||||
object::{DeletedObject, ObjectIO, ObjectOperations, ObjectToDelete},
|
||||
object::{DeletedObject, HTTPPreconditions, ObjectIO, ObjectOperations, ObjectToDelete},
|
||||
range::HTTPRangeSpec,
|
||||
};
|
||||
|
||||
@@ -40,6 +41,21 @@ where
|
||||
com::read_config(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_config_with_metadata<S>(api: Arc<S>, file: &str, opts: &ObjectOptions) -> Result<(Vec<u8>, ObjectInfo)>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
com::read_config_with_metadata(api, file, opts).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_config<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
@@ -55,6 +71,21 @@ where
|
||||
com::save_config(api, file, data).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_config_with_opts<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,
|
||||
>,
|
||||
{
|
||||
com::save_config_with_opts(api, file, data, opts).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_config<S>(api: Arc<S>, file: &str) -> Result<()>
|
||||
where
|
||||
S: ObjectOperations<
|
||||
@@ -68,3 +99,39 @@ where
|
||||
{
|
||||
com::delete_config(api, file).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_config_if_match<S>(api: Arc<S>, file: &str, etag: &str) -> Result<()>
|
||||
where
|
||||
S: ObjectOperations<
|
||||
Error = Error,
|
||||
ObjectInfo = ObjectInfo,
|
||||
ObjectOptions = ObjectOptions,
|
||||
FileInfo = FileInfo,
|
||||
ObjectToDelete = ObjectToDelete,
|
||||
DeletedObject = DeletedObject,
|
||||
>,
|
||||
{
|
||||
match api
|
||||
.delete_object(
|
||||
RUSTFS_META_BUCKET,
|
||||
file,
|
||||
ObjectOptions {
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(etag.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
|
||||
Err(Error::ConfigNotFound)
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ pub mod bucket_lifecycle_ops;
|
||||
mod config_boundary;
|
||||
pub mod core;
|
||||
pub mod evaluator;
|
||||
pub mod manual_transition_job;
|
||||
mod metadata_boundary;
|
||||
mod object_lock_boundary;
|
||||
pub use self::core as lifecycle;
|
||||
|
||||
@@ -20,7 +20,10 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::bucket::lifecycle::config_boundary;
|
||||
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent_with_manager_and_identity};
|
||||
use crate::bucket::lifecycle::tier_sweeper::{
|
||||
Jentry, delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||
};
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
@@ -42,6 +45,7 @@ const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
|
||||
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
|
||||
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
|
||||
pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -55,24 +59,35 @@ struct PersistedTierDeleteJournalEntry {
|
||||
backend_identity: Option<[u8; 32]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
version_id_exact: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
version_state: Option<rustfs_filemeta::TransitionVersionState>,
|
||||
}
|
||||
|
||||
impl PersistedTierDeleteJournalEntry {
|
||||
fn from_jentry(je: &Jentry) -> Self {
|
||||
Self {
|
||||
version: if je.version_id_exact {
|
||||
TIER_DELETE_JOURNAL_EXACT_VERSION
|
||||
} else if je.backend_identity.is_some() {
|
||||
fn from_jentry(je: &Jentry) -> Result<Self> {
|
||||
validate_version_state(je.version_state, &je.version_id, je.version_id_exact)?;
|
||||
let legacy_unknown = je.version_state == rustfs_filemeta::TransitionVersionState::Unknown;
|
||||
let version = if legacy_unknown {
|
||||
if je.backend_identity.is_some() {
|
||||
TIER_DELETE_JOURNAL_VERSION
|
||||
} else {
|
||||
1
|
||||
},
|
||||
}
|
||||
} else {
|
||||
if je.backend_identity.is_none() {
|
||||
return Err(Error::other("new tier delete journal entry is missing its backend identity"));
|
||||
}
|
||||
TIER_DELETE_JOURNAL_STATE_VERSION
|
||||
};
|
||||
Ok(Self {
|
||||
version,
|
||||
obj_name: je.obj_name.clone(),
|
||||
version_id: je.version_id.clone(),
|
||||
tier_name: je.tier_name.clone(),
|
||||
backend_identity: je.backend_identity,
|
||||
version_id_exact: je.version_id_exact.then_some(true),
|
||||
}
|
||||
version_state: (!legacy_unknown).then_some(je.version_state),
|
||||
})
|
||||
}
|
||||
|
||||
fn into_jentry(self) -> Result<Jentry> {
|
||||
@@ -84,19 +99,23 @@ impl PersistedTierDeleteJournalEntry {
|
||||
if self.obj_name.is_empty() || self.tier_name.is_empty() {
|
||||
return Err(Error::other("tier delete journal entry is incomplete"));
|
||||
}
|
||||
if self.version != TIER_DELETE_JOURNAL_EXACT_VERSION && self.version_id_exact.unwrap_or(false) {
|
||||
if self.version != TIER_DELETE_JOURNAL_EXACT_VERSION
|
||||
&& self.version != TIER_DELETE_JOURNAL_STATE_VERSION
|
||||
&& self.version_id_exact.unwrap_or(false)
|
||||
{
|
||||
return Err(Error::other(
|
||||
"legacy tier delete journal entry has an unsupported exact version constraint",
|
||||
));
|
||||
}
|
||||
let (backend_identity, version_id_exact) = match self.version {
|
||||
1 => (None, false),
|
||||
let (backend_identity, version_id_exact, version_state) = match self.version {
|
||||
1 => (None, false, rustfs_filemeta::TransitionVersionState::Unknown),
|
||||
TIER_DELETE_JOURNAL_VERSION => (
|
||||
Some(
|
||||
self.backend_identity
|
||||
.ok_or_else(|| Error::other("tier delete journal v2 entry is missing its backend identity"))?,
|
||||
),
|
||||
false,
|
||||
rustfs_filemeta::TransitionVersionState::Unknown,
|
||||
),
|
||||
TIER_DELETE_JOURNAL_EXACT_VERSION => {
|
||||
if self.version_id.is_empty() || self.version_id_exact != Some(true) {
|
||||
@@ -108,6 +127,22 @@ impl PersistedTierDeleteJournalEntry {
|
||||
.ok_or_else(|| Error::other("tier delete journal v3 entry is missing its backend identity"))?,
|
||||
),
|
||||
true,
|
||||
rustfs_filemeta::TransitionVersionState::Exact,
|
||||
)
|
||||
}
|
||||
TIER_DELETE_JOURNAL_STATE_VERSION => {
|
||||
let state = self
|
||||
.version_state
|
||||
.ok_or_else(|| Error::other("tier delete journal v4 entry is missing its version state"))?;
|
||||
let exact = self.version_id_exact.unwrap_or(false);
|
||||
validate_version_state(state, &self.version_id, exact)?;
|
||||
(
|
||||
Some(
|
||||
self.backend_identity
|
||||
.ok_or_else(|| Error::other("tier delete journal v4 entry is missing its backend identity"))?,
|
||||
),
|
||||
exact,
|
||||
state,
|
||||
)
|
||||
}
|
||||
version => return Err(Error::other(format!("unsupported tier delete journal version {version}"))),
|
||||
@@ -118,10 +153,30 @@ impl PersistedTierDeleteJournalEntry {
|
||||
tier_name: self.tier_name,
|
||||
backend_identity,
|
||||
version_id_exact,
|
||||
version_state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_version_state(
|
||||
state: rustfs_filemeta::TransitionVersionState,
|
||||
version_id: &str,
|
||||
version_id_exact: bool,
|
||||
) -> Result<()> {
|
||||
use rustfs_filemeta::TransitionVersionState::{Exact, KnownDisabled, SuspendedNull, Unknown};
|
||||
|
||||
let valid = match state {
|
||||
Unknown => !version_id_exact,
|
||||
KnownDisabled => version_id.is_empty() && !version_id_exact,
|
||||
SuspendedNull => version_id == "null" && version_id_exact,
|
||||
Exact => !version_id.is_empty() && version_id != "null" && version_id_exact,
|
||||
};
|
||||
if !valid {
|
||||
return Err(Error::other("tier delete journal version state conflicts with its version id"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TierDeleteJournalRecoveryStats {
|
||||
pub scanned: usize,
|
||||
@@ -152,14 +207,14 @@ pub(crate) fn tier_delete_journal_object_name(je: &Jentry) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn decode_tier_delete_journal_entry(data: &[u8]) -> Result<Jentry> {
|
||||
pub(crate) fn decode_tier_delete_journal_entry(data: &[u8]) -> Result<Jentry> {
|
||||
let persisted: PersistedTierDeleteJournalEntry =
|
||||
serde_json::from_slice(data).map_err(|err| Error::other(format!("decode tier delete journal failed: {err}")))?;
|
||||
persisted.into_jentry()
|
||||
}
|
||||
|
||||
fn encode_tier_delete_journal_entry(je: &Jentry) -> Result<Vec<u8>> {
|
||||
serde_json::to_vec(&PersistedTierDeleteJournalEntry::from_jentry(je))
|
||||
pub(crate) fn encode_tier_delete_journal_entry(je: &Jentry) -> Result<Vec<u8>> {
|
||||
serde_json::to_vec(&PersistedTierDeleteJournalEntry::from_jentry(je)?)
|
||||
.map_err(|err| Error::other(format!("encode tier delete journal failed: {err}")))
|
||||
}
|
||||
|
||||
@@ -209,18 +264,35 @@ where
|
||||
}
|
||||
|
||||
pub async fn process_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -> std::io::Result<()> {
|
||||
if je.version_state == rustfs_filemeta::TransitionVersionState::Unknown {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"tier delete journal remote version state is unknown",
|
||||
));
|
||||
}
|
||||
let backend_identity = je
|
||||
.backend_identity
|
||||
.ok_or_else(|| std::io::Error::other("legacy tier delete journal has no durable backend identity"))?;
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
&je.obj_name,
|
||||
&je.version_id,
|
||||
&je.tier_name,
|
||||
backend_identity,
|
||||
&api.tier_config_mgr(),
|
||||
je.version_id_exact,
|
||||
)
|
||||
.await?;
|
||||
if je.version_id_exact {
|
||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
&je.obj_name,
|
||||
&je.version_id,
|
||||
&je.tier_name,
|
||||
backend_identity,
|
||||
&api.tier_config_mgr(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
&je.obj_name,
|
||||
&je.version_id,
|
||||
&je.tier_name,
|
||||
backend_identity,
|
||||
&api.tier_config_mgr(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
remove_tier_delete_journal_entry(api, je).await
|
||||
}
|
||||
|
||||
@@ -406,8 +478,9 @@ where
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
TIER_DELETE_JOURNAL_EXACT_VERSION, await_tier_delete_journal_recovery, decode_tier_delete_journal_entry,
|
||||
encode_tier_delete_journal_entry, record_tier_delete_journal_backend_identity, tier_delete_journal_object_name,
|
||||
TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, await_tier_delete_journal_recovery,
|
||||
decode_tier_delete_journal_entry, encode_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
|
||||
tier_delete_journal_object_name,
|
||||
};
|
||||
use crate::bucket::lifecycle::tier_sweeper::Jentry;
|
||||
use crate::error::Result;
|
||||
@@ -420,7 +493,8 @@ mod tests {
|
||||
version_id: "remote-version".to_string(),
|
||||
tier_name: "WARM".to_string(),
|
||||
backend_identity: Some([7; 32]),
|
||||
version_id_exact: false,
|
||||
version_id_exact: true,
|
||||
version_state: rustfs_filemeta::TransitionVersionState::Exact,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,6 +510,7 @@ mod tests {
|
||||
assert_eq!(decoded.tier_name, je.tier_name);
|
||||
assert_eq!(decoded.backend_identity, je.backend_identity);
|
||||
assert_eq!(decoded.version_id_exact, je.version_id_exact);
|
||||
assert_eq!(decoded.version_state, je.version_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -450,7 +525,7 @@ mod tests {
|
||||
let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("exact journal JSON should decode");
|
||||
let decoded = decode_tier_delete_journal_entry(&encoded).expect("exact journal entry should decode");
|
||||
|
||||
assert_eq!(persisted["version"], TIER_DELETE_JOURNAL_EXACT_VERSION);
|
||||
assert_eq!(persisted["version"], TIER_DELETE_JOURNAL_STATE_VERSION);
|
||||
assert_eq!(persisted["version_id_exact"], true);
|
||||
assert!(decoded.version_id_exact);
|
||||
assert_ne!(tier_delete_journal_object_name(&exact), tier_delete_journal_object_name(&normalized));
|
||||
@@ -513,6 +588,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_rejects_conflicting_v4_version_states() {
|
||||
let identity = vec![7_u8; 32];
|
||||
let invalid = [
|
||||
("known-disabled", "unexpected", false),
|
||||
("suspended-null", "", true),
|
||||
("suspended-null", "null", false),
|
||||
("exact", "", true),
|
||||
("exact", "null", true),
|
||||
("exact", "version", false),
|
||||
("unknown", "version", true),
|
||||
];
|
||||
|
||||
for (state, version_id, exact) in invalid {
|
||||
let persisted = serde_json::json!({
|
||||
"version": TIER_DELETE_JOURNAL_STATE_VERSION,
|
||||
"obj_name": "remote/object",
|
||||
"version_id": version_id,
|
||||
"tier_name": "WARM",
|
||||
"backend_identity": identity,
|
||||
"version_id_exact": exact.then_some(true),
|
||||
"version_state": state,
|
||||
});
|
||||
let encoded = serde_json::to_vec(&persisted).expect("invalid journal fixture should encode");
|
||||
decode_tier_delete_journal_entry(&encoded).expect_err("conflicting v4 version state must fail closed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_journals_decode_with_unknown_version_state() {
|
||||
let v1 = br#"{"version":1,"obj_name":"remote/object","version_id":"opaque","tier_name":"WARM"}"#;
|
||||
let v2 = br#"{"version":2,"obj_name":"remote/object","version_id":"opaque","tier_name":"WARM","backend_identity":[7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7]}"#;
|
||||
|
||||
for payload in [v1.as_slice(), v2.as_slice()] {
|
||||
let decoded = decode_tier_delete_journal_entry(payload).expect("legacy journal should decode");
|
||||
assert_eq!(decoded.version_state, rustfs_filemeta::TransitionVersionState::Unknown);
|
||||
assert!(!decoded.version_id_exact);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_path_is_stable_and_sanitized() {
|
||||
let je = journal_entry();
|
||||
@@ -530,6 +645,8 @@ mod tests {
|
||||
fn tier_delete_journal_paths_separate_legacy_and_backend_identities() {
|
||||
let mut legacy = journal_entry();
|
||||
legacy.backend_identity = None;
|
||||
legacy.version_id_exact = false;
|
||||
legacy.version_state = rustfs_filemeta::TransitionVersionState::Unknown;
|
||||
let mut backend_a = journal_entry();
|
||||
backend_a.backend_identity = Some([1; 32]);
|
||||
let mut backend_b = journal_entry();
|
||||
@@ -575,6 +692,8 @@ mod tests {
|
||||
fn tier_delete_journal_without_transition_identity_stays_legacy() {
|
||||
let mut je = journal_entry();
|
||||
je.backend_identity = None;
|
||||
je.version_id_exact = false;
|
||||
je.version_state = rustfs_filemeta::TransitionVersionState::Unknown;
|
||||
|
||||
let encoded = encode_tier_delete_journal_entry(&je).expect("legacy journal should remain encodable");
|
||||
let persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("journal JSON should decode");
|
||||
|
||||
@@ -33,7 +33,6 @@ use rustfs_filemeta::FileInfo;
|
||||
|
||||
pub const DEFAULT_FREE_VERSION_RECOVERY_LIMIT: usize = 1_000;
|
||||
const DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT: usize = 10_000;
|
||||
const BACKGROUND_WALKDIR_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
#[cfg(not(test))]
|
||||
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[cfg(test)]
|
||||
@@ -42,6 +41,21 @@ const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, crate::error::Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
|
||||
WalkOptions {
|
||||
include_free_versions: true,
|
||||
limit,
|
||||
marker,
|
||||
// Total walk time scales with bucket size, so it is left unbounded
|
||||
// (Duration::ZERO disables the wall-clock budget). Per-call progress
|
||||
// stalls stay bounded by the drive-level stall budget inherited from
|
||||
// `RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS`.
|
||||
walkdir_timeout: Some(Duration::ZERO),
|
||||
walkdir_stall_timeout: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) enum RecoveryWalkTestAction {
|
||||
SendItemsThenError(Vec<ObjectInfo>, crate::error::Error),
|
||||
@@ -406,20 +420,8 @@ pub(super) async fn list_tier_free_versions(
|
||||
}
|
||||
}
|
||||
|
||||
api.walk(
|
||||
cancel,
|
||||
&bucket_name,
|
||||
"",
|
||||
tx,
|
||||
WalkOptions {
|
||||
include_free_versions: true,
|
||||
limit: walk_scan_limit,
|
||||
marker: object_marker,
|
||||
..Default::default()
|
||||
}
|
||||
.with_walkdir_timeouts(BACKGROUND_WALKDIR_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(walk_scan_limit, object_marker))
|
||||
.await
|
||||
}
|
||||
});
|
||||
|
||||
@@ -695,6 +697,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_walk_disables_total_timeout_and_inherits_stall_timeout() {
|
||||
let opts = recovery_walk_options(123, Some("marker".to_string()));
|
||||
|
||||
assert_eq!(opts.limit, 123);
|
||||
assert_eq!(opts.marker.as_deref(), Some("marker"));
|
||||
assert_eq!(opts.walkdir_timeout, Some(Duration::ZERO));
|
||||
assert_eq!(opts.walkdir_stall_timeout, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_truncation_keeps_marker_after_nonrecoverable_window() {
|
||||
let mut page = FreeVersionRecoveryPage {
|
||||
|
||||
@@ -185,6 +185,7 @@ struct ObjSweeper {
|
||||
transition_status: String,
|
||||
transition_tier: String,
|
||||
transition_version_id: String,
|
||||
transition_version_state: rustfs_filemeta::TransitionVersionState,
|
||||
remote_object: String,
|
||||
}
|
||||
|
||||
@@ -231,7 +232,9 @@ impl ObjSweeper {
|
||||
}
|
||||
|
||||
pub fn should_remove_remote_object(&self) -> Option<Jentry> {
|
||||
if self.transition_status != lifecycle::TRANSITION_COMPLETE {
|
||||
if self.transition_status != lifecycle::TRANSITION_COMPLETE
|
||||
|| self.transition_version_state == rustfs_filemeta::TransitionVersionState::Unknown
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -249,7 +252,11 @@ impl ObjSweeper {
|
||||
version_id: self.transition_version_id.clone(),
|
||||
tier_name: self.transition_tier.clone(),
|
||||
backend_identity: None,
|
||||
version_id_exact: false,
|
||||
version_id_exact: matches!(
|
||||
self.transition_version_state,
|
||||
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
|
||||
),
|
||||
version_state: self.transition_version_state,
|
||||
});
|
||||
}
|
||||
None
|
||||
@@ -286,6 +293,7 @@ pub struct Jentry {
|
||||
pub(crate) tier_name: String,
|
||||
pub(crate) backend_identity: Option<TierDestinationId>,
|
||||
pub(crate) version_id_exact: bool,
|
||||
pub(crate) version_state: rustfs_filemeta::TransitionVersionState,
|
||||
}
|
||||
|
||||
impl ExpiryOp for Jentry {
|
||||
@@ -330,7 +338,7 @@ async fn delete_object_from_remote_tier_raw_with_manager(
|
||||
let lease = TierConfigMgr::acquire_operation_lease(&tier_config_mgr, tier_name)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false).await
|
||||
delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false, true).await
|
||||
}
|
||||
|
||||
async fn delete_object_from_remote_tier_raw_with_lease(
|
||||
@@ -338,7 +346,12 @@ async fn delete_object_from_remote_tier_raw_with_lease(
|
||||
rv_id: &str,
|
||||
lease: &TierOperationLease,
|
||||
version_id_exact: bool,
|
||||
validate_remote_version_id: bool,
|
||||
) -> Result<(), std::io::Error> {
|
||||
if validate_remote_version_id {
|
||||
lease.validate_remote_version_id(rv_id)?;
|
||||
}
|
||||
|
||||
if remote_delete_breaker_is_open(Instant::now()).await {
|
||||
metrics::counter!(METRIC_DELETE_REMOTE_BREAKER_TOTAL).increment(1);
|
||||
return Err(std::io::Error::other(ERR_REMOTE_DELETE_BREAKER_OPEN));
|
||||
@@ -433,7 +446,53 @@ pub(crate) async fn delete_object_from_remote_tier_with_lease_idempotent(
|
||||
lease: &TierOperationLease,
|
||||
version_id_exact: bool,
|
||||
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
|
||||
match delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, lease, version_id_exact).await {
|
||||
delete_object_from_remote_tier_with_lease_idempotent_inner(obj_name, rv_id, lease, version_id_exact, true).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_lease_idempotent(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
lease: &TierOperationLease,
|
||||
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
|
||||
if rv_id.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"confirmed versioned transition candidate requires a non-empty remote version",
|
||||
));
|
||||
}
|
||||
#[cfg(test)]
|
||||
if obj_name == "remote/empty-guard-probe" {
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
delete_object_from_remote_tier_with_lease_idempotent_inner(obj_name, rv_id, lease, true, false).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
tier_name: &str,
|
||||
backend_identity: TierDestinationId,
|
||||
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
|
||||
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
|
||||
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(tier_config_mgr, tier_name, backend_identity)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
delete_confirmed_transition_candidate_exact_with_lease_idempotent(obj_name, rv_id, &lease).await
|
||||
}
|
||||
|
||||
async fn delete_object_from_remote_tier_with_lease_idempotent_inner(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
lease: &TierOperationLease,
|
||||
version_id_exact: bool,
|
||||
validate_remote_version_id: bool,
|
||||
) -> Result<RemoteTierDeleteOutcome, std::io::Error> {
|
||||
match delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, lease, version_id_exact, validate_remote_version_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(RemoteTierDeleteOutcome::Deleted),
|
||||
Err(err) if is_remote_tier_not_found_error(&err) => Ok(RemoteTierDeleteOutcome::AlreadyRemoved),
|
||||
Err(err) => {
|
||||
@@ -458,6 +517,7 @@ pub fn transitioned_delete_journal_entry(
|
||||
versioned: bool,
|
||||
suspended: bool,
|
||||
transitioned: &TransitionedObject,
|
||||
transition_version_state: rustfs_filemeta::TransitionVersionState,
|
||||
) -> Option<Jentry> {
|
||||
let sweeper = ObjSweeper {
|
||||
version_id,
|
||||
@@ -466,6 +526,7 @@ pub fn transitioned_delete_journal_entry(
|
||||
transition_status: transitioned.status.clone(),
|
||||
transition_tier: transitioned.tier.clone(),
|
||||
transition_version_id: transitioned.version_id.clone(),
|
||||
transition_version_state,
|
||||
remote_object: transitioned.name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -473,8 +534,13 @@ pub fn transitioned_delete_journal_entry(
|
||||
sweeper.should_remove_remote_object()
|
||||
}
|
||||
|
||||
pub fn transitioned_force_delete_journal_entry(transitioned: &TransitionedObject) -> Option<Jentry> {
|
||||
if transitioned.status != lifecycle::TRANSITION_COMPLETE {
|
||||
pub fn transitioned_force_delete_journal_entry(
|
||||
transitioned: &TransitionedObject,
|
||||
transition_version_state: rustfs_filemeta::TransitionVersionState,
|
||||
) -> Option<Jentry> {
|
||||
if transitioned.status != lifecycle::TRANSITION_COMPLETE
|
||||
|| transition_version_state == rustfs_filemeta::TransitionVersionState::Unknown
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -483,7 +549,11 @@ pub fn transitioned_force_delete_journal_entry(transitioned: &TransitionedObject
|
||||
version_id: transitioned.version_id.clone(),
|
||||
tier_name: transitioned.tier.clone(),
|
||||
backend_identity: None,
|
||||
version_id_exact: false,
|
||||
version_id_exact: matches!(
|
||||
transition_version_state,
|
||||
rustfs_filemeta::TransitionVersionState::SuspendedNull | rustfs_filemeta::TransitionVersionState::Exact
|
||||
),
|
||||
version_state: transition_version_state,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -492,11 +562,14 @@ mod test {
|
||||
use crate::client::signer_error::invalid_utf8_header_error;
|
||||
|
||||
use super::{
|
||||
ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, RemoteDeleteBreaker, RemoteTierDeleteOutcome,
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED,
|
||||
RemoteDeleteBreaker, RemoteTierDeleteOutcome, delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||
delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||
is_remote_tier_not_found_error, is_signer_header_error, set_remote_tier_delete_test_hook,
|
||||
should_record_remote_delete_failure,
|
||||
is_remote_tier_not_found_error, is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook,
|
||||
should_record_remote_delete_failure, transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
|
||||
};
|
||||
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
||||
use rustfs_filemeta::TransitionVersionState;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -540,6 +613,43 @@ mod test {
|
||||
assert!(should_record_remote_delete_failure(&Error::other("NoSuchVersion")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitioned_delete_journal_preserves_remote_version_state() {
|
||||
let cases = [
|
||||
(TransitionVersionState::Unknown, "legacy-version", None),
|
||||
(TransitionVersionState::KnownDisabled, "", Some(false)),
|
||||
(TransitionVersionState::SuspendedNull, "null", Some(true)),
|
||||
(TransitionVersionState::Exact, "opaque-version", Some(true)),
|
||||
];
|
||||
|
||||
for (state, version_id, expected_exact) in cases {
|
||||
let transitioned = TransitionedObject {
|
||||
name: "remote/object".to_string(),
|
||||
version_id: version_id.to_string(),
|
||||
tier: "WARM".to_string(),
|
||||
status: lifecycle::TRANSITION_COMPLETE.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let regular = transitioned_delete_journal_entry(None, false, false, &transitioned, state);
|
||||
let forced = transitioned_force_delete_journal_entry(&transitioned, state);
|
||||
|
||||
match expected_exact {
|
||||
Some(expected_exact) => {
|
||||
let regular = regular.expect("known version state should produce a regular delete journal entry");
|
||||
assert_eq!(regular.version_state, state);
|
||||
assert_eq!(regular.version_id_exact, expected_exact);
|
||||
let forced = forced.expect("known version state should produce a forced delete journal entry");
|
||||
assert_eq!(forced.version_state, state);
|
||||
assert_eq!(forced.version_id_exact, expected_exact);
|
||||
}
|
||||
None => {
|
||||
assert!(regular.is_none());
|
||||
assert!(forced.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn idempotent_remote_delete_treats_hooked_nosuchversion_as_already_removed() {
|
||||
@@ -622,6 +732,95 @@ mod test {
|
||||
assert_eq!(backend.remove_count().await, 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
async fn journal_delete_rejects_nonempty_remote_version_before_backend_io() {
|
||||
let manager = crate::services::tier::tier::TierConfigMgr::new();
|
||||
let backend = crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
|
||||
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
|
||||
.await
|
||||
.expect("test tier lease should be available");
|
||||
let identity = lease.backend_identity();
|
||||
drop(lease);
|
||||
backend.set_reject_non_empty_remote_versions(true);
|
||||
|
||||
let err = delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
"remote/object",
|
||||
"remote-version",
|
||||
"WARM",
|
||||
identity,
|
||||
&manager,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect_err("a provider that rejects a versioned delete must fail before remote IO");
|
||||
|
||||
assert!(err.to_string().contains("requires an unversioned remote object"));
|
||||
assert_eq!(backend.remove_count().await, 0);
|
||||
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
|
||||
"remote/object",
|
||||
"",
|
||||
"WARM",
|
||||
identity,
|
||||
&manager,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("unversioned remote delete should continue without a version ID");
|
||||
|
||||
assert_eq!(backend.remove_versions().await, vec![("remote/object".to_string(), String::new())]);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn confirmed_transition_cleanup_deletes_exact_provider_token() {
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
let manager = crate::services::tier::tier::TierConfigMgr::new();
|
||||
let backend = crate::services::tier::test_util::register_mock_tier(&manager, "WARM").await;
|
||||
let lease = crate::services::tier::tier::TierConfigMgr::acquire_operation_lease(&manager, "WARM")
|
||||
.await
|
||||
.expect("test tier lease should be available");
|
||||
let identity = lease.backend_identity();
|
||||
drop(lease);
|
||||
backend.set_reject_non_empty_remote_versions(true);
|
||||
|
||||
let outcome = delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
"remote/object",
|
||||
"provider-version-token",
|
||||
"WARM",
|
||||
identity,
|
||||
&manager,
|
||||
)
|
||||
.await
|
||||
.expect("confirmed upload compensation should delete the exact provider token");
|
||||
|
||||
assert_eq!(outcome, RemoteTierDeleteOutcome::Deleted);
|
||||
assert_eq!(backend.exact_remove_count(), 1);
|
||||
assert_eq!(
|
||||
backend.remove_versions().await,
|
||||
vec![("remote/object".to_string(), "provider-version-token".to_string())]
|
||||
);
|
||||
|
||||
let err = delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
"remote/empty-guard-probe",
|
||||
"",
|
||||
"WARM",
|
||||
identity,
|
||||
&manager,
|
||||
)
|
||||
.await
|
||||
.expect_err("confirmed versioned cleanup must reject an empty token");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(backend.remove_count().await, 1);
|
||||
assert_eq!(
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES.load(std::sync::atomic::Ordering::Relaxed),
|
||||
0,
|
||||
"empty remote versions must be rejected before exact cleanup dispatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breaker_opens_at_threshold_and_recovers_after_window() {
|
||||
let mut breaker = RemoteDeleteBreaker::new(3, Duration::from_secs(30));
|
||||
|
||||
@@ -22,10 +22,14 @@ use uuid::Uuid;
|
||||
|
||||
use crate::bucket::lifecycle::config_boundary;
|
||||
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
||||
use crate::bucket::lifecycle::tier_sweeper::delete_object_from_remote_tier_idempotent_with_manager_and_identity;
|
||||
use crate::bucket::lifecycle::tier_sweeper::{
|
||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||
};
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result as EcstoreResult};
|
||||
use crate::object_api::ObjectOptions;
|
||||
use crate::services::tier::{tier::TierConfigMgr, warm_backend::TransitionCandidateProbe};
|
||||
use crate::storage_api_contracts::{list::ListOperations as _, object::ObjectOperations as _};
|
||||
use crate::store::ECStore;
|
||||
|
||||
@@ -612,6 +616,11 @@ pub enum TransitionTransactionRecoveryOutcome {
|
||||
Retained,
|
||||
}
|
||||
|
||||
pub(crate) fn decode_transition_transaction_record(object: &str, data: &[u8]) -> Result<TransitionTransaction> {
|
||||
let transaction_id = transition_transaction_id_from_record_object_name(object)?;
|
||||
TransitionTransaction::decode(transaction_id, data)
|
||||
}
|
||||
|
||||
fn transition_transaction_id_from_record_object_name(object: &str) -> Result<Uuid> {
|
||||
let prefix = format!("{TRANSITION_TRANSACTION_RECORD_PREFIX}/");
|
||||
let suffix = object
|
||||
@@ -658,20 +667,99 @@ pub async fn process_transition_transaction_record(
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
TransitionTransactionState::LocalCommitStarted if local_commit_matches_transaction(api.clone(), transaction).await? => {
|
||||
delete_transition_transaction_record(api, transaction.transaction_id).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
TransitionTransactionState::LocalCommitStarted => {
|
||||
match local_commit_matches_transaction(api.clone(), transaction).await {
|
||||
Ok(true) => {
|
||||
delete_transition_transaction_record(api, transaction.transaction_id).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
TransitionTransactionState::AbortedNoRemote | TransitionTransactionState::Committed => {
|
||||
delete_transition_transaction_record(api, transaction.transaction_id).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
TransitionTransactionState::UploadStarted
|
||||
| TransitionTransactionState::UploadOutcomeUnknown
|
||||
| TransitionTransactionState::LocalCommitStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
TransitionTransactionState::UploadOutcomeUnknown => recover_unknown_upload_outcome(api, transaction).await,
|
||||
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
}
|
||||
}
|
||||
|
||||
async fn recover_unknown_upload_outcome(
|
||||
api: Arc<ECStore>,
|
||||
transaction: &TransitionTransaction,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(
|
||||
&api.tier_config_mgr(),
|
||||
&transaction.tier_name,
|
||||
transaction.backend_fingerprint,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::other)?;
|
||||
|
||||
match lease
|
||||
.probe_transition_candidate_for(&transaction.remote_object, transaction.transaction_id)
|
||||
.await
|
||||
.map_err(Error::other)?
|
||||
{
|
||||
TransitionCandidateProbe::Missing => {
|
||||
delete_transition_transaction_record(api, transaction.transaction_id).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
TransitionCandidateProbe::UnversionedPresent => {
|
||||
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::unversioned()).await
|
||||
}
|
||||
TransitionCandidateProbe::VersionedPresent(version_id)
|
||||
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
|
||||
{
|
||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
&transaction.remote_object,
|
||||
&version_id,
|
||||
&transaction.tier_name,
|
||||
transaction.backend_fingerprint,
|
||||
&api.tier_config_mgr(),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::other)?;
|
||||
delete_transition_transaction_record(api, transaction.transaction_id).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
TransitionCandidateProbe::VersionedPresent(version_id) => {
|
||||
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
|
||||
}
|
||||
TransitionCandidateProbe::Ambiguous | TransitionCandidateProbe::Unsupported => {
|
||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_recovered_unknown_upload_candidate(
|
||||
api: Arc<ECStore>,
|
||||
transaction: &TransitionTransaction,
|
||||
remote_version: TransitionRemoteVersion,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
let mut cleanup = transaction.clone();
|
||||
cleanup
|
||||
.mark_cleanup_pending(
|
||||
transaction.fence(),
|
||||
TransitionCleanupProof {
|
||||
transaction_id: transaction.transaction_id,
|
||||
write_id: transaction.write_id,
|
||||
remote_object: transaction.remote_object.clone(),
|
||||
remote_version,
|
||||
backend_fingerprint: transaction.backend_fingerprint,
|
||||
decision: TransitionCleanupDecision::RemoteVersionRecoveredAfterCancellation,
|
||||
},
|
||||
)
|
||||
.map_err(transition_transaction_store_error)?;
|
||||
save_transition_transaction_record(api.clone(), &cleanup).await?;
|
||||
delete_transition_remote_candidate(api.clone(), &cleanup).await?;
|
||||
delete_transition_transaction_record(api, cleanup.transaction_id).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
|
||||
fn transition_source_is_missing(err: &Error) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
|
||||
@@ -649,7 +649,7 @@ impl BucketMetadata {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_timestamps(&mut self) {
|
||||
pub(crate) fn default_timestamps(&mut self) {
|
||||
if self.policy_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
||||
self.policy_config_updated_at = self.created
|
||||
}
|
||||
@@ -1093,16 +1093,25 @@ pub async fn load_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<Buc
|
||||
}
|
||||
|
||||
pub async fn load_bucket_metadata_parse(api: Arc<ECStore>, bucket: &str, parse: bool) -> Result<BucketMetadata> {
|
||||
let mut bm = match read_bucket_metadata(api.clone(), bucket).await {
|
||||
Ok(res) => res,
|
||||
Ok(load_bucket_metadata_parse_with_presence(api, bucket, parse).await?.0)
|
||||
}
|
||||
|
||||
/// The returned `bool` reports whether the metadata was actually read from
|
||||
/// persisted storage; `false` means no metadata exists for this bucket on this
|
||||
/// store and the returned value is a fabricated in-memory default.
|
||||
pub(crate) async fn load_bucket_metadata_parse_with_presence(
|
||||
api: Arc<ECStore>,
|
||||
bucket: &str,
|
||||
parse: bool,
|
||||
) -> Result<(BucketMetadata, bool)> {
|
||||
let (mut bm, persisted) = match read_bucket_metadata(api.clone(), bucket).await {
|
||||
Ok(res) => (res, true),
|
||||
Err(err) => {
|
||||
if err != Error::ConfigNotFound {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// info!("bucketmeta {} not found with err {:?}, start to init ", bucket, &err);
|
||||
|
||||
BucketMetadata::new(bucket)
|
||||
(BucketMetadata::new(bucket), false)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1112,7 +1121,7 @@ pub async fn load_bucket_metadata_parse(api: Arc<ECStore>, bucket: &str, parse:
|
||||
bm.parse_all_configs()?;
|
||||
}
|
||||
|
||||
Ok(bm)
|
||||
Ok((bm, persisted))
|
||||
}
|
||||
|
||||
async fn read_bucket_metadata(api: Arc<ECStore>, bucket: &str) -> Result<BucketMetadata> {
|
||||
|
||||
@@ -12,15 +12,17 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::metadata::{BucketMetadata, load_bucket_metadata};
|
||||
use super::metadata::{BUCKET_TARGETS_FILE, BucketMetadata, load_bucket_metadata};
|
||||
use super::quota::BucketQuota;
|
||||
use super::target::BucketTargets;
|
||||
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
||||
use crate::bucket::metadata::load_bucket_metadata_parse;
|
||||
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
|
||||
use crate::bucket::utils::is_meta_bucketname;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result, is_err_bucket_not_found};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use crate::store::ECStore;
|
||||
use futures::future::join_all;
|
||||
use rustfs_common::heal_channel::HealOpts;
|
||||
@@ -188,7 +190,7 @@ pub async fn get(bucket: &str) -> Result<Arc<BucketMetadata>> {
|
||||
// instance cell is not initialized yet (early startup) they fall back to the
|
||||
// ambient default — the single-instance legacy behavior.
|
||||
|
||||
fn bucket_metadata_sys_of(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<RwLock<BucketMetadataSys>>> {
|
||||
pub(crate) fn bucket_metadata_sys_of(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<RwLock<BucketMetadataSys>>> {
|
||||
if let Some(sys) = ctx.bucket_metadata_sys() {
|
||||
return Ok(sys);
|
||||
}
|
||||
@@ -221,11 +223,35 @@ pub(crate) async fn remove_bucket_metadata_in(ctx: &crate::runtime::instance::In
|
||||
|
||||
pub async fn update(bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let _targets_guard = if config_file == BUCKET_TARGETS_FILE {
|
||||
Some(acquire_bucket_targets_transaction_lock(bucket).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||
|
||||
bucket_meta_sys.update(bucket, config_file, data).await
|
||||
}
|
||||
|
||||
pub async fn update_bucket_targets_under_transaction_lock(bucket: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||
bucket_meta_sys.update(bucket, BUCKET_TARGETS_FILE, data).await
|
||||
}
|
||||
|
||||
pub async fn acquire_bucket_targets_transaction_lock(bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let api = bucket_meta_sys_lock.read().await.object_store();
|
||||
let lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_targets_transaction_lock_key(bucket))
|
||||
.await?;
|
||||
Ok(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?)
|
||||
}
|
||||
|
||||
fn bucket_targets_transaction_lock_key(bucket: &str) -> String {
|
||||
format!("bucket-targets/{bucket}/transaction.lock")
|
||||
}
|
||||
|
||||
pub async fn delete(bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||
@@ -396,9 +422,23 @@ pub async fn list_bucket_targets(bucket: &str) -> Result<BucketTargets> {
|
||||
bucket_meta_sys.get_bucket_targets_config(bucket).await
|
||||
}
|
||||
|
||||
/// Bound and lifetime of the negative cache for buckets with no persisted
|
||||
/// metadata. Entries are invalidated the moment real metadata is cached, so
|
||||
/// the TTL only bounds staleness for out-of-band creations whose reload
|
||||
/// notification was lost; the capacity bounds memory under bogus-name floods.
|
||||
const ABSENT_BUCKET_METADATA_TTL: Duration = Duration::from_secs(30);
|
||||
const ABSENT_BUCKET_METADATA_MAX_ENTRIES: u64 = 10_000;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BucketMetadataSys {
|
||||
metadata_map: RwLock<HashMap<String, Arc<BucketMetadata>>>,
|
||||
/// Buckets recently observed to have no persisted metadata. Serving the
|
||||
/// fabricated default from here (instead of re-reading disk) keeps the
|
||||
/// per-request cost of repeated lookups for such names bounded — without
|
||||
/// this, every request naming a nonexistent bucket pays a namespace-lock
|
||||
/// acquisition plus a full erasure-set metadata fanout (reachable
|
||||
/// pre-auth via CORS preflight, and per-key in DeleteObjects).
|
||||
absent_metadata: moka::future::Cache<String, ()>,
|
||||
api: Arc<ECStore>,
|
||||
initialized: RwLock<bool>,
|
||||
}
|
||||
@@ -407,6 +447,10 @@ impl BucketMetadataSys {
|
||||
pub fn new(api: Arc<ECStore>) -> Self {
|
||||
Self {
|
||||
metadata_map: RwLock::new(HashMap::new()),
|
||||
absent_metadata: moka::future::Cache::builder()
|
||||
.max_capacity(ABSENT_BUCKET_METADATA_MAX_ENTRIES)
|
||||
.time_to_live(ABSENT_BUCKET_METADATA_TTL)
|
||||
.build(),
|
||||
api,
|
||||
initialized: RwLock::new(false),
|
||||
}
|
||||
@@ -461,7 +505,7 @@ impl BucketMetadataSys {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
load_bucket_metadata(self.api.clone(), bucket.as_str()).await
|
||||
load_bucket_metadata_parse_with_presence(self.api.clone(), bucket.as_str(), true).await
|
||||
});
|
||||
}
|
||||
|
||||
@@ -469,9 +513,24 @@ impl BucketMetadataSys {
|
||||
|
||||
for (idx, res) in results.into_iter().enumerate() {
|
||||
match res {
|
||||
Ok(res) => {
|
||||
Ok((bm, persisted)) => {
|
||||
if let Some(bucket) = buckets.get(idx) {
|
||||
self.set(bucket.clone(), Arc::new(res)).await;
|
||||
if persisted {
|
||||
self.set(bucket.clone(), Arc::new(bm)).await;
|
||||
} else {
|
||||
// A fabricated default (no persisted metadata
|
||||
// readable right now) must never REPLACE an
|
||||
// existing entry: the periodic refresh would
|
||||
// otherwise downgrade a lock-enabled bucket to an
|
||||
// authoritative "no lock" default on a transient
|
||||
// ConfigNotFound, disabling the object-lock
|
||||
// delete gate and wiping its target/durability
|
||||
// sync state. Insert-if-vacant keeps the startup
|
||||
// behavior for legacy buckets without a metadata
|
||||
// file, atomically under the map write lock.
|
||||
let mut map = self.metadata_map.write().await;
|
||||
map.entry(bucket.clone()).or_insert_with(|| Arc::new(bm));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -502,6 +561,8 @@ impl BucketMetadataSys {
|
||||
let mut map = self.metadata_map.write().await;
|
||||
map.insert(bucket.clone(), bm.clone());
|
||||
drop(map);
|
||||
// Real metadata supersedes any recorded absence immediately.
|
||||
self.absent_metadata.invalidate(&bucket).await;
|
||||
sync_bucket_target_sys(&bucket, &bm).await;
|
||||
sync_bucket_durability(&bucket, &bm);
|
||||
}
|
||||
@@ -604,13 +665,22 @@ impl BucketMetadataSys {
|
||||
pub async fn get_config(&self, bucket: &str) -> Result<(Arc<BucketMetadata>, bool)> {
|
||||
let has_bm = {
|
||||
let map = self.metadata_map.read().await;
|
||||
map.get(&bucket.to_string()).cloned()
|
||||
map.get(bucket).cloned()
|
||||
};
|
||||
|
||||
if let Some(bm) = has_bm {
|
||||
Ok((bm, false))
|
||||
} else {
|
||||
let bm = match load_bucket_metadata(self.api.clone(), bucket).await {
|
||||
// A recent lookup already established there is no persisted
|
||||
// metadata: serve the fabricated default without another
|
||||
// namespace-lock + erasure-set fanout.
|
||||
if self.absent_metadata.get(bucket).await.is_some() {
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.default_timestamps();
|
||||
return Ok((Arc::new(bm), true));
|
||||
}
|
||||
|
||||
let (bm, persisted) = match load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true).await {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
return if *self.initialized.read().await {
|
||||
@@ -621,13 +691,27 @@ impl BucketMetadataSys {
|
||||
}
|
||||
};
|
||||
|
||||
let mut map = self.metadata_map.write().await;
|
||||
|
||||
let bm = Arc::new(bm);
|
||||
map.insert(bucket.to_string(), bm.clone());
|
||||
drop(map);
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
|
||||
// This lazy path caches only metadata that actually exists on
|
||||
// this store. A fabricated default must not enter the map:
|
||||
// `get()` is map-only and fail-closed — the object-lock delete
|
||||
// gate (`object_lock_delete_check_required`) skips its per-object
|
||||
// protection stat exactly when the map serves metadata saying the
|
||||
// bucket has no Object Lock, so caching a fabricated default here
|
||||
// would turn a metadata miss into an authoritative "no lock"
|
||||
// answer. (Startup `concurrent_load` still caches fabricated
|
||||
// defaults for buckets listed on disk — legacy buckets without a
|
||||
// metadata file — but never lets one replace an existing entry.)
|
||||
if persisted {
|
||||
let mut map = self.metadata_map.write().await;
|
||||
map.insert(bucket.to_string(), bm.clone());
|
||||
drop(map);
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
} else {
|
||||
self.absent_metadata.insert(bucket.to_string(), ()).await;
|
||||
}
|
||||
|
||||
Ok((bm, true))
|
||||
}
|
||||
@@ -657,6 +741,8 @@ impl BucketMetadataSys {
|
||||
|
||||
if let Some(config) = &bm.policy_config {
|
||||
Ok((config.clone(), bm.policy_config_updated_at))
|
||||
} else if !bm.policy_config_json.is_empty() {
|
||||
Ok((serde_json::from_slice(&bm.policy_config_json)?, bm.policy_config_updated_at))
|
||||
} else {
|
||||
Err(Error::ConfigNotFound)
|
||||
}
|
||||
@@ -847,13 +933,142 @@ impl BucketMetadataSys {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only fixture shared with sibling modules (e.g. the quota checker
|
||||
/// tests): a 4-disk `ECStore` on an isolated instance context, so tests
|
||||
/// exercising the metadata system never touch ambient process state.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
use super::*;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::store::init_local_disks_with_instance_ctx;
|
||||
|
||||
pub(crate) async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
let mut dirs = Vec::with_capacity(4);
|
||||
let mut endpoints = Vec::with_capacity(4);
|
||||
for disk_idx in 0..4 {
|
||||
let dir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let mut endpoint =
|
||||
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(disk_idx);
|
||||
dirs.push(dir);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 4,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "metadata-sys-cache-test".to_string(),
|
||||
platform: "test".to_string(),
|
||||
}]);
|
||||
let instance_ctx = Arc::new(InstanceContext::new());
|
||||
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
||||
.await
|
||||
.expect("local disks should initialize");
|
||||
let ecstore = ECStore::new_with_instance_ctx(
|
||||
"127.0.0.1:0".parse().expect("test address"),
|
||||
endpoint_pools,
|
||||
CancellationToken::new(),
|
||||
instance_ctx,
|
||||
)
|
||||
.await
|
||||
.expect("ECStore should initialize");
|
||||
(dirs, ecstore)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_support::isolated_store_over_temp_disks;
|
||||
use super::*;
|
||||
use crate::bucket::target::{BucketTarget, BucketTargetType, Credentials};
|
||||
use serial_test::serial;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Pins the fail-closed caching contract of the lazy `get_config` path
|
||||
/// and the refresh no-replace rule: fabricated defaults are returned but
|
||||
/// never served by the map-only `get()`, persisted metadata is cached on
|
||||
/// lazy load (superseding a recorded absence), and a refresh-load miss
|
||||
/// never replaces an existing entry.
|
||||
#[tokio::test]
|
||||
async fn get_config_never_caches_fabricated_defaults_as_authoritative() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
|
||||
// (a) Miss: the fabricated default is returned but not cached.
|
||||
let (bm, _) = sys
|
||||
.get_config("absent-bucket")
|
||||
.await
|
||||
.expect("fabricated default should be returned");
|
||||
assert!(bm.object_lock_config_xml.is_empty());
|
||||
assert!(
|
||||
sys.get("absent-bucket").await.is_err(),
|
||||
"a fabricated default must never be served by the map-only get()"
|
||||
);
|
||||
|
||||
// The repeat lookup is served from the negative cache, same answer.
|
||||
let (bm, _) = sys
|
||||
.get_config("absent-bucket")
|
||||
.await
|
||||
.expect("negative-cached default should be returned");
|
||||
assert!(bm.object_lock_config_xml.is_empty());
|
||||
assert!(sys.get("absent-bucket").await.is_err());
|
||||
|
||||
// (b) Persisting real metadata supersedes the recorded absence, and a
|
||||
// lazy reload after a map wipe re-caches it.
|
||||
let mut persisted = BucketMetadata::new("absent-bucket");
|
||||
persisted.policy_config_json = b"persisted-marker".to_vec();
|
||||
sys.persist_and_set(persisted).await.expect("metadata should persist");
|
||||
sys.metadata_map.write().await.clear();
|
||||
let _ = sys
|
||||
.get_config("absent-bucket")
|
||||
.await
|
||||
.expect("persisted metadata should lazily reload");
|
||||
let cached = sys
|
||||
.get("absent-bucket")
|
||||
.await
|
||||
.expect("lazily loaded persisted metadata must be cached");
|
||||
assert_eq!(cached.policy_config_json, b"persisted-marker".to_vec());
|
||||
|
||||
// (c) A refresh-load miss (no persisted metadata readable) must not
|
||||
// replace an existing entry.
|
||||
let mut kept = BucketMetadata::new("kept-bucket");
|
||||
kept.policy_config_json = b"kept-marker".to_vec();
|
||||
sys.set("kept-bucket".to_string(), Arc::new(kept)).await;
|
||||
let mut failed = HashSet::new();
|
||||
let refresh_targets = vec!["kept-bucket".to_string()];
|
||||
sys.concurrent_load(&refresh_targets, &mut failed).await;
|
||||
let kept = sys
|
||||
.get("kept-bucket")
|
||||
.await
|
||||
.expect("existing entry must survive a refresh miss");
|
||||
assert_eq!(
|
||||
kept.policy_config_json,
|
||||
b"kept-marker".to_vec(),
|
||||
"a fabricated refresh default must not replace real metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_bucket_policy_rejects_malformed_cached_policy() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
let mut metadata = BucketMetadata::new("malformed-policy");
|
||||
metadata.policy_config_json = b"{".to_vec();
|
||||
sys.set("malformed-policy".to_string(), Arc::new(metadata)).await;
|
||||
|
||||
let err = sys
|
||||
.get_bucket_policy("malformed-policy")
|
||||
.await
|
||||
.expect_err("malformed persisted policy must not be treated as missing");
|
||||
|
||||
assert!(matches!(err, Error::Io(_)), "malformed persisted policy must surface its parse failure");
|
||||
}
|
||||
|
||||
fn target(bucket: &str, id: &str) -> BucketTarget {
|
||||
BucketTarget {
|
||||
source_bucket: bucket.to_string(),
|
||||
|
||||
@@ -538,10 +538,12 @@ mod tests {
|
||||
use crate::disk::{BUCKET_META_PREFIX, MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
|
||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||
use crate::object_api::{ObjectOptions, PutObjReader};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations, BucketOptions, MakeBucketOptions};
|
||||
use crate::storage_api_contracts::object::{ObjectIO, ObjectOperations};
|
||||
use crate::store::{ECStore, init_local_disks};
|
||||
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
@@ -570,10 +572,20 @@ mod tests {
|
||||
cmd_line: "minio-migrate-test".to_string(),
|
||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||
}]);
|
||||
init_local_disks(endpoint_pools.clone()).await.unwrap();
|
||||
let ecstore = ECStore::new("127.0.0.1:0".parse().unwrap(), endpoint_pools, CancellationToken::new())
|
||||
// Isolated instance context: this test deletes its disks at the end,
|
||||
// and dead entries in the shared registry break other cached envs.
|
||||
let instance_ctx = Arc::new(InstanceContext::new());
|
||||
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let ecstore = ECStore::new_with_instance_ctx(
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
endpoint_pools,
|
||||
CancellationToken::new(),
|
||||
instance_ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let existing: Vec<String> = ecstore
|
||||
.list_bucket(&BucketOptions {
|
||||
no_metadata: true,
|
||||
|
||||
@@ -15,23 +15,26 @@
|
||||
use super::metadata_sys::get_bucket_metadata_sys;
|
||||
use crate::error::{Result, StorageError};
|
||||
use rustfs_policy::policy::{BucketPolicy, BucketPolicyArgs};
|
||||
use tracing::info;
|
||||
|
||||
pub struct PolicySys {}
|
||||
|
||||
impl PolicySys {
|
||||
pub async fn is_allowed(args: &BucketPolicyArgs<'_>) -> bool {
|
||||
match Self::get(args.bucket).await {
|
||||
Ok(cfg) => return cfg.is_allowed(args).await,
|
||||
Err(err) => {
|
||||
if err != StorageError::ConfigNotFound {
|
||||
info!("config get err {:?}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args.is_owner
|
||||
matches!(Self::try_is_allowed(args).await, Ok(true))
|
||||
}
|
||||
|
||||
pub async fn try_is_allowed(args: &BucketPolicyArgs<'_>) -> Result<bool> {
|
||||
Self::is_allowed_with_policy(args, Self::get(args.bucket).await).await
|
||||
}
|
||||
|
||||
async fn is_allowed_with_policy(args: &BucketPolicyArgs<'_>, policy: Result<BucketPolicy>) -> Result<bool> {
|
||||
match policy {
|
||||
Ok(policy) => Ok(policy.is_allowed(args).await),
|
||||
Err(StorageError::ConfigNotFound) => Ok(args.is_owner),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(bucket: &str) -> Result<BucketPolicy> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -41,3 +44,91 @@ impl PolicySys {
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PolicySys, StorageError};
|
||||
use rustfs_policy::policy::action::{Action, S3Action};
|
||||
use rustfs_policy::policy::{BucketPolicy, BucketPolicyArgs};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn args<'a>(
|
||||
is_owner: bool,
|
||||
groups: &'a Option<Vec<String>>,
|
||||
conditions: &'a HashMap<String, Vec<String>>,
|
||||
) -> BucketPolicyArgs<'a> {
|
||||
BucketPolicyArgs {
|
||||
bucket: "bucket",
|
||||
action: Action::S3Action(S3Action::GetObjectAction),
|
||||
is_owner,
|
||||
account: "account",
|
||||
groups,
|
||||
conditions,
|
||||
object: "object",
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_policy_preserves_owner_and_iam_fallback_semantics() {
|
||||
let groups = None;
|
||||
let conditions = HashMap::new();
|
||||
assert!(
|
||||
PolicySys::is_allowed_with_policy(&args(true, &groups, &conditions), Err(StorageError::ConfigNotFound),)
|
||||
.await
|
||||
.expect("missing policy should preserve owner access")
|
||||
);
|
||||
assert!(
|
||||
!PolicySys::is_allowed_with_policy(&args(false, &groups, &conditions), Err(StorageError::ConfigNotFound),)
|
||||
.await
|
||||
.expect("missing policy should defer non-owner access to IAM")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_load_failures_propagate() {
|
||||
let groups = None;
|
||||
let conditions = HashMap::new();
|
||||
for (failure, expected_message) in [
|
||||
(StorageError::Io(std::io::Error::other("policy read failed")), "policy read failed"),
|
||||
(
|
||||
StorageError::other("bucket metadata sys not initialized for this instance"),
|
||||
"bucket metadata sys not initialized for this instance",
|
||||
),
|
||||
] {
|
||||
let result = PolicySys::is_allowed_with_policy(&args(true, &groups, &conditions), Err(failure)).await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(StorageError::Io(ref err)) if err.to_string().contains(expected_message)),
|
||||
"policy I/O and uninitialized metadata failures must propagate instead of granting owner access"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_bucket_deny_precedes_iam_allow() {
|
||||
let groups = None;
|
||||
let conditions = HashMap::new();
|
||||
let policy: BucketPolicy = serde_json::from_str(
|
||||
r#"{
|
||||
"Version":"2012-10-17",
|
||||
"Statement":[{
|
||||
"Effect":"Deny",
|
||||
"Principal":{"AWS":"*"},
|
||||
"Action":["s3:GetObject"],
|
||||
"Resource":["arn:aws:s3:::bucket/*"]
|
||||
}]
|
||||
}"#,
|
||||
)
|
||||
.expect("deny policy should parse");
|
||||
|
||||
let bucket_allowed = PolicySys::is_allowed_with_policy(&args(true, &groups, &conditions), Ok(policy))
|
||||
.await
|
||||
.expect("loaded bucket policy should evaluate");
|
||||
let iam_allowed = true;
|
||||
let request_allowed = bucket_allowed && iam_allowed;
|
||||
|
||||
assert!(iam_allowed, "test precondition: IAM grants the action");
|
||||
assert!(!bucket_allowed, "test precondition: bucket policy explicitly denies the action");
|
||||
assert!(!request_allowed, "explicit bucket Deny must reject before IAM Allow fallback");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +118,17 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
pub async fn get_quota_config(&self, bucket: &str) -> Result<BucketQuota, QuotaError> {
|
||||
let meta = self
|
||||
// `get_config`, not the map-only `get()`: a bucket with no persisted
|
||||
// metadata must resolve to the fabricated default (no quota
|
||||
// configured) so the admission check passes and the request reaches
|
||||
// the NoSuchBucket answer — a map-only miss would fail every such
|
||||
// PUT closed with 503 before the 404 could be produced. Real read
|
||||
// faults still surface as errors and keep the fail-closed behavior.
|
||||
let (meta, _) = self
|
||||
.metadata_sys
|
||||
.read()
|
||||
.await
|
||||
.get(bucket)
|
||||
.get_config(bucket)
|
||||
.await
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
|
||||
@@ -161,7 +167,7 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
let quota = self.get_quota_config(bucket).await?;
|
||||
let current_usage = self.get_real_time_usage(bucket).await.unwrap_or(0);
|
||||
let current_usage = self.get_real_time_usage(bucket).await?;
|
||||
|
||||
Ok((quota, Some(current_usage)))
|
||||
}
|
||||
@@ -171,13 +177,59 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
pub async fn get_real_time_usage(&self, bucket: &str) -> Result<u64, QuotaError> {
|
||||
Ok(get_bucket_usage_memory(bucket).await.unwrap_or(0))
|
||||
get_bucket_usage_memory(bucket)
|
||||
.await
|
||||
.ok_or_else(|| QuotaError::UsageUnavailable {
|
||||
bucket: bucket.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use serial_test::serial;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Regression (PR #5307 / s3-tests `test_100_continue_error_retry`): a
|
||||
/// bucket with no persisted metadata has no quota, so the admission check
|
||||
/// must pass and let the request reach its NoSuchBucket answer. With the
|
||||
/// map-only `get()` this failed closed as a retryable 503 on every PUT to
|
||||
/// a nonexistent bucket.
|
||||
#[tokio::test]
|
||||
async fn quota_check_allows_bucket_without_persisted_metadata() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
||||
let checker = QuotaChecker::new(sys);
|
||||
|
||||
let result = checker
|
||||
.check_quota("no-such-bucket", QuotaOperation::PutObject, 1024)
|
||||
.await
|
||||
.expect("a bucket with no persisted metadata has no quota and must not fail the check");
|
||||
assert!(result.allowed);
|
||||
assert_eq!(result.quota_limit, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_usage_rejects_an_unknown_mutation_baseline() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
||||
let checker = QuotaChecker::new(sys);
|
||||
let bucket = format!("quota-unknown-{}", Uuid::new_v4().simple());
|
||||
|
||||
crate::data_usage::record_bucket_object_write_memory(&bucket, None, 42).await;
|
||||
let result = checker.get_real_time_usage(&bucket).await;
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
.expect("test usage cache cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(QuotaError::UsageUnavailable { bucket: failed_bucket }) if failed_bucket == bucket),
|
||||
"quota decisions must fail closed without an authoritative usage baseline"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_quota_check_no_limit() {
|
||||
|
||||
@@ -110,6 +110,8 @@ pub enum QuotaError {
|
||||
QuotaExceeded { current: u64, limit: u64, operation: u64 },
|
||||
#[error("Quota configuration not found for bucket: {bucket}")]
|
||||
ConfigNotFound { bucket: String },
|
||||
#[error("Authoritative data usage is unavailable for bucket: {bucket}")]
|
||||
UsageUnavailable { bucket: String },
|
||||
#[error("Invalid quota configuration: {reason}")]
|
||||
InvalidConfig { reason: String },
|
||||
#[error("Storage error: {0}")]
|
||||
@@ -155,7 +157,7 @@ impl QuotaErrorResponse {
|
||||
request_id: request_id.to_string(),
|
||||
host_id: host_id.to_string(),
|
||||
},
|
||||
QuotaError::StorageError(_) => Self {
|
||||
QuotaError::UsageUnavailable { .. } | QuotaError::StorageError(_) => Self {
|
||||
code: QUOTA_INTERNAL_ERROR_CODE.to_string(),
|
||||
message: quota_error.to_string(),
|
||||
resource: QUOTA_API_PATH.to_string(),
|
||||
|
||||
@@ -52,9 +52,9 @@ pub use replication_config_boundary::{
|
||||
pub(crate) use replication_filemeta_boundary::ReplicateTargetDecision;
|
||||
pub(crate) use replication_filemeta_boundary::version_purge_statuses_map;
|
||||
pub use replication_filemeta_boundary::{
|
||||
REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState, ReplicationStatusType, ReplicationType,
|
||||
VersionPurgeStatusType, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
|
||||
version_purge_status_to_filemeta,
|
||||
MrfOpKind, MrfReplicateEntry, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState,
|
||||
ReplicationStatusType, ReplicationType, VersionPurgeStatusType, replication_state_to_filemeta,
|
||||
replication_status_to_filemeta, replication_statuses_map, version_purge_status_to_filemeta,
|
||||
};
|
||||
pub(crate) use replication_filemeta_boundary::{
|
||||
replication_state_from_filemeta, replication_status_from_filemeta, version_purge_status_from_filemeta,
|
||||
@@ -69,8 +69,8 @@ pub use replication_object_decision_boundary::{
|
||||
should_use_existing_delete_replication_source,
|
||||
};
|
||||
pub use replication_pool::{
|
||||
DynReplicationPool, ReplicationPoolTrait, get_global_replication_pool, get_global_replication_stats,
|
||||
init_background_replication,
|
||||
DurableMrfBacklog, DynReplicationPool, ReplicationPoolTrait, get_global_replication_pool, get_global_replication_stats,
|
||||
init_background_replication, read_durable_mrf_backlog, resync_start_conflict_id,
|
||||
};
|
||||
pub use replication_queue_boundary::{
|
||||
DeletedObjectReplicationInfo, ReplicationHealQueueResult, ReplicationOperation, ReplicationPriority,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub(crate) use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
|
||||
pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
|
||||
pub(crate) use rustfs_replication::{
|
||||
REPLICATE_EXISTING, REPLICATE_EXISTING_DELETE, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos,
|
||||
ReplicatedTargetInfo, ReplicationAction, ReplicationWorkerOperation, ResyncDecision, get_replication_state,
|
||||
|
||||
@@ -53,6 +53,10 @@ impl ReplicationMetadataStore {
|
||||
format!("{REPLICATION_DIR}/{bucket}/{arn}")
|
||||
}
|
||||
|
||||
pub(crate) fn resync_admission_lock_key(bucket: &str) -> String {
|
||||
format!("{REPLICATION_DIR}/{bucket}/admission.lock")
|
||||
}
|
||||
|
||||
pub(crate) fn bucket_resync_dir_path(bucket: &str) -> String {
|
||||
path_join_buf(&[BUCKET_META_PREFIX, bucket, REPLICATION_DIR])
|
||||
}
|
||||
@@ -73,6 +77,10 @@ mod tests {
|
||||
ReplicationMetadataStore::resync_lock_key("bucket-a", "arn-a"),
|
||||
".replication/bucket-a/arn-a"
|
||||
);
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::resync_admission_lock_key("bucket-a"),
|
||||
".replication/bucket-a/admission.lock"
|
||||
);
|
||||
assert_eq!(
|
||||
ReplicationMetadataStore::bucket_resync_dir_path("bucket-a"),
|
||||
"buckets/bucket-a/.replication"
|
||||
|
||||
@@ -68,6 +68,51 @@ const EVENT_REPLICATION_RESYNC_LOAD_SKIPPED: &str = "replication_resync_load_ski
|
||||
const EVENT_REPLICATION_RESYNC_RECOVERED: &str = "replication_resync_recovered";
|
||||
const EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE: &str = "replication_mrf_queue_unavailable";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DurableMrfBacklog {
|
||||
pub available: bool,
|
||||
pub entries: Vec<MrfReplicateEntry>,
|
||||
}
|
||||
|
||||
fn durable_mrf_backlog_from_read(result: Result<Vec<u8>, EcstoreError>) -> DurableMrfBacklog {
|
||||
match result {
|
||||
Ok(data) => match decode_mrf_file(&data) {
|
||||
Ok(entries) if entries.iter().all(|entry| entry.size >= 0) => DurableMrfBacklog {
|
||||
available: true,
|
||||
entries,
|
||||
},
|
||||
Ok(_) | Err(_) => DurableMrfBacklog::default(),
|
||||
},
|
||||
Err(EcstoreError::ConfigNotFound) => DurableMrfBacklog {
|
||||
available: true,
|
||||
entries: Vec::new(),
|
||||
},
|
||||
Err(_) => DurableMrfBacklog::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_durable_mrf_backlog<S: ReplicationObjectIO>(storage: Arc<S>) -> DurableMrfBacklog {
|
||||
durable_mrf_backlog_from_read(ReplicationConfigStore::read(storage, ReplicationMetadataStore::MRF_REPLICATION_FILE).await)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("replication resync {active_resync_id} is already active for {bucket}/{arn}")]
|
||||
struct ResyncActiveConflictError {
|
||||
bucket: String,
|
||||
arn: String,
|
||||
active_resync_id: String,
|
||||
}
|
||||
|
||||
pub fn resync_start_conflict_id(error: &EcstoreError) -> Option<&str> {
|
||||
match error {
|
||||
EcstoreError::Io(io_error) => io_error
|
||||
.get_ref()?
|
||||
.downcast_ref::<ResyncActiveConflictError>()
|
||||
.map(|conflict| conflict.active_resync_id.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Main replication pool structure
|
||||
#[derive(Debug)]
|
||||
pub struct ReplicationPool<S: ReplicationStorage> {
|
||||
@@ -948,47 +993,123 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
}
|
||||
|
||||
pub async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let bucket_status = {
|
||||
let mut status_map = self.resyncer.status_map.write().await;
|
||||
let bucket_status = status_map.entry(opts.bucket.clone()).or_insert_with(|| {
|
||||
let mut status = BucketReplicationResyncStatus::new();
|
||||
status.id = 0;
|
||||
status
|
||||
});
|
||||
let new_run = self.clone().admit_bucket_resync(opts.clone()).await?;
|
||||
self.activate_bucket_resync(opts, !new_run).await
|
||||
}
|
||||
|
||||
bucket_status.last_update = Some(now);
|
||||
bucket_status.targets_map.insert(
|
||||
opts.arn.clone(),
|
||||
TargetReplicationResyncStatus {
|
||||
start_time: Some(now),
|
||||
last_update: Some(now),
|
||||
resync_id: opts.resync_id.clone(),
|
||||
resync_before_date: opts.resync_before,
|
||||
resync_status: ResyncStatusType::ResyncPending,
|
||||
failed_size: 0,
|
||||
failed_count: 0,
|
||||
replicated_size: 0,
|
||||
replicated_count: 0,
|
||||
bucket: opts.bucket.clone(),
|
||||
object: String::new(),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
pub async fn admit_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
|
||||
tokio::spawn(async move { self.admit_bucket_resync_transaction(opts).await })
|
||||
.await
|
||||
.map_err(|error| EcstoreError::other(format!("replication resync admission task failed: {error}")))?
|
||||
}
|
||||
|
||||
bucket_status.clone()
|
||||
async fn admit_bucket_resync_transaction(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
|
||||
let admission_lock_key = ReplicationMetadataStore::resync_admission_lock_key(&opts.bucket);
|
||||
let admission_lock = self
|
||||
.storage
|
||||
.new_ns_lock(ReplicationMetadataStore::rustfs_meta_bucket(), &admission_lock_key)
|
||||
.await?;
|
||||
// Lock order: bucket resync admission lock -> resync status config-object lock.
|
||||
let _admission_guard = match admission_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
||||
Ok(guard) => guard,
|
||||
Err(lock_error) => {
|
||||
if let Ok(status) = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await {
|
||||
self.resyncer.status_map.write().await.insert(opts.bucket.clone(), status);
|
||||
}
|
||||
return Err(EcstoreError::from(lock_error));
|
||||
}
|
||||
};
|
||||
|
||||
let mut bucket_status = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await?;
|
||||
if let Some(active) = bucket_status.targets_map.get(&opts.arn) {
|
||||
if active.resync_id == opts.resync_id {
|
||||
self.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
.await
|
||||
.insert(opts.bucket.clone(), bucket_status);
|
||||
return Ok(false);
|
||||
}
|
||||
if should_auto_resume_resync(active.resync_status) {
|
||||
let active_resync_id = active.resync_id.clone();
|
||||
self.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
.await
|
||||
.insert(opts.bucket.clone(), bucket_status);
|
||||
return Err(EcstoreError::other(ResyncActiveConflictError {
|
||||
bucket: opts.bucket.clone(),
|
||||
arn: opts.arn.clone(),
|
||||
active_resync_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
bucket_status.last_update = Some(now);
|
||||
bucket_status.targets_map.insert(
|
||||
opts.arn.clone(),
|
||||
TargetReplicationResyncStatus {
|
||||
start_time: Some(now),
|
||||
last_update: Some(now),
|
||||
resync_id: opts.resync_id.clone(),
|
||||
resync_before_date: opts.resync_before,
|
||||
resync_status: ResyncStatusType::ResyncPending,
|
||||
failed_size: 0,
|
||||
failed_count: 0,
|
||||
replicated_size: 0,
|
||||
replicated_count: 0,
|
||||
bucket: opts.bucket.clone(),
|
||||
object: String::new(),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
save_resync_status(&opts.bucket, &bucket_status, self.storage.clone()).await?;
|
||||
self.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
.await
|
||||
.insert(opts.bucket.clone(), bucket_status);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn activate_bucket_resync(self: Arc<Self>, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError> {
|
||||
let bucket_status = load_bucket_resync_metadata(&opts.bucket, self.storage.clone()).await?;
|
||||
let Some(target_status) = bucket_status.targets_map.get(&opts.arn) else {
|
||||
return Err(EcstoreError::other("replication resync admission is missing"));
|
||||
};
|
||||
if target_status.resync_id != opts.resync_id {
|
||||
return Err(EcstoreError::other(ResyncActiveConflictError {
|
||||
bucket: opts.bucket.clone(),
|
||||
arn: opts.arn.clone(),
|
||||
active_resync_id: target_status.resync_id.clone(),
|
||||
}));
|
||||
}
|
||||
if !should_auto_resume_resync(target_status.resync_status) {
|
||||
return Ok(());
|
||||
}
|
||||
self.resyncer
|
||||
.status_map
|
||||
.write()
|
||||
.await
|
||||
.insert(opts.bucket.clone(), bucket_status);
|
||||
|
||||
let resyncer = self.resyncer.clone();
|
||||
let storage = self.storage.clone();
|
||||
let cancel_token = CancellationToken::new();
|
||||
resyncer.register_cancel_token(&opts, cancel_token.clone()).await;
|
||||
tokio::spawn(async move {
|
||||
Box::pin(resyncer.clone().resync_bucket(cancel_token, storage, false, opts.clone())).await;
|
||||
resyncer.clear_cancel_token(&opts).await;
|
||||
});
|
||||
if resyncer.register_cancel_token(&opts, cancel_token.clone()).await {
|
||||
tokio::spawn(async move {
|
||||
Box::pin(
|
||||
resyncer
|
||||
.clone()
|
||||
.resync_bucket(cancel_token, storage, recovering, opts.clone()),
|
||||
)
|
||||
.await;
|
||||
resyncer.clear_cancel_token(&opts).await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1141,9 +1262,10 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let resync = self.resyncer.clone();
|
||||
let storage = self.storage.clone();
|
||||
tokio::spawn(async move {
|
||||
resync.register_cancel_token(&opts, ctx.clone()).await;
|
||||
Box::pin(resync.clone().resync_bucket(ctx, storage, true, opts.clone())).await;
|
||||
resync.clear_cancel_token(&opts).await;
|
||||
if resync.register_cancel_token(&opts, ctx.clone()).await {
|
||||
Box::pin(resync.clone().resync_bucket(ctx, storage, true, opts.clone())).await;
|
||||
resync.clear_cancel_token(&opts).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1247,6 +1369,8 @@ pub trait ReplicationPoolTrait: std::fmt::Debug {
|
||||
async fn resize(&self, priority: ReplicationPriority, max_workers: usize, max_l_workers: usize);
|
||||
async fn get_bucket_resync_status(&self, bucket: &str) -> Result<BucketReplicationResyncStatus, EcstoreError>;
|
||||
async fn cancel_bucket_resync(&self, opts: ResyncOpts) -> Result<(), EcstoreError>;
|
||||
async fn admit_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError>;
|
||||
async fn activate_bucket_resync(self: Arc<Self>, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError>;
|
||||
async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError>;
|
||||
async fn init_resync(
|
||||
self: Arc<Self>,
|
||||
@@ -1290,6 +1414,14 @@ impl<S: ReplicationStorage> ReplicationPoolTrait for ReplicationPool<S> {
|
||||
self.cancel_bucket_resync(opts).await
|
||||
}
|
||||
|
||||
async fn admit_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<bool, EcstoreError> {
|
||||
self.admit_bucket_resync(opts).await
|
||||
}
|
||||
|
||||
async fn activate_bucket_resync(self: Arc<Self>, opts: ResyncOpts, recovering: bool) -> Result<(), EcstoreError> {
|
||||
self.activate_bucket_resync(opts, recovering).await
|
||||
}
|
||||
|
||||
async fn start_bucket_resync(self: Arc<Self>, opts: ResyncOpts) -> Result<(), EcstoreError> {
|
||||
self.start_bucket_resync(opts).await
|
||||
}
|
||||
@@ -1553,7 +1685,9 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{Debug, Formatter};
|
||||
use std::io::Cursor;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -1562,10 +1696,16 @@ mod tests {
|
||||
type TestObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, EcstoreError>;
|
||||
|
||||
struct LoadResyncSharedState {
|
||||
data: Vec<u8>,
|
||||
data: StdMutex<Vec<u8>>,
|
||||
lock_manager: Arc<rustfs_lock::GlobalLockManager>,
|
||||
first_read_started: Notify,
|
||||
delay_first_read: AtomicBool,
|
||||
read_count: AtomicUsize,
|
||||
write_count: AtomicUsize,
|
||||
fail_next_write: AtomicBool,
|
||||
block_next_write: AtomicBool,
|
||||
write_started: Notify,
|
||||
allow_write: Notify,
|
||||
}
|
||||
|
||||
struct LoadResyncNodeStore {
|
||||
@@ -1606,22 +1746,31 @@ mod tests {
|
||||
_h: Self::HeaderMap,
|
||||
_opts: &Self::ObjectOptions,
|
||||
) -> Result<Self::GetObjectReader, Self::Error> {
|
||||
if object != ReplicationMetadataStore::bucket_resync_file_path("load-resync-lock") {
|
||||
if !object.ends_with("/.replication/resync.bin") {
|
||||
return Err(EcstoreError::FileNotFound);
|
||||
}
|
||||
|
||||
let read_index = self.shared.read_count.fetch_add(1, Ordering::SeqCst);
|
||||
if read_index == 0 {
|
||||
if read_index == 0 && self.shared.delay_first_read.load(Ordering::SeqCst) {
|
||||
self.shared.first_read_started.notify_waiters();
|
||||
tokio::time::sleep(Duration::from_millis(1_500)).await;
|
||||
}
|
||||
|
||||
let data = self.shared.data.clone();
|
||||
let data = self
|
||||
.shared
|
||||
.data
|
||||
.lock()
|
||||
.expect("test data lock should not be poisoned")
|
||||
.clone();
|
||||
if data.is_empty() {
|
||||
return Err(EcstoreError::FileNotFound);
|
||||
}
|
||||
let size = i64::try_from(data.len()).expect("test metadata length should fit i64");
|
||||
Ok(Self::GetObjectReader {
|
||||
stream: Box::new(Cursor::new(data.clone())),
|
||||
stream: Box::new(Cursor::new(data)),
|
||||
object_info: ObjectInfo {
|
||||
size: data.len() as i64,
|
||||
actual_size: data.len() as i64,
|
||||
size,
|
||||
actual_size: size,
|
||||
..Default::default()
|
||||
},
|
||||
buffered_body: None,
|
||||
@@ -1633,9 +1782,20 @@ mod tests {
|
||||
&self,
|
||||
_bucket: &str,
|
||||
_object: &str,
|
||||
_data: &mut Self::PutObjectReader,
|
||||
data: &mut Self::PutObjectReader,
|
||||
_opts: &Self::ObjectOptions,
|
||||
) -> Result<Self::ObjectInfo, Self::Error> {
|
||||
if self.shared.fail_next_write.swap(false, Ordering::SeqCst) {
|
||||
return Err(EcstoreError::Unexpected);
|
||||
}
|
||||
if self.shared.block_next_write.swap(false, Ordering::SeqCst) {
|
||||
self.shared.write_started.notify_one();
|
||||
self.shared.allow_write.notified().await;
|
||||
}
|
||||
let mut encoded = Vec::new();
|
||||
data.stream.read_to_end(&mut encoded).await.map_err(EcstoreError::from)?;
|
||||
*self.shared.data.lock().expect("test data lock should not be poisoned") = encoded;
|
||||
self.shared.write_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(ObjectInfo::default())
|
||||
}
|
||||
}
|
||||
@@ -1869,6 +2029,267 @@ mod tests {
|
||||
encode_resync_file(&status).expect("test resync metadata should encode")
|
||||
}
|
||||
|
||||
fn empty_resync_shared_state() -> Arc<LoadResyncSharedState> {
|
||||
Arc::new(LoadResyncSharedState {
|
||||
data: StdMutex::new(Vec::new()),
|
||||
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
|
||||
first_read_started: Notify::new(),
|
||||
delay_first_read: AtomicBool::new(false),
|
||||
read_count: AtomicUsize::new(0),
|
||||
write_count: AtomicUsize::new(0),
|
||||
fail_next_write: AtomicBool::new(false),
|
||||
block_next_write: AtomicBool::new(false),
|
||||
write_started: Notify::new(),
|
||||
allow_write: Notify::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn hold_resync_runtime_lock(
|
||||
shared: &Arc<LoadResyncSharedState>,
|
||||
bucket: &str,
|
||||
arn: &str,
|
||||
) -> rustfs_lock::NamespaceLockGuard {
|
||||
let lock =
|
||||
rustfs_lock::NamespaceLock::with_local_manager("resync-start-blocker".to_string(), shared.lock_manager.clone());
|
||||
let lock = rustfs_lock::NamespaceLockWrapper::new(
|
||||
lock,
|
||||
rustfs_lock::ObjectKey::new(
|
||||
ReplicationMetadataStore::rustfs_meta_bucket().to_string(),
|
||||
ReplicationMetadataStore::resync_lock_key(bucket, arn),
|
||||
),
|
||||
"blocker".to_string(),
|
||||
);
|
||||
lock.get_write_lock(Duration::from_secs(1))
|
||||
.await
|
||||
.expect("test should hold the runtime resync lock")
|
||||
}
|
||||
|
||||
fn test_resync_opts(bucket: &str, arn: &str, id: &str) -> ResyncOpts {
|
||||
ResyncOpts {
|
||||
bucket: bucket.to_string(),
|
||||
arn: arn.to_string(),
|
||||
resync_id: id.to_string(),
|
||||
resync_before: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_resync_starts_accept_one_id_and_reject_the_other() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let first_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let second_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-b", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "atomic-start", "arn:test").await;
|
||||
|
||||
let first = first_pool
|
||||
.clone()
|
||||
.start_bucket_resync(test_resync_opts("atomic-start", "arn:test", "run-a"));
|
||||
let second = second_pool
|
||||
.clone()
|
||||
.start_bucket_resync(test_resync_opts("atomic-start", "arn:test", "run-b"));
|
||||
let (first, second) = tokio::join!(first, second);
|
||||
|
||||
let (accepted_id, conflict) = match (first, second) {
|
||||
(Ok(()), Err(conflict)) => ("run-a", conflict),
|
||||
(Err(conflict), Ok(())) => ("run-b", conflict),
|
||||
outcome => panic!("exactly one concurrent start should be accepted: {outcome:?}"),
|
||||
};
|
||||
assert_eq!(resync_start_conflict_id(&conflict), Some(accepted_id));
|
||||
|
||||
let persisted = decode_resync_file(&shared.data.lock().expect("test data lock should not be poisoned"))
|
||||
.expect("accepted status should be persisted");
|
||||
assert_eq!(persisted.targets_map["arn:test"].resync_id, accepted_id);
|
||||
assert_eq!(persisted.targets_map["arn:test"].resync_status, ResyncStatusType::ResyncPending);
|
||||
assert_eq!(
|
||||
first_pool.resyncer.status_map.read().await["atomic-start"].targets_map["arn:test"].resync_id,
|
||||
accepted_id
|
||||
);
|
||||
assert_eq!(
|
||||
second_pool.resyncer.status_map.read().await["atomic-start"].targets_map["arn:test"].resync_id,
|
||||
accepted_id
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_resync_id_retry_is_idempotent_without_rewriting_status() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "same-id", "arn:test").await;
|
||||
let opts = test_resync_opts("same-id", "arn:test", "run-a");
|
||||
|
||||
pool.clone()
|
||||
.start_bucket_resync(opts.clone())
|
||||
.await
|
||||
.expect("first start should be accepted");
|
||||
let first_status = pool
|
||||
.resyncer
|
||||
.status_map
|
||||
.read()
|
||||
.await
|
||||
.get("same-id")
|
||||
.expect("accepted status should be published")
|
||||
.targets_map["arn:test"]
|
||||
.clone();
|
||||
|
||||
pool.clone()
|
||||
.start_bucket_resync(opts)
|
||||
.await
|
||||
.expect("same ID retry should be accepted idempotently");
|
||||
let retried_status = pool
|
||||
.resyncer
|
||||
.status_map
|
||||
.read()
|
||||
.await
|
||||
.get("same-id")
|
||||
.expect("retried status should remain published")
|
||||
.targets_map["arn:test"]
|
||||
.clone();
|
||||
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(retried_status.resync_id, first_status.resync_id);
|
||||
assert_eq!(retried_status.start_time, first_status.start_time);
|
||||
assert_eq!(retried_status.resync_status, ResyncStatusType::ResyncPending);
|
||||
assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admitted_resync_waits_for_target_metadata_commit_before_activation() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "two-phase-start", "arn:test").await;
|
||||
let opts = test_resync_opts("two-phase-start", "arn:test", "run-a");
|
||||
|
||||
let new_run = pool
|
||||
.clone()
|
||||
.admit_bucket_resync(opts.clone())
|
||||
.await
|
||||
.expect("admission should persist the intent");
|
||||
assert!(new_run);
|
||||
assert!(pool.resyncer.cancel_tokens.read().await.is_empty());
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 1);
|
||||
|
||||
pool.clone()
|
||||
.activate_bucket_resync(opts, false)
|
||||
.await
|
||||
.expect("activation should start the admitted run");
|
||||
assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_id_retry_after_restart_recreates_missing_runtime_task() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let mut persisted = BucketReplicationResyncStatus::new();
|
||||
persisted.targets_map.insert(
|
||||
"arn:test".to_string(),
|
||||
TargetReplicationResyncStatus {
|
||||
bucket: "restart-retry".to_string(),
|
||||
resync_id: "run-a".to_string(),
|
||||
resync_status: ResyncStatusType::ResyncPending,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
*shared.data.lock().expect("test data lock should not be poisoned") =
|
||||
encode_resync_file(&persisted).expect("restart status should encode");
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "restart-retry", "arn:test").await;
|
||||
|
||||
pool.clone()
|
||||
.start_bucket_resync(test_resync_opts("restart-retry", "arn:test", "run-a"))
|
||||
.await
|
||||
.expect("same ID retry should recover an accepted run");
|
||||
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(pool.resyncer.cancel_tokens.read().await.len(), 1);
|
||||
assert_eq!(
|
||||
pool.resyncer.status_map.read().await["restart-retry"].targets_map["arn:test"].resync_id,
|
||||
"run-a"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_completed_resync_id_retry_does_not_restart_work() {
|
||||
let shared = empty_resync_shared_state();
|
||||
let mut persisted = BucketReplicationResyncStatus::new();
|
||||
persisted.targets_map.insert(
|
||||
"arn:test".to_string(),
|
||||
TargetReplicationResyncStatus {
|
||||
bucket: "completed-retry".to_string(),
|
||||
resync_id: "run-a".to_string(),
|
||||
resync_status: ResyncStatusType::ResyncCompleted,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
*shared.data.lock().expect("test data lock should not be poisoned") =
|
||||
encode_resync_file(&persisted).expect("completed status should encode");
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
|
||||
pool.clone()
|
||||
.start_bucket_resync(test_resync_opts("completed-retry", "arn:test", "run-a"))
|
||||
.await
|
||||
.expect("completed same ID retry should remain idempotent");
|
||||
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 0);
|
||||
assert!(pool.resyncer.cancel_tokens.read().await.is_empty());
|
||||
assert_eq!(
|
||||
pool.resyncer.status_map.read().await["completed-retry"].targets_map["arn:test"].resync_status,
|
||||
ResyncStatusType::ResyncCompleted
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_failure_does_not_publish_or_persist_requested_id() {
|
||||
let shared = empty_resync_shared_state();
|
||||
shared.fail_next_write.store(true, Ordering::SeqCst);
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
|
||||
let error = pool
|
||||
.clone()
|
||||
.start_bucket_resync(test_resync_opts("failed-start", "arn:test", "run-a"))
|
||||
.await
|
||||
.expect_err("metadata save failure should reject the start");
|
||||
|
||||
assert!(matches!(error, EcstoreError::Unexpected));
|
||||
assert!(shared.data.lock().expect("test data lock should not be poisoned").is_empty());
|
||||
assert!(!pool.resyncer.status_map.read().await.contains_key("failed-start"));
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canceled_start_request_finishes_accepted_transaction() {
|
||||
let shared = empty_resync_shared_state();
|
||||
shared.block_next_write.store(true, Ordering::SeqCst);
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let _runtime_guard = hold_resync_runtime_lock(&shared, "canceled-start", "arn:test").await;
|
||||
|
||||
let start_pool = pool.clone();
|
||||
let start = tokio::spawn(async move {
|
||||
start_pool
|
||||
.start_bucket_resync(test_resync_opts("canceled-start", "arn:test", "run-a"))
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), shared.write_started.notified())
|
||||
.await
|
||||
.expect("start transaction should reach the durable write");
|
||||
start.abort();
|
||||
assert!(start.await.expect_err("caller task should be canceled").is_cancelled());
|
||||
shared.allow_write.notify_one();
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if pool.resyncer.status_map.read().await.contains_key("canceled-start") {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("detached admission transaction should finish after caller cancellation");
|
||||
assert_eq!(shared.write_count.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
pool.resyncer.status_map.read().await["canceled-start"].targets_map["arn:test"].resync_id,
|
||||
"run-a"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_queue_admission_combines_target_results() {
|
||||
let mut admission = ReplicationQueueAdmission::Skipped;
|
||||
@@ -1958,10 +2379,16 @@ mod tests {
|
||||
async fn load_resync_leader_lock_allows_only_one_startup_recovery() {
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("1"))], async {
|
||||
let shared = Arc::new(LoadResyncSharedState {
|
||||
data: load_resync_test_metadata(),
|
||||
data: StdMutex::new(load_resync_test_metadata()),
|
||||
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
|
||||
first_read_started: Notify::new(),
|
||||
delay_first_read: AtomicBool::new(true),
|
||||
read_count: AtomicUsize::new(0),
|
||||
write_count: AtomicUsize::new(0),
|
||||
fail_next_write: AtomicBool::new(false),
|
||||
block_next_write: AtomicBool::new(false),
|
||||
write_started: Notify::new(),
|
||||
allow_write: Notify::new(),
|
||||
});
|
||||
let leader_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", shared.clone()))).await;
|
||||
let skipped_pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-b", shared.clone()))).await;
|
||||
@@ -2233,4 +2660,53 @@ mod tests {
|
||||
// None so replay falls back to the current time (backlog#867 backward compatibility).
|
||||
assert_eq!(entry.delete_marker_mtime, None, "missing deleteMarkerMtime key must default to None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_mrf_snapshot_reads_restart_backlog_and_valid_empty_state() {
|
||||
let entries = vec![MrfReplicateEntry {
|
||||
bucket: "restart-bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
version_id: None,
|
||||
retry_count: 1,
|
||||
size: 512,
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
delete_marker_mtime: None,
|
||||
}];
|
||||
let encoded = encode_mrf_file(&entries).expect("durable MRF backlog should encode");
|
||||
|
||||
let recovered = durable_mrf_backlog_from_read(Ok(encoded));
|
||||
assert!(recovered.available);
|
||||
assert_eq!(recovered.entries.len(), 1);
|
||||
assert_eq!(recovered.entries[0].bucket, "restart-bucket");
|
||||
assert_eq!(recovered.entries[0].size, 512);
|
||||
|
||||
let missing_file = durable_mrf_backlog_from_read(Err(EcstoreError::ConfigNotFound));
|
||||
assert!(missing_file.available);
|
||||
assert!(missing_file.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_mrf_snapshot_marks_corrupt_or_invalid_data_unavailable() {
|
||||
let corrupt = durable_mrf_backlog_from_read(Ok(vec![0, 1, 2]));
|
||||
assert!(!corrupt.available);
|
||||
assert!(corrupt.entries.is_empty());
|
||||
|
||||
let negative = encode_mrf_file(&[MrfReplicateEntry {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
version_id: None,
|
||||
retry_count: 0,
|
||||
size: -1,
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
delete_marker_mtime: None,
|
||||
}])
|
||||
.expect("invalid persisted entry should still encode for boundary testing");
|
||||
let invalid = durable_mrf_backlog_from_read(Ok(negative));
|
||||
assert!(!invalid.available);
|
||||
assert!(invalid.entries.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ use super::replication_filemeta_boundary::MrfReplicateEntry;
|
||||
|
||||
pub use rustfs_replication::{BucketReplicationResyncStatus, ResyncOpts, ResyncStatusType, TargetReplicationResyncStatus};
|
||||
pub(crate) use rustfs_replication::{
|
||||
is_version_id_mismatch, resync_state_accepts_update, should_auto_resume_resync, should_count_head_proxy_failure,
|
||||
is_version_id_mismatch, resync_state_accepts_update, sanitize_resync_error_detail, should_auto_resume_resync,
|
||||
should_count_head_proxy_failure,
|
||||
};
|
||||
|
||||
pub(crate) const RESYNC_META_FORMAT: u16 = rustfs_replication::resync::RESYNC_META_FORMAT;
|
||||
|
||||
@@ -37,7 +37,7 @@ use super::replication_queue_boundary::DeletedObjectReplicationInfo;
|
||||
use super::replication_resync_boundary::ResyncStatusType;
|
||||
use super::replication_resync_boundary::{
|
||||
BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus, encode_resync_file, is_version_id_mismatch,
|
||||
resync_state_accepts_update, should_count_head_proxy_failure,
|
||||
resync_state_accepts_update, sanitize_resync_error_detail, should_count_head_proxy_failure,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use super::replication_resync_boundary::{RESYNC_META_FORMAT, RESYNC_META_VERSION, WIRE_ZERO_TIME_UNIX, decode_resync_file};
|
||||
@@ -117,6 +117,20 @@ const RESYNC_TIME_INTERVAL: TokioDuration = TokioDuration::from_secs(60);
|
||||
|
||||
static WARNED_MONITOR_UNINIT: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
fn resync_target_error_detail<E, R>(error: &SdkError<E, R>) -> Option<String>
|
||||
where
|
||||
E: ProvideErrorMetadata,
|
||||
{
|
||||
sanitize_resync_error_detail(error.code().unwrap_or(match error {
|
||||
SdkError::ConstructionFailure(_) => "failed to construct target request",
|
||||
SdkError::TimeoutError(_) => "target request timed out",
|
||||
SdkError::DispatchFailure(_) => "target dispatch failed",
|
||||
SdkError::ResponseError(_) => "invalid target response",
|
||||
SdkError::ServiceError(_) => "target service error",
|
||||
_ => "target request failed",
|
||||
}))
|
||||
}
|
||||
|
||||
async fn finish_resync_workers(
|
||||
worker_txs: Vec<tokio::sync::mpsc::Sender<ReplicateObjectInfo>>,
|
||||
results_tx: tokio::sync::mpsc::Sender<TargetReplicationResyncStatus>,
|
||||
@@ -241,11 +255,13 @@ fn resync_status_duration(
|
||||
Some(std::time::Duration::from_millis(millis))
|
||||
}
|
||||
|
||||
type ResyncCancelKey = (String, String, String);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReplicationResyncer {
|
||||
pub status_map: Arc<RwLock<HashMap<String, BucketReplicationResyncStatus>>>,
|
||||
pub worker_size: usize,
|
||||
pub cancel_tokens: Arc<RwLock<HashMap<String, CancellationToken>>>,
|
||||
pub(crate) cancel_tokens: Arc<RwLock<HashMap<ResyncCancelKey, CancellationToken>>>,
|
||||
}
|
||||
|
||||
impl ReplicationResyncer {
|
||||
@@ -257,12 +273,19 @@ impl ReplicationResyncer {
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_key(opts: &ResyncOpts) -> String {
|
||||
format!("{}:{}", opts.bucket, opts.arn)
|
||||
fn cancel_key(opts: &ResyncOpts) -> ResyncCancelKey {
|
||||
(opts.bucket.clone(), opts.arn.clone(), opts.resync_id.clone())
|
||||
}
|
||||
|
||||
pub async fn register_cancel_token(&self, opts: &ResyncOpts, token: CancellationToken) {
|
||||
self.cancel_tokens.write().await.insert(Self::cancel_key(opts), token);
|
||||
pub async fn register_cancel_token(&self, opts: &ResyncOpts, token: CancellationToken) -> bool {
|
||||
let mut cancel_tokens = self.cancel_tokens.write().await;
|
||||
match cancel_tokens.entry(Self::cancel_key(opts)) {
|
||||
std::collections::hash_map::Entry::Vacant(entry) => {
|
||||
entry.insert(token);
|
||||
true
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_cancel_token(&self, opts: &ResyncOpts) {
|
||||
@@ -428,6 +451,9 @@ impl ReplicationResyncer {
|
||||
state.replicated_size += status.replicated_size;
|
||||
state.failed_count += status.failed_count;
|
||||
state.failed_size += status.failed_size;
|
||||
if state.error.is_none() && status.failed_count > 0 {
|
||||
state.error = status.error.as_deref().and_then(sanitize_resync_error_detail);
|
||||
}
|
||||
state.last_update = Some(now);
|
||||
bucket_status.last_update = Some(now);
|
||||
}
|
||||
@@ -885,6 +911,7 @@ impl ReplicationResyncer {
|
||||
"Processed resync object"
|
||||
);
|
||||
}
|
||||
st.error = err.as_ref().and_then(resync_target_error_detail);
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
return;
|
||||
@@ -2071,14 +2098,20 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
|
||||
|
||||
for arn in tgt_arns {
|
||||
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(&bucket, &arn).await else {
|
||||
// Deliberately debug: this fires once per object per ARN, so a target that
|
||||
// stays unreachable would flood the log from the replication hot path. The
|
||||
// condition is reported once per pass by the site-replication reconciler and
|
||||
// once per rebuild by `update_all_targets`, which is where an operator can act
|
||||
// on it; the per-object event below still records each dropped object.
|
||||
debug!(
|
||||
event = EVENT_RESYNC_RUNTIME_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||
bucket = %bucket,
|
||||
object = %object,
|
||||
arn = %arn,
|
||||
reason = "target_client_missing",
|
||||
"Skipping replication object target"
|
||||
"Replication rule has no bucket target for its destination ARN; object not replicated"
|
||||
);
|
||||
send_local_event(EventArgs {
|
||||
event_name: EventName::ObjectReplicationNotTracked.to_string(),
|
||||
@@ -3227,6 +3260,7 @@ mod tests {
|
||||
assert_eq!(tgt.start_time, Some(start));
|
||||
assert_eq!(tgt.last_update, Some(last));
|
||||
assert_eq!(tgt.resync_before_date, Some(before));
|
||||
assert_eq!(tgt.error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3681,6 +3715,59 @@ mod tests {
|
||||
assert!(resyncer.target_has_resync_failures(&opts).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_inc_stats_retains_first_sanitized_error_across_success() {
|
||||
let resyncer = ReplicationResyncer::new().await;
|
||||
let opts = ResyncOpts {
|
||||
bucket: "bucket".to_string(),
|
||||
arn: "arn:replication::dest".to_string(),
|
||||
resync_id: "run-new".to_string(),
|
||||
resync_before: None,
|
||||
};
|
||||
let failed = TargetReplicationResyncStatus {
|
||||
failed_count: 1,
|
||||
object: "failed-object".to_string(),
|
||||
error: Some("Authorization: Bearer status-secret".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let later_failure = TargetReplicationResyncStatus {
|
||||
failed_count: 1,
|
||||
object: "later-failed-object".to_string(),
|
||||
error: Some("AccessDenied".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let succeeded = TargetReplicationResyncStatus {
|
||||
replicated_count: 1,
|
||||
object: "successful-object".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
resyncer.inc_stats(&failed, opts.clone()).await;
|
||||
resyncer.inc_stats(&later_failure, opts.clone()).await;
|
||||
resyncer.inc_stats(&succeeded, opts.clone()).await;
|
||||
|
||||
let status_map = resyncer.status_map.read().await;
|
||||
let target = &status_map["bucket"].targets_map["arn:replication::dest"];
|
||||
assert_eq!(target.failed_count, 2);
|
||||
assert_eq!(target.replicated_count, 1);
|
||||
assert_eq!(target.object, "successful-object");
|
||||
assert_eq!(target.error.as_deref(), Some("[redacted sensitive resync error detail]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resync_target_error_detail_uses_safe_service_code_and_fallback() {
|
||||
let metadata = aws_smithy_types::error::ErrorMetadata::builder()
|
||||
.code("AccessDenied")
|
||||
.message("Authorization: Bearer status-secret")
|
||||
.build();
|
||||
let service_error = SdkError::service_error(HeadObjectError::generic(metadata), ());
|
||||
let timeout_error =
|
||||
SdkError::<HeadObjectError, ()>::timeout_error(std::io::Error::new(std::io::ErrorKind::TimedOut, "status-secret"));
|
||||
|
||||
assert_eq!(resync_target_error_detail(&service_error).as_deref(), Some("AccessDenied"));
|
||||
assert_eq!(resync_target_error_detail(&timeout_error).as_deref(), Some("target request timed out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resync_state_accepts_update_only_for_matching_run() {
|
||||
let current = TargetReplicationResyncStatus {
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
use super::replication_error_boundary::Error;
|
||||
use super::replication_filemeta_boundary::{ReplicatedTargetInfo, ReplicationStatusType, ReplicationType};
|
||||
use super::replication_resync_boundary::ResyncStatusType;
|
||||
#[cfg(test)]
|
||||
use super::replication_stats_boundary::FailStats;
|
||||
use super::replication_stats_boundary::{
|
||||
ActiveWorkerStat, BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, ProxyMetric, ProxyStatsCache,
|
||||
QueueCache, SRMetricsSummary, XferStats,
|
||||
QueueCache, ReplicationMetricScope, SRMetricsSummary, XferStats,
|
||||
};
|
||||
use super::runtime_boundary as runtime_sources;
|
||||
use std::collections::HashMap;
|
||||
@@ -361,10 +363,12 @@ impl ReplicationStats {
|
||||
if rs.transfer_duration > Duration::default() {
|
||||
stat.latency.update(rs.transfer_size, rs.transfer_duration);
|
||||
stat.update_xfer_rate(rs.transfer_size, rs.transfer_duration);
|
||||
stat.latency_scope = ReplicationMetricScope::NodeLocal;
|
||||
}
|
||||
}
|
||||
(false, true, false) => {
|
||||
stat.fail_stats.add_size(rs.transfer_size, rs.err.as_ref());
|
||||
stat.failed = stat.fail_stats.to_metric();
|
||||
}
|
||||
(false, false, true) => {
|
||||
// Pending status, no processing for now
|
||||
@@ -379,7 +383,10 @@ impl ReplicationStats {
|
||||
let mut result = HashMap::with_capacity(cache.len());
|
||||
|
||||
for (bucket, stats) in cache.iter() {
|
||||
result.insert(bucket.clone(), stats.clone_stats());
|
||||
let mut snapshot = stats.clone_stats();
|
||||
snapshot.mark_node_local_provider_available();
|
||||
snapshot.queue_scope = ReplicationMetricScope::NodeLocal;
|
||||
result.insert(bucket.clone(), snapshot);
|
||||
}
|
||||
drop(cache);
|
||||
|
||||
@@ -388,6 +395,8 @@ impl ReplicationStats {
|
||||
for (bucket, queue_stats) in &q_cache.bucket_stats {
|
||||
let bucket_stats = result.entry(bucket.clone()).or_insert_with(BucketReplicationStats::new);
|
||||
bucket_stats.q_stat = queue_stats.snapshot();
|
||||
bucket_stats.mark_node_local_provider_available();
|
||||
bucket_stats.queue_scope = ReplicationMetricScope::NodeLocal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,9 +414,13 @@ impl ReplicationStats {
|
||||
pub async fn get(&self, bucket: &str) -> BucketReplicationStats {
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(stats) = cache.get(bucket) {
|
||||
stats.clone_stats()
|
||||
let mut snapshot = stats.clone_stats();
|
||||
snapshot.mark_node_local_provider_available();
|
||||
snapshot
|
||||
} else {
|
||||
BucketReplicationStats::new()
|
||||
let mut snapshot = BucketReplicationStats::new();
|
||||
snapshot.mark_node_local_provider_available();
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,11 +466,15 @@ impl ReplicationStats {
|
||||
let mut tq = InQueueMetric::default();
|
||||
|
||||
for bucket_stat in &bucket_stats {
|
||||
tot_replica_size += bucket_stat.replication_stats.replica_size;
|
||||
tot_replica_count += bucket_stat.replication_stats.replica_count;
|
||||
tot_replica_size = tot_replica_size.saturating_add(bucket_stat.replication_stats.replica_size);
|
||||
tot_replica_count = tot_replica_count.saturating_add(bucket_stat.replication_stats.replica_count);
|
||||
|
||||
for q in &bucket_stat.queue_stats.nodes {
|
||||
tq = tq.merge(&q.q_stats);
|
||||
if bucket_stat.replication_stats.queue_scope != ReplicationMetricScope::Unavailable {
|
||||
tq = tq.merge(&bucket_stat.replication_stats.q_stat);
|
||||
} else {
|
||||
for q in &bucket_stat.queue_stats.nodes {
|
||||
tq = tq.merge(&q.q_stats);
|
||||
}
|
||||
}
|
||||
|
||||
for (arn, stat) in &bucket_stat.replication_stats.stats {
|
||||
@@ -470,22 +487,38 @@ impl ReplicationStats {
|
||||
let f_stats = stat.fail_stats.merge(&old_stat.fail_stats);
|
||||
let lrg = old_stat.xfer_rate_lrg.merge(&stat.xfer_rate_lrg);
|
||||
let sml = old_stat.xfer_rate_sml.merge(&stat.xfer_rate_sml);
|
||||
let latency_available = stat.latency_scope != ReplicationMetricScope::Unavailable
|
||||
|| old_stat.latency_scope != ReplicationMetricScope::Unavailable;
|
||||
let bandwidth_available = stat.bandwidth_scope != ReplicationMetricScope::Unavailable
|
||||
|| old_stat.bandwidth_scope != ReplicationMetricScope::Unavailable;
|
||||
|
||||
*old_stat = BucketReplicationStat {
|
||||
failed: f_stats.to_metric(),
|
||||
fail_stats: f_stats,
|
||||
replicated_size: stat.replicated_size + old_stat.replicated_size,
|
||||
replicated_count: stat.replicated_count + old_stat.replicated_count,
|
||||
replicated_size: stat.replicated_size.saturating_add(old_stat.replicated_size),
|
||||
replicated_count: stat.replicated_count.saturating_add(old_stat.replicated_count),
|
||||
latency: stat.latency.merge(&old_stat.latency),
|
||||
xfer_rate_lrg: lrg,
|
||||
xfer_rate_sml: sml,
|
||||
bandwidth_limit_bytes_per_sec: stat.bandwidth_limit_bytes_per_sec,
|
||||
bandwidth_limit_bytes_per_sec: stat
|
||||
.bandwidth_limit_bytes_per_sec
|
||||
.saturating_add(old_stat.bandwidth_limit_bytes_per_sec),
|
||||
current_bandwidth_bytes_per_sec: stat.current_bandwidth_bytes_per_sec
|
||||
+ old_stat.current_bandwidth_bytes_per_sec,
|
||||
latency_scope: if latency_available {
|
||||
ReplicationMetricScope::ClusterAggregated
|
||||
} else {
|
||||
ReplicationMetricScope::Unavailable
|
||||
},
|
||||
bandwidth_scope: if bandwidth_available {
|
||||
ReplicationMetricScope::ClusterAggregated
|
||||
} else {
|
||||
ReplicationMetricScope::Unavailable
|
||||
},
|
||||
};
|
||||
|
||||
tot_replicated_size += stat.replicated_size;
|
||||
tot_replicated_count += stat.replicated_count;
|
||||
tot_replicated_size = tot_replicated_size.saturating_add(stat.replicated_size);
|
||||
tot_replicated_count = tot_replicated_count.saturating_add(stat.replicated_count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,23 +532,28 @@ impl ReplicationStats {
|
||||
resync_started_count: bucket_stats
|
||||
.iter()
|
||||
.map(|stats| stats.replication_stats.resync_started_count)
|
||||
.sum(),
|
||||
.fold(0i64, i64::saturating_add),
|
||||
resync_completed_count: bucket_stats
|
||||
.iter()
|
||||
.map(|stats| stats.replication_stats.resync_completed_count)
|
||||
.sum(),
|
||||
.fold(0i64, i64::saturating_add),
|
||||
resync_failed_count: bucket_stats
|
||||
.iter()
|
||||
.map(|stats| stats.replication_stats.resync_failed_count)
|
||||
.sum(),
|
||||
.fold(0i64, i64::saturating_add),
|
||||
resync_canceled_count: bucket_stats
|
||||
.iter()
|
||||
.map(|stats| stats.replication_stats.resync_canceled_count)
|
||||
.sum(),
|
||||
.fold(0i64, i64::saturating_add),
|
||||
resync_duration_ms: bucket_stats
|
||||
.iter()
|
||||
.map(|stats| stats.replication_stats.resync_duration_ms)
|
||||
.sum(),
|
||||
.fold(0i64, i64::saturating_add),
|
||||
provider_available: true,
|
||||
cluster_complete: true,
|
||||
observed_node_count: u32::try_from(bucket_stats.len()).unwrap_or(u32::MAX),
|
||||
expected_node_count: u32::try_from(bucket_stats.len()).unwrap_or(u32::MAX),
|
||||
queue_scope: ReplicationMetricScope::ClusterAggregated,
|
||||
};
|
||||
|
||||
let qs = Default::default();
|
||||
@@ -547,6 +585,33 @@ impl ReplicationStats {
|
||||
bs
|
||||
}
|
||||
|
||||
pub async fn aggregate_bucket_replication_stats(
|
||||
&self,
|
||||
bucket: &str,
|
||||
bucket_stats: Vec<BucketStats>,
|
||||
expected_node_count: u32,
|
||||
) -> BucketStats {
|
||||
let mut aggregated = self.calculate_bucket_replication_stats(bucket, bucket_stats).await;
|
||||
let observed_node_count = aggregated.replication_stats.observed_node_count;
|
||||
let complete = observed_node_count == expected_node_count;
|
||||
aggregated.replication_stats.expected_node_count = expected_node_count;
|
||||
aggregated.replication_stats.cluster_complete = complete;
|
||||
aggregated.replication_stats.queue_scope = if complete {
|
||||
ReplicationMetricScope::ClusterAggregated
|
||||
} else {
|
||||
ReplicationMetricScope::PartialCluster
|
||||
};
|
||||
for stat in aggregated.replication_stats.stats.values_mut() {
|
||||
if stat.latency_scope != ReplicationMetricScope::Unavailable {
|
||||
stat.latency_scope = aggregated.replication_stats.queue_scope;
|
||||
}
|
||||
if stat.bandwidth_scope != ReplicationMetricScope::Unavailable {
|
||||
stat.bandwidth_scope = aggregated.replication_stats.queue_scope;
|
||||
}
|
||||
}
|
||||
aggregated
|
||||
}
|
||||
|
||||
/// Get latest replication statistics
|
||||
pub async fn get_latest_replication_stats(&self, bucket: &str) -> BucketStats {
|
||||
// In actual implementation, statistics would be obtained from cluster
|
||||
@@ -567,6 +632,15 @@ impl ReplicationStats {
|
||||
};
|
||||
drop(cache);
|
||||
|
||||
{
|
||||
let q_cache = self.q_cache.lock().await;
|
||||
if let Some(queue_stats) = q_cache.bucket_stats.get(bucket) {
|
||||
replication_stats.q_stat = queue_stats.snapshot();
|
||||
}
|
||||
}
|
||||
replication_stats.mark_node_local_provider_available();
|
||||
replication_stats.queue_scope = ReplicationMetricScope::NodeLocal;
|
||||
|
||||
if let Some(monitor) = runtime_sources::bucket_monitor() {
|
||||
let bw_report = monitor.get_report(|name| name == bucket);
|
||||
for (opts, bw) in bw_report.bucket_stats {
|
||||
@@ -578,8 +652,7 @@ impl ReplicationStats {
|
||||
xfer_rate_sml: XferStats::new(),
|
||||
..Default::default()
|
||||
});
|
||||
stat.bandwidth_limit_bytes_per_sec = bw.limit_bytes_per_sec;
|
||||
stat.current_bandwidth_bytes_per_sec = bw.current_bandwidth_bytes_per_sec;
|
||||
stat.set_node_local_bandwidth(bw.limit_bytes_per_sec, bw.current_bandwidth_bytes_per_sec);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +797,132 @@ mod tests {
|
||||
assert_eq!(stat.replicated_count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn latest_stats_include_queue_until_drained() {
|
||||
let stats = ReplicationStats::new();
|
||||
|
||||
stats.inc_q("queued-bucket", 4096, false, ReplicationType::Object).await;
|
||||
let queued = stats.get_latest_replication_stats("queued-bucket").await;
|
||||
assert!(queued.replication_stats.provider_available);
|
||||
assert_eq!(queued.replication_stats.q_stat.curr.count, 1);
|
||||
assert_eq!(queued.replication_stats.q_stat.curr.bytes, 4096);
|
||||
assert_eq!(queued.replication_stats.queue_scope, ReplicationMetricScope::NodeLocal);
|
||||
|
||||
stats.dec_q("queued-bucket", 4096, false, ReplicationType::Object).await;
|
||||
let drained = stats.get_latest_replication_stats("queued-bucket").await;
|
||||
assert_eq!(drained.replication_stats.q_stat.curr.count, 0);
|
||||
assert_eq!(drained.replication_stats.q_stat.curr.bytes, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_metric_matches_authoritative_fail_stats() {
|
||||
let stats = ReplicationStats::new();
|
||||
let target_info = ReplicatedTargetInfo {
|
||||
arn: "failed-arn".to_string(),
|
||||
size: 2048,
|
||||
duration: Duration::from_millis(25),
|
||||
op_type: ReplicationType::Object,
|
||||
error: Some("target unavailable".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
stats
|
||||
.update(
|
||||
"failed-bucket",
|
||||
&target_info,
|
||||
ReplicationStatusType::Failed,
|
||||
ReplicationStatusType::Pending,
|
||||
)
|
||||
.await;
|
||||
|
||||
let snapshot = stats.get_latest_replication_stats("failed-bucket").await;
|
||||
let target = &snapshot.replication_stats.stats["failed-arn"];
|
||||
assert_eq!(target.failed.count, target.fail_stats.count);
|
||||
assert_eq!(target.failed.size, target.fail_stats.size);
|
||||
assert_eq!(target.failed.count, 1);
|
||||
assert_eq!(target.failed.size, 2048);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_empty_provider_is_not_reported_as_unavailable() {
|
||||
let stats = ReplicationStats::new();
|
||||
|
||||
let snapshot = stats.get_latest_replication_stats("empty-bucket").await;
|
||||
|
||||
assert!(snapshot.replication_stats.provider_available);
|
||||
assert!(snapshot.replication_stats.cluster_complete);
|
||||
assert_eq!(snapshot.replication_stats.observed_node_count, 1);
|
||||
assert_eq!(snapshot.replication_stats.expected_node_count, 1);
|
||||
assert!(snapshot.replication_stats.stats.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_aggregation_counts_each_node_once_and_marks_partial() {
|
||||
let stats = ReplicationStats::new();
|
||||
let node = |failed_count, failed_size, queued_count, queued_size| {
|
||||
let mut fail_stats = FailStats::new();
|
||||
fail_stats.count = failed_count;
|
||||
fail_stats.size = failed_size;
|
||||
let mut targets = HashMap::new();
|
||||
targets.insert(
|
||||
"arn".to_string(),
|
||||
BucketReplicationStat {
|
||||
fail_stats,
|
||||
latency_scope: ReplicationMetricScope::NodeLocal,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let q_stat = InQueueMetric::default();
|
||||
q_stat.curr.now_count.store(queued_count, Ordering::Relaxed);
|
||||
q_stat.curr.now_bytes.store(queued_size, Ordering::Relaxed);
|
||||
let q_stat = q_stat.snapshot();
|
||||
BucketStats {
|
||||
replication_stats: BucketReplicationStats {
|
||||
stats: targets,
|
||||
q_stat,
|
||||
provider_available: true,
|
||||
queue_scope: ReplicationMetricScope::NodeLocal,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
let aggregated = stats
|
||||
.aggregate_bucket_replication_stats("bucket", vec![node(1, 10, 2, 20), node(3, 30, 4, 40)], 3)
|
||||
.await;
|
||||
|
||||
let target = &aggregated.replication_stats.stats["arn"];
|
||||
assert_eq!(target.failed.count, 4);
|
||||
assert_eq!(target.failed.size, 40);
|
||||
assert_eq!(aggregated.replication_stats.q_stat.curr.count, 6);
|
||||
assert_eq!(aggregated.replication_stats.q_stat.curr.bytes, 60);
|
||||
assert_eq!(aggregated.replication_stats.observed_node_count, 2);
|
||||
assert_eq!(aggregated.replication_stats.expected_node_count, 3);
|
||||
assert!(!aggregated.replication_stats.cluster_complete);
|
||||
assert_eq!(aggregated.replication_stats.queue_scope, ReplicationMetricScope::PartialCluster);
|
||||
assert_eq!(target.latency_scope, ReplicationMetricScope::PartialCluster);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_queue_updates_are_visible_without_lost_counts() {
|
||||
let stats = Arc::new(ReplicationStats::new());
|
||||
let mut tasks = Vec::with_capacity(32);
|
||||
for _ in 0..32 {
|
||||
let stats = Arc::clone(&stats);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
stats.inc_q("concurrent-bucket", 7, false, ReplicationType::Object).await;
|
||||
}));
|
||||
}
|
||||
for task in tasks {
|
||||
task.await.expect("queue update task should complete");
|
||||
}
|
||||
|
||||
let snapshot = stats.get_latest_replication_stats("concurrent-bucket").await;
|
||||
assert_eq!(snapshot.replication_stats.q_stat.curr.count, 32);
|
||||
assert_eq!(snapshot.replication_stats.q_stat.curr.bytes, 224);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_all_includes_proxy_only_bucket() {
|
||||
let stats = ReplicationStats::new();
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
// limitations under the License.
|
||||
|
||||
pub use rustfs_replication::BucketStats;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_replication::FailStats;
|
||||
pub(crate) use rustfs_replication::{
|
||||
ActiveWorkerStat, BucketReplicationStat, BucketReplicationStats, InQueueMetric, ProxyMetric, ProxyStatsCache, QueueCache,
|
||||
SRMetricsSummary, XferStats,
|
||||
ReplicationMetricScope, SRMetricsSummary, XferStats,
|
||||
};
|
||||
|
||||
@@ -310,10 +310,8 @@ pub fn check_list_multipart_args(
|
||||
) -> Result<()> {
|
||||
check_list_objs_args(bucket, prefix, key_marker)?;
|
||||
|
||||
if let Some(upload_id_marker) = upload_id_marker {
|
||||
if let Some(key_marker) = key_marker
|
||||
&& key_marker.ends_with('/')
|
||||
{
|
||||
if let (Some(key_marker), Some(upload_id_marker)) = (key_marker, upload_id_marker) {
|
||||
if key_marker.ends_with('/') {
|
||||
return Err(StorageError::InvalidUploadIDKeyCombination(
|
||||
upload_id_marker.to_string(),
|
||||
key_marker.to_string(),
|
||||
@@ -629,6 +627,11 @@ mod tests {
|
||||
assert!(check_list_objs_args("INVALID", "", &None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_multipart_upload_marker_is_ignored_without_key_marker() {
|
||||
assert!(check_list_multipart_args("valid-bucket", "", &None, &Some("not-base64!".to_string()), &None,).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_multipart_args() {
|
||||
assert!(check_new_multipart_args("valid-bucket", "valid-object").is_ok());
|
||||
|
||||
@@ -85,4 +85,21 @@ impl BucketVersioningSys {
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Instance-scoped variant of [`Self::get`] (backlog#1052): resolves the
|
||||
/// caller's own instance context so a second in-process store never
|
||||
/// answers with the first instance's versioning state; falls back to the
|
||||
/// ambient system when the instance cell is not initialized.
|
||||
pub(crate) async fn get_in(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<VersioningConfiguration> {
|
||||
if bucket == RUSTFS_META_BUCKET || bucket.starts_with(RUSTFS_META_BUCKET) {
|
||||
return Ok(VersioningConfiguration::default());
|
||||
}
|
||||
|
||||
let bucket_meta_sys_lock = crate::bucket::metadata_sys::bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
let (cfg, _) = bucket_meta_sys.get_versioning_config(bucket).await?;
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,24 @@ fn classify_listing_quorum_failure(errors: &[DiskError]) -> DiskError {
|
||||
DiskError::ErasureReadQuorum
|
||||
}
|
||||
|
||||
/// Returns true when a metacache listing missed quorum purely because the
|
||||
/// volume or path is absent on a quorum of drives, i.e. every recorded failure
|
||||
/// is [`DiskError::VolumeNotFound`] or [`DiskError::FileNotFound`].
|
||||
///
|
||||
/// This is a benign, expected outcome: the caller is handed
|
||||
/// `VolumeNotFound`/`FileNotFound` and decides how to react. The most common
|
||||
/// trigger is a startup race where the system bucket has not yet been created
|
||||
/// on every drive when an early reader (e.g. the IAM config loader) lists it.
|
||||
/// It must be distinguished from a listing that failed for a real reason (I/O,
|
||||
/// timeout, corruption) so the former is not surfaced at `error`. See
|
||||
/// rustfs/rustfs#5076.
|
||||
fn is_benign_not_found_listing_failure(errors: &[DiskError]) -> bool {
|
||||
!errors.is_empty()
|
||||
&& errors
|
||||
.iter()
|
||||
.all(|err| matches!(err, DiskError::VolumeNotFound | DiskError::FileNotFound))
|
||||
}
|
||||
|
||||
struct PublishedBytesWriter<W> {
|
||||
inner: W,
|
||||
published: bool,
|
||||
@@ -249,6 +267,23 @@ impl Clone for ListPathRawOptions {
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
|
||||
WalkDirOptions {
|
||||
bucket: opts.bucket.clone(),
|
||||
base_dir: opts.path.clone(),
|
||||
recursive: opts.recursive,
|
||||
incl_deleted: opts.incl_deleted,
|
||||
report_notfound: opts.report_not_found,
|
||||
filter_prefix: opts.filter_prefix.clone(),
|
||||
forward_to: opts.forward_to.clone(),
|
||||
limit: opts.per_disk_limit,
|
||||
skip_total_timeout: opts.skip_walkdir_total_timeout,
|
||||
timeout_ms: opts.walkdir_timeout.map(duration_millis),
|
||||
stall_timeout_ms: opts.walkdir_stall_timeout.map(duration_millis),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
|
||||
let rx = rx.child_token();
|
||||
let _cancel_guard = rx.clone().drop_guard();
|
||||
@@ -355,20 +390,7 @@ async fn list_path_raw_inner(
|
||||
None
|
||||
};
|
||||
|
||||
let wakl_opts = WalkDirOptions {
|
||||
bucket: opts_clone.bucket.clone(),
|
||||
base_dir: opts_clone.path.clone(),
|
||||
recursive: opts_clone.recursive,
|
||||
incl_deleted: opts_clone.incl_deleted,
|
||||
report_notfound: opts_clone.report_not_found,
|
||||
filter_prefix: opts_clone.filter_prefix.clone(),
|
||||
forward_to: opts_clone.forward_to.clone(),
|
||||
limit: opts_clone.per_disk_limit,
|
||||
skip_total_timeout: opts_clone.skip_walkdir_total_timeout,
|
||||
timeout_ms: opts_clone.walkdir_timeout.map(duration_millis),
|
||||
stall_timeout_ms: opts_clone.walkdir_stall_timeout.map(duration_millis),
|
||||
..Default::default()
|
||||
};
|
||||
let wakl_opts = walk_dir_options(&opts_clone);
|
||||
|
||||
let mut need_fallback = false;
|
||||
let mut last_err = None;
|
||||
@@ -541,27 +563,7 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
|
||||
let fallback_walk_started = std::time::Instant::now();
|
||||
match disk
|
||||
.as_ref()
|
||||
.walk_dir(
|
||||
WalkDirOptions {
|
||||
bucket: opts_clone.bucket.clone(),
|
||||
base_dir: opts_clone.path.clone(),
|
||||
recursive: opts_clone.recursive,
|
||||
incl_deleted: opts_clone.incl_deleted,
|
||||
report_notfound: opts_clone.report_not_found,
|
||||
filter_prefix: opts_clone.filter_prefix.clone(),
|
||||
forward_to: opts_clone.forward_to.clone(),
|
||||
limit: opts_clone.per_disk_limit,
|
||||
skip_total_timeout: opts_clone.skip_walkdir_total_timeout,
|
||||
timeout_ms: opts_clone.walkdir_timeout.map(duration_millis),
|
||||
stall_timeout_ms: opts_clone.walkdir_stall_timeout.map(duration_millis),
|
||||
..Default::default()
|
||||
},
|
||||
&mut wr,
|
||||
)
|
||||
.await
|
||||
{
|
||||
match disk.as_ref().walk_dir(walk_dir_options(&opts_clone), &mut wr).await {
|
||||
Ok(_r) => {
|
||||
rustfs_io_metrics::record_stage_duration(
|
||||
"metacache_walk_dir_fallback",
|
||||
@@ -840,17 +842,38 @@ async fn list_path_raw_inner(
|
||||
_ => {}
|
||||
});
|
||||
|
||||
error!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %opts.bucket,
|
||||
path = %opts.path,
|
||||
state = "quorum_failed",
|
||||
error = %combined_err.join(", "),
|
||||
"Metacache listing quorum failed"
|
||||
);
|
||||
let failures = errs.iter().flatten().cloned().collect::<Vec<_>>();
|
||||
// A listing that misses quorum purely because the volume/path is
|
||||
// absent on a quorum of drives is benign and expected — the caller
|
||||
// receives VolumeNotFound/FileNotFound and decides how to react.
|
||||
// The common trigger is a startup race where the system bucket is
|
||||
// not yet created on every drive when an early reader (e.g. the IAM
|
||||
// config loader) lists it, so surfacing it at `error` is misleading
|
||||
// noise (rustfs/rustfs#5076). Keep `error` for listings that failed
|
||||
// for a real reason (I/O, timeout, corruption).
|
||||
if is_benign_not_found_listing_failure(&failures) {
|
||||
debug!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %opts.bucket,
|
||||
path = %opts.path,
|
||||
state = "quorum_not_found",
|
||||
error = %combined_err.join(", "),
|
||||
"Metacache listing quorum not reached (volume/path absent)"
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %opts.bucket,
|
||||
path = %opts.path,
|
||||
state = "quorum_failed",
|
||||
error = %combined_err.join(", "),
|
||||
"Metacache listing quorum failed"
|
||||
);
|
||||
}
|
||||
return Err(classify_listing_quorum_failure(&failures));
|
||||
}
|
||||
|
||||
@@ -1037,6 +1060,34 @@ mod tests {
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn benign_not_found_listing_failure_detection() {
|
||||
// Pure not-found quorum misses are benign (the volume/path simply does
|
||||
// not exist on a quorum of drives) and must not be logged at ERROR.
|
||||
assert!(is_benign_not_found_listing_failure(&[DiskError::VolumeNotFound]));
|
||||
assert!(is_benign_not_found_listing_failure(
|
||||
&[DiskError::VolumeNotFound, DiskError::FileNotFound,]
|
||||
));
|
||||
// No recorded failure is not a not-found case.
|
||||
assert!(!is_benign_not_found_listing_failure(&[]));
|
||||
// Any real error must keep the failure at ERROR severity.
|
||||
assert!(!is_benign_not_found_listing_failure(&[DiskError::VolumeNotFound, DiskError::Timeout,]));
|
||||
assert!(!is_benign_not_found_listing_failure(&[DiskError::DiskNotFound]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_dir_options_preserve_zero_total_and_inherited_stall_timeouts() {
|
||||
let options = walk_dir_options(&ListPathRawOptions {
|
||||
walkdir_timeout: Some(Duration::ZERO),
|
||||
walkdir_stall_timeout: None,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert_eq!(options.timeout_ms, Some(0));
|
||||
assert_eq!(options.stall_timeout_ms, None);
|
||||
assert!(!options.skip_total_timeout);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_empty_disks_returns_read_quorum() {
|
||||
let err = list_path_raw(CancellationToken::new(), ListPathRawOptions::default())
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::client::{
|
||||
ListBucketResult, ListBucketV2Result, ListMultipartUploadsResult, ListObjectPartsResult, ListVersionsResult, ObjectPart,
|
||||
},
|
||||
credentials,
|
||||
transition_api::{ReaderImpl, RequestMetadata, TransitionClient},
|
||||
transition_api::{ReaderImpl, RequestMetadata, TransitionClient, collect_response_body},
|
||||
};
|
||||
use crate::storage_api_contracts::bucket::BucketInfo;
|
||||
use http::{HeaderMap, StatusCode};
|
||||
@@ -88,7 +88,7 @@ impl TransitionClient {
|
||||
url_values.insert("max-keys".to_string(), max_keys.to_string());
|
||||
}
|
||||
|
||||
let mut resp = self
|
||||
let resp = self
|
||||
.execute_method(
|
||||
http::Method::GET,
|
||||
&mut RequestMetadata {
|
||||
@@ -164,7 +164,7 @@ impl TransitionClient {
|
||||
Ok(list_bucket_result)
|
||||
}
|
||||
|
||||
pub fn list_object_versions_query(
|
||||
pub async fn list_object_versions_query(
|
||||
&self,
|
||||
bucket_name: &str,
|
||||
opts: &ListObjectsOptions,
|
||||
@@ -172,93 +172,88 @@ impl TransitionClient {
|
||||
version_id_marker: &str,
|
||||
delimiter: &str,
|
||||
) -> Result<ListVersionsResult, std::io::Error> {
|
||||
/*if err := s3utils.CheckValidBucketName(bucketName); err != nil {
|
||||
return ListVersionsResult{}, err
|
||||
let mut url_values = HashMap::new();
|
||||
url_values.insert("versions".to_string(), "".to_string());
|
||||
url_values.insert("prefix".to_string(), opts.prefix.clone());
|
||||
url_values.insert("delimiter".to_string(), delimiter.to_string());
|
||||
url_values.insert("encoding-type".to_string(), "url".to_string());
|
||||
|
||||
if !key_marker.is_empty() {
|
||||
url_values.insert("key-marker".to_string(), key_marker.to_string());
|
||||
}
|
||||
if err := s3utils.CheckValidObjectNamePrefix(opts.Prefix); err != nil {
|
||||
return ListVersionsResult{}, err
|
||||
}
|
||||
urlValues := make(url.Values)
|
||||
|
||||
urlValues.Set("versions", "")
|
||||
|
||||
urlValues.Set("prefix", opts.Prefix)
|
||||
|
||||
urlValues.Set("delimiter", delimiter)
|
||||
|
||||
if keyMarker != "" {
|
||||
urlValues.Set("key-marker", keyMarker)
|
||||
}
|
||||
|
||||
if opts.max_keys > 0 {
|
||||
urlValues.Set("max-keys", fmt.Sprintf("%d", opts.max_keys))
|
||||
url_values.insert("max-keys".to_string(), opts.max_keys.to_string());
|
||||
}
|
||||
if !version_id_marker.is_empty() {
|
||||
url_values.insert("version-id-marker".to_string(), version_id_marker.to_string());
|
||||
}
|
||||
if opts.with_metadata {
|
||||
url_values.insert("metadata".to_string(), "true".to_string());
|
||||
}
|
||||
|
||||
if versionIDMarker != "" {
|
||||
urlValues.Set("version-id-marker", versionIDMarker)
|
||||
let mut resp = self
|
||||
.execute_method(
|
||||
http::Method::GET,
|
||||
&mut RequestMetadata {
|
||||
bucket_name: bucket_name.to_string(),
|
||||
object_name: "".to_string(),
|
||||
query_values: url_values,
|
||||
content_sha256_hex: EMPTY_STRING_SHA256_HASH.to_string(),
|
||||
custom_header: opts.headers.clone(),
|
||||
content_body: ReaderImpl::Body(Bytes::new()),
|
||||
content_length: 0,
|
||||
content_md5_base64: "".to_string(),
|
||||
stream_sha256: false,
|
||||
trailer: HeaderMap::new(),
|
||||
pre_sign_url: Default::default(),
|
||||
add_crc: Default::default(),
|
||||
extra_pre_sign_header: Default::default(),
|
||||
bucket_location: Default::default(),
|
||||
expires: Default::default(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let resp_status = resp.status();
|
||||
let headers = resp.headers().clone();
|
||||
let body = collect_response_body(resp.into_body(), MAX_S3_CLIENT_RESPONSE_SIZE).await?;
|
||||
if resp_status != StatusCode::OK {
|
||||
return Err(std::io::Error::other(http_resp_to_error_response(
|
||||
resp_status,
|
||||
&headers,
|
||||
body,
|
||||
bucket_name,
|
||||
"",
|
||||
)));
|
||||
}
|
||||
|
||||
if opts.WithMetadata {
|
||||
urlValues.Set("metadata", "true")
|
||||
let mut versions = quick_xml::de::from_reader::<_, ListVersionsResult>(body.as_slice())
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
|
||||
for version in &mut versions.versions {
|
||||
version.key = decode_s3_name(&version.key, &versions.encoding_type)?;
|
||||
}
|
||||
for marker in &mut versions.delete_markers {
|
||||
marker.key = decode_s3_name(&marker.key, &versions.encoding_type)?;
|
||||
}
|
||||
for prefix in &mut versions.common_prefixes {
|
||||
prefix.prefix = decode_s3_name(&prefix.prefix, &versions.encoding_type)?;
|
||||
}
|
||||
if !versions.next_key_marker.is_empty() {
|
||||
versions.next_key_marker = decode_s3_name(&versions.next_key_marker, &versions.encoding_type)?;
|
||||
}
|
||||
|
||||
urlValues.Set("encoding-type", "url")
|
||||
|
||||
let resp = self.executeMethod(http::Method::GET, &mut RequestMetadata{
|
||||
bucketName: bucketName,
|
||||
queryValues: urlValues,
|
||||
contentSHA256Hex: emptySHA256Hex,
|
||||
customHeader: opts.headers,
|
||||
}).await?;
|
||||
defer closeResponse(resp)
|
||||
if err != nil {
|
||||
return ListVersionsResult{}, err
|
||||
}
|
||||
if resp != nil {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ListVersionsResult{}, httpRespToErrorResponse(resp, bucketName, "")
|
||||
}
|
||||
}
|
||||
|
||||
listObjectVersionsOutput := ListVersionsResult{}
|
||||
err = xml_decoder(resp.Body, &listObjectVersionsOutput)
|
||||
if err != nil {
|
||||
return ListVersionsResult{}, err
|
||||
}
|
||||
|
||||
for i, obj := range listObjectVersionsOutput.Versions {
|
||||
listObjectVersionsOutput.Versions[i].Key, err = decode_s3_name(obj.Key, listObjectVersionsOutput.EncodingType)
|
||||
if err != nil {
|
||||
return listObjectVersionsOutput, err
|
||||
}
|
||||
}
|
||||
|
||||
for i, obj := range listObjectVersionsOutput.CommonPrefixes {
|
||||
listObjectVersionsOutput.CommonPrefixes[i].Prefix, err = decode_s3_name(obj.Prefix, listObjectVersionsOutput.EncodingType)
|
||||
if err != nil {
|
||||
return listObjectVersionsOutput, err
|
||||
}
|
||||
}
|
||||
|
||||
if listObjectVersionsOutput.NextKeyMarker != "" {
|
||||
listObjectVersionsOutput.NextKeyMarker, err = decode_s3_name(listObjectVersionsOutput.NextKeyMarker, listObjectVersionsOutput.EncodingType)
|
||||
if err != nil {
|
||||
return listObjectVersionsOutput, err
|
||||
}
|
||||
}
|
||||
|
||||
Ok(listObjectVersionsOutput)*/
|
||||
Err(std::io::Error::new(
|
||||
ErrorKind::Unsupported,
|
||||
credentials::ErrorResponse {
|
||||
if versions.is_truncated && versions.next_key_marker.is_empty() {
|
||||
return Err(std::io::Error::other(credentials::ErrorResponse {
|
||||
sts_error: credentials::STSError {
|
||||
r#type: "".to_string(),
|
||||
code: "NotImplemented".to_string(),
|
||||
message: format!("list_object_versions_query is not implemented for bucket {bucket_name}"),
|
||||
message: "Truncated ListObjectVersions response should have next key marker set".to_string(),
|
||||
},
|
||||
request_id: "".to_string(),
|
||||
},
|
||||
))
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
pub fn list_objects_query(
|
||||
@@ -364,6 +359,7 @@ impl TransitionClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ListObjectsOptions {
|
||||
reverse_versions: bool,
|
||||
@@ -431,3 +427,51 @@ fn decode_s3_name(name: &str, encoding_type: &str) -> Result<String, std::io::Er
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_versions_xml_preserves_versions_and_delete_markers() {
|
||||
let xml = br#"
|
||||
<ListVersionsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>tier-bucket</Name>
|
||||
<Prefix>archive/object</Prefix>
|
||||
<KeyMarker></KeyMarker>
|
||||
<VersionIdMarker></VersionIdMarker>
|
||||
<MaxKeys>2</MaxKeys>
|
||||
<IsTruncated>true</IsTruncated>
|
||||
<NextKeyMarker>archive/object</NextKeyMarker>
|
||||
<NextVersionIdMarker>version-a</NextVersionIdMarker>
|
||||
<Version>
|
||||
<Key>archive/object</Key>
|
||||
<VersionId>version-a</VersionId>
|
||||
<IsLatest>true</IsLatest>
|
||||
<LastModified>2026-07-22T00:00:00Z</LastModified>
|
||||
<ETag>"etag-a"</ETag>
|
||||
<Size>5</Size>
|
||||
<StorageClass>STANDARD</StorageClass>
|
||||
</Version>
|
||||
<DeleteMarker>
|
||||
<Key>archive/object</Key>
|
||||
<VersionId>marker-a</VersionId>
|
||||
<IsLatest>false</IsLatest>
|
||||
<LastModified>2026-07-22T00:00:01Z</LastModified>
|
||||
</DeleteMarker>
|
||||
</ListVersionsResult>
|
||||
"#;
|
||||
|
||||
let parsed =
|
||||
quick_xml::de::from_reader::<_, ListVersionsResult>(xml.as_slice()).expect("ListObjectVersions XML should parse");
|
||||
|
||||
assert!(parsed.is_truncated);
|
||||
assert_eq!(parsed.next_key_marker, "archive/object");
|
||||
assert_eq!(parsed.next_version_id_marker, "version-a");
|
||||
assert_eq!(parsed.versions.len(), 1);
|
||||
assert_eq!(parsed.versions[0].key, "archive/object");
|
||||
assert_eq!(parsed.versions[0].version_id, "version-a");
|
||||
assert_eq!(parsed.delete_markers.len(), 1);
|
||||
assert_eq!(parsed.delete_markers[0].version_id, "marker-a");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ impl TransitionClient {
|
||||
bucket: complete_multipart_upload_result.bucket,
|
||||
key: complete_multipart_upload_result.key,
|
||||
etag: trim_etag(&complete_multipart_upload_result.etag),
|
||||
version_id: self.raw_version_id(&h)?.unwrap_or_default().to_string(),
|
||||
version_id: self.legacy_remote_version_id(&h)?,
|
||||
location: complete_multipart_upload_result.location,
|
||||
expiration: exp_time,
|
||||
expiration_rule_id: rule_id,
|
||||
|
||||
@@ -22,7 +22,7 @@ use bytes::Bytes;
|
||||
use futures::future::join_all;
|
||||
use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use std::io::Error;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Mutex, MutexGuard, RwLock};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use time::{OffsetDateTime, format_description};
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -46,6 +46,14 @@ use crate::client::utils::base64_encode;
|
||||
use rustfs_utils::path::trim_etag;
|
||||
use s3s::header::X_AMZ_EXPIRATION;
|
||||
|
||||
fn lock_md5_hasher(
|
||||
md5_hasher: &Mutex<Option<rustfs_utils::hash::HashAlgorithm>>,
|
||||
) -> Result<MutexGuard<'_, Option<rustfs_utils::hash::HashAlgorithm>>, std::io::Error> {
|
||||
md5_hasher
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("MD5 hasher state is unavailable"))
|
||||
}
|
||||
|
||||
/// Read exactly `want` bytes for a single multipart part, or fewer if the reader
|
||||
/// reaches EOF first. Advances the reader so the next call returns the following
|
||||
/// part. Replaces the previous per-part `read_all()`/`to_vec()`, which drained
|
||||
@@ -177,7 +185,7 @@ impl TransitionClient {
|
||||
let length = buf.len();
|
||||
|
||||
if opts.send_content_md5 {
|
||||
let mut md5_hasher = self.md5_hasher.lock().unwrap();
|
||||
let mut md5_hasher = lock_md5_hasher(&self.md5_hasher)?;
|
||||
let md5_hash = match md5_hasher.as_mut() {
|
||||
Some(hasher) => hasher,
|
||||
None => return Err(std::io::Error::other("MD5 hasher not initialized")),
|
||||
@@ -370,7 +378,7 @@ impl TransitionClient {
|
||||
let mut md5_base64: String = "".to_string();
|
||||
|
||||
if opts.send_content_md5 {
|
||||
let mut md5_hasher = clone_self.md5_hasher.lock().unwrap();
|
||||
let mut md5_hasher = lock_md5_hasher(&clone_self.md5_hasher)?;
|
||||
let md5_hash = match md5_hasher.as_mut() {
|
||||
Some(hasher) => hasher,
|
||||
None => {
|
||||
@@ -418,6 +426,9 @@ impl TransitionClient {
|
||||
}
|
||||
|
||||
let results = join_all(futures).await;
|
||||
for result in results {
|
||||
result?;
|
||||
}
|
||||
|
||||
select! {
|
||||
err = err_rx.recv() => {
|
||||
@@ -567,7 +578,7 @@ impl TransitionClient {
|
||||
key: object_name.to_string(),
|
||||
etag: trim_etag(h.get("ETag").and_then(|v| v.to_str().ok()).unwrap_or("")),
|
||||
|
||||
version_id: self.raw_version_id(h)?.unwrap_or_default().to_string(),
|
||||
version_id: self.legacy_remote_version_id(h)?,
|
||||
size,
|
||||
expiration: exp_time,
|
||||
expiration_rule_id: rule_id,
|
||||
@@ -620,10 +631,12 @@ fn collect_complete_parts(parts_info: &HashMap<i64, ObjectPart>, total_parts_cou
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ObjectPart, ReaderImpl, collect_complete_parts, read_multipart_part};
|
||||
use super::{ObjectPart, ReaderImpl, collect_complete_parts, lock_md5_hasher, read_multipart_part};
|
||||
use crate::object_api::GetObjectReader;
|
||||
use bytes::Bytes;
|
||||
use rustfs_utils::hash::HashAlgorithm;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// Drive a reader through the same per-part loop the multipart stream uses and
|
||||
// collect the size of every part. Regression for rustfs/rustfs#4811: the old
|
||||
@@ -733,4 +746,18 @@ mod tests {
|
||||
"a gap in the parts map must be an error, not a panic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisoned_md5_state_fails_closed() {
|
||||
let hasher = Arc::new(Mutex::new(Some(HashAlgorithm::Md5)));
|
||||
let poison_target = Arc::clone(&hasher);
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = poison_target.lock().expect("fresh mutex should lock");
|
||||
panic!("poison MD5 state");
|
||||
})
|
||||
.join();
|
||||
|
||||
let error = lock_md5_hasher(&hasher).expect_err("poisoned hash state must not be reused");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::Other);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ impl TransitionClient {
|
||||
object_name: object_name.to_string(),
|
||||
object_version_id: opts.version_id,
|
||||
delete_marker: resp.headers().get(X_AMZ_DELETE_MARKER).map_or(false, |v| v == "true"),
|
||||
delete_marker_version_id: self.raw_version_id(resp.headers())?.unwrap_or_default().to_string(),
|
||||
delete_marker_version_id: self.legacy_remote_version_id(resp.headers())?,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,34 +55,38 @@ pub struct ListBucketV2Result {
|
||||
pub start_after: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "PascalCase")]
|
||||
pub struct Version {
|
||||
etag: String,
|
||||
is_latest: bool,
|
||||
key: String,
|
||||
last_modified: OffsetDateTime,
|
||||
owner: Owner,
|
||||
size: i64,
|
||||
storage_class: String,
|
||||
version_id: String,
|
||||
user_metadata: HashMap<String, String>,
|
||||
user_tags: HashMap<String, String>,
|
||||
is_delete_marker: bool,
|
||||
#[serde(rename = "ETag")]
|
||||
pub etag: String,
|
||||
pub is_latest: bool,
|
||||
pub key: String,
|
||||
pub size: i64,
|
||||
pub storage_class: String,
|
||||
pub version_id: String,
|
||||
pub user_metadata: HashMap<String, String>,
|
||||
pub user_tags: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "PascalCase")]
|
||||
pub struct ListVersionsResult {
|
||||
versions: Vec<Version>,
|
||||
common_prefixes: Vec<CommonPrefix>,
|
||||
name: String,
|
||||
prefix: String,
|
||||
delimiter: String,
|
||||
max_keys: i64,
|
||||
encoding_type: String,
|
||||
is_truncated: bool,
|
||||
key_marker: String,
|
||||
version_id_marker: String,
|
||||
next_key_marker: String,
|
||||
next_version_id_marker: String,
|
||||
#[serde(rename = "Version")]
|
||||
pub versions: Vec<Version>,
|
||||
#[serde(rename = "DeleteMarker")]
|
||||
pub delete_markers: Vec<Version>,
|
||||
pub common_prefixes: Vec<CommonPrefix>,
|
||||
pub name: String,
|
||||
pub prefix: String,
|
||||
pub delimiter: String,
|
||||
pub max_keys: i64,
|
||||
pub encoding_type: String,
|
||||
pub is_truncated: bool,
|
||||
pub key_marker: String,
|
||||
pub version_id_marker: String,
|
||||
pub next_key_marker: String,
|
||||
pub next_version_id_marker: String,
|
||||
}
|
||||
|
||||
pub struct ListBucketResult {
|
||||
|
||||
@@ -46,11 +46,33 @@ impl RemoteVersion {
|
||||
Self::Unknown | Self::Disabled => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn exact_request_id(&self) -> Result<Option<&str>, Error> {
|
||||
match self {
|
||||
Self::Unknown => Err(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
"remote object version is unknown; exact version routing is unsafe",
|
||||
)),
|
||||
Self::Disabled => Ok(None),
|
||||
Self::SuspendedNull => Ok(Some("null")),
|
||||
Self::Exact(version_id) => Ok(Some(version_id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ConditionalCreateCapability {
|
||||
Unsupported,
|
||||
IfNoneMatchStar,
|
||||
GenerationMatchZero,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct ProviderVersionCapabilities {
|
||||
raw_version_header: Option<&'static str>,
|
||||
pub(crate) bucket_versioning_state: bool,
|
||||
pub(crate) list_object_versions: bool,
|
||||
pub(crate) conditional_create: ConditionalCreateCapability,
|
||||
pub(crate) exact_get_delete: bool,
|
||||
}
|
||||
|
||||
@@ -62,28 +84,59 @@ impl ProviderVersionCapabilities {
|
||||
|| tier_type.eq_ignore_ascii_case("r2")
|
||||
|| tier_type.eq_ignore_ascii_case("wasabi")
|
||||
{
|
||||
let list_object_versions = tier_type.eq_ignore_ascii_case("s3")
|
||||
|| tier_type.eq_ignore_ascii_case("rustfs")
|
||||
|| tier_type.eq_ignore_ascii_case("minio")
|
||||
|| tier_type.eq_ignore_ascii_case("r2");
|
||||
Self {
|
||||
raw_version_header: Some(X_AMZ_VERSION_ID),
|
||||
bucket_versioning_state: list_object_versions,
|
||||
list_object_versions,
|
||||
conditional_create: if tier_type.eq_ignore_ascii_case("s3") || tier_type.eq_ignore_ascii_case("r2") {
|
||||
ConditionalCreateCapability::IfNoneMatchStar
|
||||
} else {
|
||||
ConditionalCreateCapability::Unsupported
|
||||
},
|
||||
exact_get_delete: true,
|
||||
}
|
||||
} else if tier_type.eq_ignore_ascii_case("aliyun") {
|
||||
Self {
|
||||
raw_version_header: Some(X_OSS_VERSION_ID),
|
||||
bucket_versioning_state: false,
|
||||
list_object_versions: false,
|
||||
conditional_create: ConditionalCreateCapability::Unsupported,
|
||||
exact_get_delete: true,
|
||||
}
|
||||
} else if tier_type.eq_ignore_ascii_case("tencent") {
|
||||
Self {
|
||||
raw_version_header: Some(X_COS_VERSION_ID),
|
||||
bucket_versioning_state: false,
|
||||
list_object_versions: false,
|
||||
conditional_create: ConditionalCreateCapability::Unsupported,
|
||||
exact_get_delete: true,
|
||||
}
|
||||
} else if tier_type.eq_ignore_ascii_case("huaweicloud") {
|
||||
Self {
|
||||
raw_version_header: Some(X_OBS_VERSION_ID),
|
||||
bucket_versioning_state: false,
|
||||
list_object_versions: false,
|
||||
conditional_create: ConditionalCreateCapability::Unsupported,
|
||||
exact_get_delete: true,
|
||||
}
|
||||
} else if tier_type.eq_ignore_ascii_case("gcs") {
|
||||
Self {
|
||||
raw_version_header: None,
|
||||
bucket_versioning_state: false,
|
||||
list_object_versions: false,
|
||||
conditional_create: ConditionalCreateCapability::GenerationMatchZero,
|
||||
exact_get_delete: false,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
raw_version_header: None,
|
||||
bucket_versioning_state: false,
|
||||
list_object_versions: false,
|
||||
conditional_create: ConditionalCreateCapability::Unsupported,
|
||||
exact_get_delete: false,
|
||||
}
|
||||
}
|
||||
@@ -119,7 +172,7 @@ impl ProviderVersionCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_remote_version_id(version_id: &str) -> Result<(), Error> {
|
||||
pub(crate) fn validate_remote_version_id(version_id: &str) -> Result<(), Error> {
|
||||
if version_id.is_empty() {
|
||||
return Err(Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
@@ -143,7 +196,7 @@ fn validate_remote_version_id(version_id: &str) -> Result<(), Error> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BucketVersioningState, ProviderVersionCapabilities, RemoteVersion};
|
||||
use super::{BucketVersioningState, ConditionalCreateCapability, ProviderVersionCapabilities, RemoteVersion};
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
|
||||
#[test]
|
||||
@@ -219,6 +272,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_capability_matrix_is_conservative_and_provider_specific() {
|
||||
for (tier_type, state, list, conditional_create, exact_get_delete) in [
|
||||
("s3", true, true, ConditionalCreateCapability::IfNoneMatchStar, true),
|
||||
("rustfs", true, true, ConditionalCreateCapability::Unsupported, true),
|
||||
("minio", true, true, ConditionalCreateCapability::Unsupported, true),
|
||||
("r2", true, true, ConditionalCreateCapability::IfNoneMatchStar, true),
|
||||
("wasabi", false, false, ConditionalCreateCapability::Unsupported, true),
|
||||
("aliyun", false, false, ConditionalCreateCapability::Unsupported, true),
|
||||
("tencent", false, false, ConditionalCreateCapability::Unsupported, true),
|
||||
("huaweicloud", false, false, ConditionalCreateCapability::Unsupported, true),
|
||||
("gcs", false, false, ConditionalCreateCapability::GenerationMatchZero, false),
|
||||
("azure", false, false, ConditionalCreateCapability::Unsupported, false),
|
||||
("unsupported", false, false, ConditionalCreateCapability::Unsupported, false),
|
||||
] {
|
||||
let capabilities = ProviderVersionCapabilities::for_tier_type(tier_type);
|
||||
assert_eq!(capabilities.bucket_versioning_state, state, "{tier_type} versioning state");
|
||||
assert_eq!(capabilities.list_object_versions, list, "{tier_type} version listing");
|
||||
assert_eq!(capabilities.conditional_create, conditional_create, "{tier_type} conditional create");
|
||||
assert_eq!(capabilities.exact_get_delete, exact_get_delete, "{tier_type} exact routing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_version_states_preserve_unknown_disabled_suspended_and_exact() {
|
||||
let capabilities = ProviderVersionCapabilities::for_tier_type("s3");
|
||||
let empty = HeaderMap::new();
|
||||
let mut null = HeaderMap::new();
|
||||
null.insert("x-amz-version-id", HeaderValue::from_static("null"));
|
||||
let mut exact = HeaderMap::new();
|
||||
exact.insert("x-amz-version-id", HeaderValue::from_static("opaque.generation-7"));
|
||||
|
||||
for (headers, state, expected) in [
|
||||
(&empty, BucketVersioningState::Unknown, RemoteVersion::Unknown),
|
||||
(&empty, BucketVersioningState::Disabled, RemoteVersion::Disabled),
|
||||
(&empty, BucketVersioningState::Suspended, RemoteVersion::Unknown),
|
||||
(&empty, BucketVersioningState::Enabled, RemoteVersion::Unknown),
|
||||
(&null, BucketVersioningState::Suspended, RemoteVersion::SuspendedNull),
|
||||
(
|
||||
&exact,
|
||||
BucketVersioningState::Enabled,
|
||||
RemoteVersion::Exact("opaque.generation-7".to_string()),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
capabilities
|
||||
.remote_version(headers, state)
|
||||
.expect("version state should normalize"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_request_routing_fails_closed_for_unknown_versions() {
|
||||
for (version, expected) in [
|
||||
(RemoteVersion::Disabled, None),
|
||||
(RemoteVersion::SuspendedNull, Some("null")),
|
||||
(RemoteVersion::Exact("opaque-v1".to_string()), Some("opaque-v1")),
|
||||
] {
|
||||
assert_eq!(version.exact_request_id().expect("known version state"), expected);
|
||||
}
|
||||
assert!(RemoteVersion::Unknown.exact_request_id().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_version_rejects_empty_or_oversized_headers() {
|
||||
let oversized = "v".repeat(1025);
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::client::{
|
||||
},
|
||||
constants::{UNSIGNED_PAYLOAD, UNSIGNED_PAYLOAD_TRAILER},
|
||||
credentials::{CredContext, Credentials, SignatureType, Static},
|
||||
provider_versions::{BucketVersioningState, ProviderVersionCapabilities},
|
||||
provider_versions::{BucketVersioningState, ProviderVersionCapabilities, RemoteVersion},
|
||||
signer_error,
|
||||
};
|
||||
use crate::{client::checksum::ChecksumMode, object_api::GetObjectReader};
|
||||
@@ -332,6 +332,22 @@ impl TransitionClient {
|
||||
self.provider_version_capabilities().raw_version_id(headers)
|
||||
}
|
||||
|
||||
pub(crate) fn remote_version(
|
||||
&self,
|
||||
headers: &HeaderMap,
|
||||
versioning: BucketVersioningState,
|
||||
) -> Result<RemoteVersion, std::io::Error> {
|
||||
self.provider_version_capabilities().remote_version(headers, versioning)
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_remote_version_id(&self, headers: &HeaderMap) -> Result<String, std::io::Error> {
|
||||
Ok(self
|
||||
.remote_version(headers, BucketVersioningState::Unknown)?
|
||||
.exact_id()
|
||||
.unwrap_or_default()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn trace_errors_only_off(&self) {
|
||||
if let Ok(mut trace_errors_only) = self.trace_errors_only.lock() {
|
||||
*trace_errors_only = false;
|
||||
@@ -1095,6 +1111,16 @@ impl Default for ObjectInfo {
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectInfo {
|
||||
pub(crate) fn remote_version(
|
||||
&self,
|
||||
capabilities: ProviderVersionCapabilities,
|
||||
versioning: BucketVersioningState,
|
||||
) -> Result<RemoteVersion, std::io::Error> {
|
||||
capabilities.remote_version(&self.metadata, versioning)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct RestoreInfo {
|
||||
ongoing_restore: bool,
|
||||
@@ -1414,7 +1440,7 @@ mod tests {
|
||||
MAX_S3_CLIENT_RESPONSE_SIZE, MAX_S3_ERROR_RESPONSE_SIZE, SignatureType, build_tls_config, collect_response_body,
|
||||
signer_error_to_io_error, to_object_info_for_provider, validate_header_values, with_rustls_init_guard,
|
||||
};
|
||||
use crate::client::provider_versions::ProviderVersionCapabilities;
|
||||
use crate::client::provider_versions::{BucketVersioningState, ProviderVersionCapabilities, RemoteVersion};
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use http_body_util::Full;
|
||||
use hyper::body::Bytes;
|
||||
@@ -1539,6 +1565,11 @@ mod tests {
|
||||
.expect("opaque provider version should parse");
|
||||
|
||||
assert_eq!(info.version_id, None);
|
||||
assert_eq!(
|
||||
info.remote_version(ProviderVersionCapabilities::for_tier_type("tencent"), BucketVersioningState::Enabled,)
|
||||
.expect("opaque response version should remain available"),
|
||||
RemoteVersion::Exact("opaque.version_01".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
info.metadata.get("x-cos-version-id").and_then(|value| value.to_str().ok()),
|
||||
Some("opaque.version_01")
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-r5qv-rc46-hv8q>
|
||||
|
||||
use crate::cluster::rpc::context_propagation::{inject_request_id_into_http_headers, inject_trace_context_into_http_headers};
|
||||
use crate::storage_api_contracts::internode::NS_SCANNER_PROTOCOL_VERSION;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose;
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
@@ -35,6 +36,8 @@ 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_utils::get_env_bool;
|
||||
use sha2::Digest as _;
|
||||
use sha2::Sha256;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
@@ -58,8 +61,30 @@ 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 MAX_REPLAY_PROTECTED_NONCES: usize = 65_536;
|
||||
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(|| {
|
||||
get_env_bool(
|
||||
rustfs_config::ENV_INTERNODE_RPC_SIGNATURE_STRICT,
|
||||
rustfs_config::DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT,
|
||||
)
|
||||
});
|
||||
static INTERNODE_RPC_BODY_DIGEST_STRICT: LazyLock<bool> = LazyLock::new(|| {
|
||||
get_env_bool(
|
||||
rustfs_config::ENV_INTERNODE_RPC_BODY_DIGEST_STRICT,
|
||||
rustfs_config::DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT,
|
||||
)
|
||||
});
|
||||
// Sized for peak legitimate body-bound mutation RPS x the retention window; 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 body-bound 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)
|
||||
});
|
||||
static RPC_SECRET_RESOLUTION_LOG_ONCE: Once = Once::new();
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -100,6 +125,10 @@ impl RpcNonceCache {
|
||||
return Err(std::io::Error::other("RPC request replay detected"));
|
||||
}
|
||||
if self.nonces.len() >= 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"));
|
||||
}
|
||||
self.nonces.insert(nonce);
|
||||
@@ -201,6 +230,42 @@ fn verify_signature(secret: &str, url: &str, method: &Method, timestamp: i64, si
|
||||
mac.verify_slice(&signature).is_ok()
|
||||
}
|
||||
|
||||
fn update_ns_scanner_capability_mac(mac: &mut HmacSha256, challenge: Uuid, server_epoch: Uuid) {
|
||||
mac.update(NS_SCANNER_CAPABILITY_AUTH_DOMAIN);
|
||||
mac.update(&NS_SCANNER_PROTOCOL_VERSION.to_be_bytes());
|
||||
mac.update(challenge.as_bytes());
|
||||
mac.update(server_epoch.as_bytes());
|
||||
}
|
||||
|
||||
fn generate_ns_scanner_capability_proof(secret: &str, challenge: Uuid, server_epoch: Uuid) -> std::io::Result<Vec<u8>> {
|
||||
if challenge.is_nil() || server_epoch.is_nil() {
|
||||
return Err(std::io::Error::other("Invalid namespace scanner capability scope"));
|
||||
}
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_ns_scanner_capability_mac(&mut mac, challenge, server_epoch);
|
||||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn verify_ns_scanner_capability_proof(secret: &str, challenge: Uuid, server_epoch: Uuid, proof: &[u8]) -> std::io::Result<()> {
|
||||
if challenge.is_nil() || server_epoch.is_nil() {
|
||||
return Err(std::io::Error::other("Invalid namespace scanner capability scope"));
|
||||
}
|
||||
let mut mac =
|
||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
||||
update_ns_scanner_capability_mac(&mut mac, challenge, server_epoch);
|
||||
mac.verify_slice(proof)
|
||||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Invalid namespace scanner capability proof"))
|
||||
}
|
||||
|
||||
pub fn sign_ns_scanner_capability(challenge: Uuid, server_epoch: Uuid) -> std::io::Result<Vec<u8>> {
|
||||
generate_ns_scanner_capability_proof(&get_shared_secret()?, challenge, server_epoch)
|
||||
}
|
||||
|
||||
pub fn verify_ns_scanner_capability(challenge: Uuid, server_epoch: Uuid, proof: &[u8]) -> std::io::Result<()> {
|
||||
verify_ns_scanner_capability_proof(&get_shared_secret()?, challenge, server_epoch, proof)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct SignatureV2Scope<'a> {
|
||||
audience: &'a str,
|
||||
@@ -288,7 +353,7 @@ fn check_and_record_nonce(nonce: Uuid, signed_at: i64) -> std::io::Result<()> {
|
||||
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, MAX_REPLAY_PROTECTED_NONCES)
|
||||
cache.check_and_record(nonce, signed_at, now, wall_time, expires_at, *REPLAY_CACHE_CAPACITY)
|
||||
}
|
||||
|
||||
/// Build headers with authentication signature
|
||||
@@ -401,6 +466,50 @@ pub fn verify_tonic_canonical_body_digest<T>(request: &tonic::Request<T>, canoni
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify a mutating disk RPC's canonical body digest with a rolling-upgrade fallback.
|
||||
///
|
||||
/// When the request carries a real (non-`UNSIGNED-PAYLOAD`) content SHA-256 it is verified exactly
|
||||
/// like [`verify_tonic_canonical_body_digest`]. The digest value is a member of the signed v2
|
||||
/// scope, so within the v2 lane it cannot be stripped or altered without invalidating the signature
|
||||
/// `check_auth` already enforced. When the request carries no digest — a peer that predates
|
||||
/// body-digest signing, or an attacker who downgraded the request to the legacy signature by
|
||||
/// dropping every v2 header — the request is accepted and counted on the body-digest fallback
|
||||
/// counter unless `RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT` is enabled. That switch is what actually
|
||||
/// closes on-path body tampering for covered handlers: it rejects every digestless mutation,
|
||||
/// including v1-downgraded ones. It converges independently of the signature-strict switch
|
||||
/// (<https://github.com/rustfs/backlog/issues/1327>).
|
||||
pub fn verify_tonic_mutation_body_digest<T>(request: &tonic::Request<T>, canonical_body: &[u8]) -> std::io::Result<()> {
|
||||
verify_tonic_mutation_body_digest_with_strictness(request, canonical_body, *INTERNODE_RPC_BODY_DIGEST_STRICT)
|
||||
}
|
||||
|
||||
/// [`verify_tonic_mutation_body_digest`] with the strict gate injected as a parameter, so both
|
||||
/// rollout postures are unit-testable without racing on process-global environment variables.
|
||||
fn verify_tonic_mutation_body_digest_with_strictness<T>(
|
||||
request: &tonic::Request<T>,
|
||||
canonical_body: &[u8],
|
||||
strict: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let digest = request
|
||||
.metadata()
|
||||
.get(RPC_CONTENT_SHA256_HEADER)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
match digest {
|
||||
Some(digest) if digest != UNSIGNED_PAYLOAD => verify_tonic_canonical_body_digest(request, canonical_body),
|
||||
_ => {
|
||||
// RUSTFS_COMPAT_TODO(disk-mutation-body-digest): accept digestless peers during rolling upgrades. Remove after the
|
||||
// minimum supported RustFS peer version body-binds every mutating disk RPC.
|
||||
if strict {
|
||||
return Err(std::io::Error::other("RPC mutation requires a body-bound v2 signature"));
|
||||
}
|
||||
// Count only ACCEPTED digestless mutations: this counter is the convergence gate that
|
||||
// must read zero fleet-wide across a release window before
|
||||
// `RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT` may be enabled.
|
||||
global_internode_metrics().record_body_digest_fallback();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_v2_auth_headers(headers: &HeaderMap) -> bool {
|
||||
[
|
||||
RPC_AUTH_VERSION_HEADER,
|
||||
@@ -412,12 +521,40 @@ fn has_v2_auth_headers(headers: &HeaderMap) -> bool {
|
||||
.any(|name| headers.contains_key(*name))
|
||||
}
|
||||
|
||||
/// Whether the server requires target-bound v2 authentication on every internode gRPC request,
|
||||
/// rejecting the legacy constant-target fallback instead of accepting it. Default-off rollout
|
||||
/// lever gated on the v1-fallback counter reading zero fleet-wide; see
|
||||
/// [`rustfs_config::ENV_INTERNODE_RPC_SIGNATURE_STRICT`] and
|
||||
/// <https://github.com/rustfs/backlog/issues/1327>.
|
||||
fn internode_rpc_signature_strict() -> bool {
|
||||
*INTERNODE_RPC_SIGNATURE_STRICT
|
||||
}
|
||||
|
||||
/// Verify gRPC authentication, preferring v2 without downgrade on malformed v2 metadata.
|
||||
pub fn verify_tonic_rpc_signature(audience: &str, path: &str, headers: &HeaderMap) -> std::io::Result<()> {
|
||||
verify_tonic_rpc_signature_with_strictness(audience, path, headers, internode_rpc_signature_strict())
|
||||
}
|
||||
|
||||
/// [`verify_tonic_rpc_signature`] with the strict gate injected as a parameter, so both rollout
|
||||
/// postures are unit-testable without racing on process-global environment variables.
|
||||
fn verify_tonic_rpc_signature_with_strictness(
|
||||
audience: &str,
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
strict: bool,
|
||||
) -> std::io::Result<()> {
|
||||
if !has_v2_auth_headers(headers) {
|
||||
// RUSTFS_COMPAT_TODO(heal-rpc-auth-v2): accept old peers during rolling upgrades. Remove after the minimum
|
||||
// supported RustFS peer version sends v2 authentication on every internode gRPC request.
|
||||
return verify_rpc_signature(TONIC_RPC_PREFIX, &Method::GET, headers);
|
||||
if strict {
|
||||
return Err(std::io::Error::other("RPC v2 authentication required"));
|
||||
}
|
||||
verify_rpc_signature(TONIC_RPC_PREFIX, &Method::GET, headers)?;
|
||||
// Count only ACCEPTED legacy-only requests: this counter is the convergence gate that must
|
||||
// read zero fleet-wide across a release window before
|
||||
// `RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT` may be enabled.
|
||||
global_internode_metrics().record_signature_v1_fallback();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let path = path
|
||||
@@ -593,6 +730,20 @@ mod tests {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_scanner_capability_proof_binds_challenge_and_server_epoch() {
|
||||
let secret = "test-scanner-capability-secret";
|
||||
let challenge = Uuid::new_v4();
|
||||
let server_epoch = Uuid::new_v4();
|
||||
let proof =
|
||||
generate_ns_scanner_capability_proof(secret, challenge, server_epoch).expect("capability proof should be generated");
|
||||
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, server_epoch, &proof).is_ok());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, Uuid::new_v4(), server_epoch, &proof).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof(secret, challenge, Uuid::new_v4(), &proof).is_err());
|
||||
assert!(verify_ns_scanner_capability_proof("different-secret", challenge, server_epoch, &proof).is_err());
|
||||
}
|
||||
|
||||
/// Security regression for GHSA-r5qv-rc46-hv8q (internode RPC fail-closed,
|
||||
/// fixed in rustfs/rustfs#4402): secret resolution must never silently fall
|
||||
/// back to a default/empty shared secret. Missing and default secrets both
|
||||
@@ -1111,7 +1262,10 @@ mod tests {
|
||||
assert_eq!(error.to_string(), "Invalid RPC v2 signature");
|
||||
}
|
||||
|
||||
// The `rpc_v1_fallback_counter` serial group covers every test that drives (or asserts on) the
|
||||
// process-global v1-fallback counter, so exact-delta assertions cannot race with each other.
|
||||
#[test]
|
||||
#[serial_test::serial(rpc_v1_fallback_counter)]
|
||||
fn legacy_tonic_signature_remains_accepted_during_rolling_upgrade() {
|
||||
ensure_test_rpc_secret();
|
||||
let headers = gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET).expect("legacy auth headers should build");
|
||||
@@ -1119,6 +1273,87 @@ mod tests {
|
||||
assert!(verify_tonic_rpc_signature("node-a:9000", "/node_service.NodeService/Ping", &headers).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(rpc_v1_fallback_counter)]
|
||||
fn accepted_legacy_fallback_increments_v1_fallback_counter() {
|
||||
ensure_test_rpc_secret();
|
||||
let headers = gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET).expect("legacy auth headers should build");
|
||||
let before = global_internode_metrics().snapshot().signature_v1_fallback_total;
|
||||
|
||||
assert!(
|
||||
verify_tonic_rpc_signature_with_strictness("node-a:9000", "/node_service.NodeService/Ping", &headers, false).is_ok(),
|
||||
"a legacy-only peer must keep authenticating while the strict gate is off"
|
||||
);
|
||||
|
||||
let after = global_internode_metrics().snapshot().signature_v1_fallback_total;
|
||||
assert_eq!(
|
||||
after,
|
||||
before + 1,
|
||||
"an accepted legacy-only request must increment the v1 fallback counter exactly once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(rpc_v1_fallback_counter)]
|
||||
fn rejected_legacy_fallback_does_not_count_as_v1_fallback() {
|
||||
ensure_test_rpc_secret();
|
||||
// Legacy-shaped headers with a forged signature: the fallback path runs but must reject,
|
||||
// and a rejected request is not a rollout-convergence signal.
|
||||
let mut headers = HeaderMap::new();
|
||||
let now = OffsetDateTime::now_utc().unix_timestamp();
|
||||
headers.insert(SIGNATURE_HEADER, HeaderValue::from_static("not-a-real-signature"));
|
||||
headers.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&now.to_string()).unwrap());
|
||||
let before = global_internode_metrics().snapshot().signature_v1_fallback_total;
|
||||
|
||||
assert!(
|
||||
verify_tonic_rpc_signature_with_strictness("node-a:9000", "/node_service.NodeService/Ping", &headers, false).is_err(),
|
||||
"a forged legacy signature must still be rejected"
|
||||
);
|
||||
|
||||
let after = global_internode_metrics().snapshot().signature_v1_fallback_total;
|
||||
assert_eq!(after, before, "a rejected legacy request must not count as an accepted fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(rpc_v1_fallback_counter)]
|
||||
fn strict_gate_rejects_legacy_only_auth_but_keeps_v2() {
|
||||
ensure_test_rpc_secret();
|
||||
let legacy = gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET).expect("legacy auth headers should build");
|
||||
let before = global_internode_metrics().snapshot().signature_v1_fallback_total;
|
||||
let error = verify_tonic_rpc_signature_with_strictness("node-a:9000", "/node_service.NodeService/Ping", &legacy, true)
|
||||
.expect_err("strict mode must reject legacy-only authentication");
|
||||
assert_eq!(error.to_string(), "RPC v2 authentication required");
|
||||
|
||||
let v2 = gen_tonic_signature_headers("node-a:9000", "node_service.NodeService", "Ping", None)
|
||||
.expect("tonic auth headers should build");
|
||||
assert!(
|
||||
verify_tonic_rpc_signature_with_strictness("node-a:9000", "/node_service.NodeService/Ping", &v2, true).is_ok(),
|
||||
"strict mode must keep accepting v2-authenticated peers"
|
||||
);
|
||||
let after = global_internode_metrics().snapshot().signature_v1_fallback_total;
|
||||
assert_eq!(after, before, "neither a strict rejection nor a v2 acceptance is a legacy fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(rpc_v1_fallback_counter)]
|
||||
fn strict_gate_default_posture_is_fail_open_legacy_accept() {
|
||||
ensure_test_rpc_secret();
|
||||
// The public entry point resolves strictness from the environment, whose compile-time
|
||||
// default is pinned to false in `rustfs_config`. A legacy-only peer therefore keeps
|
||||
// authenticating through the default build with no configuration at all.
|
||||
let headers = gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET).expect("legacy auth headers should build");
|
||||
assert!(
|
||||
verify_tonic_rpc_signature_with_strictness(
|
||||
"node-a:9000",
|
||||
"/node_service.NodeService/Ping",
|
||||
&headers,
|
||||
rustfs_config::DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT,
|
||||
)
|
||||
.is_ok(),
|
||||
"the default strict posture must accept legacy-only peers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_bound_tonic_request_rejects_replay_and_body_tampering() {
|
||||
ensure_test_rpc_secret();
|
||||
@@ -1238,6 +1473,129 @@ mod tests {
|
||||
assert!(cache.nonces.contains(&nonce_b));
|
||||
}
|
||||
|
||||
// The `rpc_body_digest_fallback_counter` serial group covers every test that drives (or
|
||||
// asserts on) the process-global body-digest fallback counter, so exact-delta assertions
|
||||
// cannot race with each other.
|
||||
#[test]
|
||||
#[serial_test::serial(rpc_body_digest_fallback_counter)]
|
||||
fn digestless_mutation_is_accepted_and_counted_while_strict_gate_is_off() {
|
||||
let request = tonic::Request::new(());
|
||||
let before = global_internode_metrics().snapshot().body_digest_fallback_total;
|
||||
|
||||
assert!(
|
||||
verify_tonic_mutation_body_digest_with_strictness(&request, b"canonical-mutation-body", false).is_ok(),
|
||||
"a digestless peer must keep mutating while the strict gate is off"
|
||||
);
|
||||
|
||||
let after = global_internode_metrics().snapshot().body_digest_fallback_total;
|
||||
assert_eq!(
|
||||
after,
|
||||
before + 1,
|
||||
"an accepted digestless mutation must increment the body-digest fallback counter exactly once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(rpc_body_digest_fallback_counter)]
|
||||
fn strict_mutation_gate_rejects_digestless_but_keeps_body_bound() {
|
||||
let before = global_internode_metrics().snapshot().body_digest_fallback_total;
|
||||
|
||||
let digestless = tonic::Request::new(());
|
||||
let error = verify_tonic_mutation_body_digest_with_strictness(&digestless, b"body", true)
|
||||
.expect_err("strict mode must reject a mutation without a body digest");
|
||||
assert_eq!(error.to_string(), "RPC mutation requires a body-bound v2 signature");
|
||||
|
||||
let mut unsigned = tonic::Request::new(());
|
||||
unsigned
|
||||
.metadata_mut()
|
||||
.as_mut()
|
||||
.insert(RPC_CONTENT_SHA256_HEADER, HeaderValue::from_static(UNSIGNED_PAYLOAD));
|
||||
let error = verify_tonic_mutation_body_digest_with_strictness(&unsigned, b"body", true)
|
||||
.expect_err("strict mode must reject an explicitly unsigned mutation payload");
|
||||
assert_eq!(error.to_string(), "RPC mutation requires a body-bound v2 signature");
|
||||
|
||||
let mut bound = tonic::Request::new(());
|
||||
set_tonic_canonical_body_digest(&mut bound, b"body").expect("digest metadata should encode");
|
||||
bound
|
||||
.metadata_mut()
|
||||
.as_mut()
|
||||
.insert(RPC_AUTH_VERSION_HEADER, HeaderValue::from_static(RPC_AUTH_VERSION_V2));
|
||||
assert!(
|
||||
verify_tonic_mutation_body_digest_with_strictness(&bound, b"body", true).is_ok(),
|
||||
"strict mode must keep accepting body-bound mutations"
|
||||
);
|
||||
let tampered = verify_tonic_mutation_body_digest_with_strictness(&bound, b"tampered-body", true)
|
||||
.expect_err("a tampered canonical body must fail even in strict mode");
|
||||
assert_eq!(tampered.to_string(), "RPC content SHA-256 mismatch");
|
||||
|
||||
let after = global_internode_metrics().snapshot().body_digest_fallback_total;
|
||||
assert_eq!(
|
||||
after, before,
|
||||
"neither strict rejections nor bound verifications are digestless fallbacks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial(rpc_body_digest_fallback_counter)]
|
||||
fn mutation_digest_default_posture_is_fail_open_digestless_accept() {
|
||||
// The public entry point resolves strictness from the environment, whose compile-time
|
||||
// default is pinned to false in `rustfs_config`. A digestless peer therefore keeps
|
||||
// mutating through the default build with no configuration at all.
|
||||
let request = tonic::Request::new(());
|
||||
assert!(
|
||||
verify_tonic_mutation_body_digest_with_strictness(
|
||||
&request,
|
||||
b"canonical-mutation-body",
|
||||
rustfs_config::DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT,
|
||||
)
|
||||
.is_ok(),
|
||||
"the default strict posture must accept digestless mutations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_data_mutation_contract_binds_method_nonce_and_body() {
|
||||
ensure_test_rpc_secret();
|
||||
let message = rustfs_protos::proto_gen::node_service::RenameDataRequest {
|
||||
disk: "http://node-a:9000/data/rustfs0".to_string(),
|
||||
src_volume: ".rustfs.sys/multipart".to_string(),
|
||||
src_path: "uploads/object".to_string(),
|
||||
file_info: "{\"volume\":\"bucket\"}".to_string(),
|
||||
dst_volume: "bucket".to_string(),
|
||||
dst_path: "object".to_string(),
|
||||
file_info_bin: vec![0x81, 0xA1, 0x76, 0x01].into(),
|
||||
};
|
||||
let body = rustfs_protos::canonical_rename_data_request_body(&message).expect("small request should encode");
|
||||
let mut request = tonic::Request::new(());
|
||||
set_tonic_canonical_body_digest(&mut request, &body).expect("canonical body digest should be attached");
|
||||
let content_sha256 = request
|
||||
.metadata()
|
||||
.get(RPC_CONTENT_SHA256_HEADER)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
let headers = gen_tonic_signature_headers("node-a:9000", "node_service.NodeService", "RenameData", content_sha256)
|
||||
.expect("body-bound auth headers should build");
|
||||
request.metadata_mut().as_mut().extend(headers.clone());
|
||||
|
||||
assert!(
|
||||
verify_tonic_rpc_signature("node-a:9000", "/node_service.NodeService/RenameData", &headers).is_ok(),
|
||||
"the rename_data signature must bind destination, method, nonce, and body digest"
|
||||
);
|
||||
let replay = verify_tonic_rpc_signature("node-a:9000", "/node_service.NodeService/RenameData", &headers)
|
||||
.expect_err("reusing a consumed rename_data nonce must fail");
|
||||
assert_eq!(replay.to_string(), "RPC request replay detected");
|
||||
let transplant = verify_tonic_rpc_signature("node-a:9000", "/node_service.NodeService/DeleteVersion", &headers)
|
||||
.expect_err("a rename_data signature must not authenticate a different method");
|
||||
assert_eq!(transplant.to_string(), "Invalid RPC v2 signature");
|
||||
|
||||
assert!(verify_tonic_mutation_body_digest(&request, &body).is_ok());
|
||||
let mut tampered = message;
|
||||
tampered.file_info_bin = Vec::new().into();
|
||||
let tampered_body = rustfs_protos::canonical_rename_data_request_body(&tampered).expect("small request should encode");
|
||||
let stripped = verify_tonic_mutation_body_digest(&request, &tampered_body)
|
||||
.expect_err("stripping the msgpack payload to force the JSON fallback decode must fail");
|
||||
assert_eq!(stripped.to_string(), "RPC content SHA-256 mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_cache_rejects_replay_after_wall_clock_regression() {
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::cluster::rpc::build_auth_headers;
|
||||
use crate::cluster::rpc::{build_auth_headers, verify_ns_scanner_capability};
|
||||
use crate::disk::error::{Error, Result};
|
||||
use crate::disk::{FileReader, FileWriter};
|
||||
use crate::storage_api_contracts::internode::{
|
||||
WALK_DIR_BODY_SHA256_QUERY, WALK_DIR_STREAM_COMPLETION_QUERY, WALK_DIR_STREAM_COMPLETION_V1,
|
||||
NS_SCANNER_BODY_SHA256_QUERY, NS_SCANNER_CAPABILITY_CHALLENGE_QUERY, NS_SCANNER_CYCLE_QUERY, NS_SCANNER_LEADER_EPOCH_QUERY,
|
||||
NS_SCANNER_PROTOCOL_VERSION, NS_SCANNER_PROTOCOL_VERSION_QUERY, NS_SCANNER_REQUEST_ID_QUERY, NS_SCANNER_SERVER_EPOCH_QUERY,
|
||||
NS_SCANNER_SESSION_ID_QUERY, NS_SCANNER_SESSION_SEQUENCE_QUERY, NsScannerCapabilityResponse, WALK_DIR_BODY_SHA256_QUERY,
|
||||
WALK_DIR_STREAM_COMPLETION_QUERY, WALK_DIR_STREAM_COMPLETION_V1,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use http::{HeaderMap, HeaderValue, Method, header::CONTENT_TYPE};
|
||||
@@ -28,13 +31,18 @@ use rustfs_rio::{HttpReader, HttpWriter};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
static INTERNODE_DATA_TRANSPORT: OnceLock<std::result::Result<Arc<dyn InternodeDataTransport>, String>> = OnceLock::new();
|
||||
|
||||
const READ_FILE_STREAM_PATH: &str = "/rustfs/rpc/read_file_stream";
|
||||
const PUT_FILE_STREAM_PATH: &str = "/rustfs/rpc/put_file_stream";
|
||||
const WALK_DIR_PATH: &str = "/rustfs/rpc/walk_dir";
|
||||
const NS_SCANNER_PATH: &str = "/rustfs/rpc/ns_scanner";
|
||||
const NS_SCANNER_MAX_CAPABILITY_RESPONSE_SIZE: usize = 1024;
|
||||
const CONTENT_TYPE_JSON: &str = "application/json";
|
||||
const CONTENT_TYPE_MSGPACK: &str = "application/msgpack";
|
||||
|
||||
fn unsupported_transport_message(transport: &str) -> String {
|
||||
format!(
|
||||
@@ -101,6 +109,25 @@ pub struct WalkDirStreamRequest {
|
||||
pub stall_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NsScannerStreamRequest {
|
||||
pub endpoint: String,
|
||||
pub disk: String,
|
||||
pub request_id: Uuid,
|
||||
pub server_epoch: Uuid,
|
||||
pub session_id: Uuid,
|
||||
pub session_sequence: u64,
|
||||
pub next_cycle: u64,
|
||||
pub leader_epoch: u64,
|
||||
pub body: Vec<u8>,
|
||||
pub stall_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NsScannerCapabilityRequest {
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
/// Data-plane stream opener used by `RemoteDisk`.
|
||||
///
|
||||
/// This boundary is limited to remote disk streams that can move large payloads.
|
||||
@@ -114,6 +141,12 @@ pub trait InternodeDataTransport: Send + Sync + std::fmt::Debug {
|
||||
async fn open_read(&self, request: ReadStreamRequest) -> Result<FileReader>;
|
||||
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter>;
|
||||
async fn open_walk_dir(&self, request: WalkDirStreamRequest) -> Result<FileReader>;
|
||||
async fn open_ns_scanner(&self, _request: NsScannerStreamRequest) -> Result<FileReader> {
|
||||
Err(Error::MethodNotAllowed)
|
||||
}
|
||||
async fn probe_ns_scanner(&self, _request: NsScannerCapabilityRequest) -> Result<Uuid> {
|
||||
Err(Error::MethodNotAllowed)
|
||||
}
|
||||
fn name(&self) -> &'static str;
|
||||
fn capabilities(&self) -> InternodeDataTransportCapabilities;
|
||||
}
|
||||
@@ -148,6 +181,39 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
|
||||
))
|
||||
}
|
||||
|
||||
async fn open_ns_scanner(&self, request: NsScannerStreamRequest) -> Result<FileReader> {
|
||||
let url = build_ns_scanner_url(&request);
|
||||
let mut headers = msgpack_headers();
|
||||
build_auth_headers(&url, &Method::POST, &mut headers)?;
|
||||
Ok(Box::new(
|
||||
HttpReader::new_with_stall_timeout(url, Method::POST, headers, Some(request.body), request.stall_timeout).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn probe_ns_scanner(&self, request: NsScannerCapabilityRequest) -> Result<Uuid> {
|
||||
let challenge = Uuid::new_v4();
|
||||
let url = build_ns_scanner_capability_url(&request, challenge);
|
||||
let mut headers = msgpack_headers();
|
||||
build_auth_headers(&url, &Method::GET, &mut headers)?;
|
||||
let reader = HttpReader::new(url, Method::GET, headers, None).await?;
|
||||
let mut body = Vec::new();
|
||||
reader
|
||||
.take(u64::try_from(NS_SCANNER_MAX_CAPABILITY_RESPONSE_SIZE + 1).unwrap_or(u64::MAX))
|
||||
.read_to_end(&mut body)
|
||||
.await?;
|
||||
if body.is_empty() || body.len() > NS_SCANNER_MAX_CAPABILITY_RESPONSE_SIZE {
|
||||
return Err(Error::other("invalid remote namespace scanner capability response size"));
|
||||
}
|
||||
let response: NsScannerCapabilityResponse =
|
||||
rmp_serde::from_slice(&body).map_err(|_| Error::other("invalid remote namespace scanner capability response"))?;
|
||||
if response.version != NS_SCANNER_PROTOCOL_VERSION || response.server_epoch.is_nil() {
|
||||
return Err(Error::other("incompatible remote namespace scanner capability response"));
|
||||
}
|
||||
verify_ns_scanner_capability(challenge, response.server_epoch, &response.proof)
|
||||
.map_err(|err| Error::other(format!("remote namespace scanner capability authentication failed: {err}")))?;
|
||||
Ok(response.server_epoch)
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
DEFAULT_INTERNODE_DATA_TRANSPORT
|
||||
}
|
||||
@@ -197,12 +263,54 @@ fn build_walk_dir_url(request: &WalkDirStreamRequest) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ns_scanner_url(request: &NsScannerStreamRequest) -> String {
|
||||
let body_sha256 = hex_simd::encode_to_string(Sha256::digest(&request.body), hex_simd::AsciiCase::Lower);
|
||||
format!(
|
||||
"{}{}?disk={}&{}={}&{}={}&{}={}&{}={}&{}={}&{}={}&{}={}",
|
||||
request.endpoint,
|
||||
NS_SCANNER_PATH,
|
||||
urlencoding::encode(&request.disk),
|
||||
NS_SCANNER_REQUEST_ID_QUERY,
|
||||
request.request_id,
|
||||
NS_SCANNER_SERVER_EPOCH_QUERY,
|
||||
request.server_epoch,
|
||||
NS_SCANNER_SESSION_ID_QUERY,
|
||||
request.session_id,
|
||||
NS_SCANNER_SESSION_SEQUENCE_QUERY,
|
||||
request.session_sequence,
|
||||
NS_SCANNER_CYCLE_QUERY,
|
||||
request.next_cycle,
|
||||
NS_SCANNER_LEADER_EPOCH_QUERY,
|
||||
request.leader_epoch,
|
||||
NS_SCANNER_BODY_SHA256_QUERY,
|
||||
body_sha256
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ns_scanner_capability_url(request: &NsScannerCapabilityRequest, challenge: Uuid) -> String {
|
||||
format!(
|
||||
"{}{}?{}={}&{}={}",
|
||||
request.endpoint,
|
||||
NS_SCANNER_PATH,
|
||||
NS_SCANNER_PROTOCOL_VERSION_QUERY,
|
||||
NS_SCANNER_PROTOCOL_VERSION,
|
||||
NS_SCANNER_CAPABILITY_CHALLENGE_QUERY,
|
||||
challenge
|
||||
)
|
||||
}
|
||||
|
||||
fn json_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_JSON));
|
||||
headers
|
||||
}
|
||||
|
||||
fn msgpack_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_MSGPACK));
|
||||
headers
|
||||
}
|
||||
|
||||
fn build_internode_data_transport_result(
|
||||
configured_transport: Option<&str>,
|
||||
) -> std::result::Result<Arc<dyn InternodeDataTransport>, String> {
|
||||
@@ -241,6 +349,65 @@ pub fn build_internode_data_transport_from_env() -> Result<Arc<dyn InternodeData
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LegacyTestTransport;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InternodeDataTransport for LegacyTestTransport {
|
||||
async fn open_read(&self, _request: ReadStreamRequest) -> Result<FileReader> {
|
||||
Ok(Box::new(tokio::io::empty()))
|
||||
}
|
||||
|
||||
async fn open_write(&self, _request: WriteStreamRequest) -> Result<FileWriter> {
|
||||
Ok(Box::new(tokio::io::sink()))
|
||||
}
|
||||
|
||||
async fn open_walk_dir(&self, _request: WalkDirStreamRequest) -> Result<FileReader> {
|
||||
Ok(Box::new(tokio::io::empty()))
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"legacy-test"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> InternodeDataTransportCapabilities {
|
||||
InternodeDataTransportCapabilities::tcp_http()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_transport_defaults_namespace_scanner_to_unsupported() {
|
||||
let transport = LegacyTestTransport;
|
||||
|
||||
let probe_err = transport
|
||||
.probe_ns_scanner(NsScannerCapabilityRequest {
|
||||
endpoint: "http://node1:9000".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("legacy transport should report namespace scanner as unsupported");
|
||||
assert!(matches!(probe_err, Error::MethodNotAllowed));
|
||||
|
||||
let open_result = transport
|
||||
.open_ns_scanner(NsScannerStreamRequest {
|
||||
endpoint: "http://node1:9000".to_string(),
|
||||
disk: "http://node1:9000/data/rustfs0".to_string(),
|
||||
request_id: Uuid::new_v4(),
|
||||
server_epoch: Uuid::new_v4(),
|
||||
session_id: Uuid::new_v4(),
|
||||
session_sequence: 0,
|
||||
next_cycle: 7,
|
||||
leader_epoch: 9,
|
||||
body: Vec::new(),
|
||||
stall_timeout: None,
|
||||
})
|
||||
.await;
|
||||
let open_err = match open_result {
|
||||
Ok(_) => panic!("legacy transport should not open namespace scanner streams"),
|
||||
Err(err) => err,
|
||||
};
|
||||
assert!(matches!(open_err, Error::MethodNotAllowed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_http_capabilities_are_behavior_preserving() {
|
||||
let transport = TcpHttpInternodeDataTransport;
|
||||
@@ -322,6 +489,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ns_scanner_url_binds_body_and_encodes_disk_ref() {
|
||||
let request_id = Uuid::parse_str("11111111-2222-4333-8444-555555555555").expect("request ID");
|
||||
let server_epoch = Uuid::parse_str("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee").expect("server epoch");
|
||||
let session_id = Uuid::parse_str("99999999-8888-4777-8666-555555555555").expect("session ID");
|
||||
let url = build_ns_scanner_url(&NsScannerStreamRequest {
|
||||
endpoint: "http://node1:9000".to_string(),
|
||||
disk: "http://node1:9000/data/rustfs0".to_string(),
|
||||
request_id,
|
||||
server_epoch,
|
||||
session_id,
|
||||
session_sequence: 3,
|
||||
next_cycle: 7,
|
||||
leader_epoch: 9,
|
||||
body: b"scanner-request".to_vec(),
|
||||
stall_timeout: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
concat!(
|
||||
"http://node1:9000/rustfs/rpc/ns_scanner?disk=http%3A%2F%2Fnode1%3A9000%2Fdata%2Frustfs0",
|
||||
"&ns_scanner_request_id=11111111-2222-4333-8444-555555555555",
|
||||
"&ns_scanner_server_epoch=aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
"&ns_scanner_session_id=99999999-8888-4777-8666-555555555555",
|
||||
"&ns_scanner_session_sequence=3",
|
||||
"&ns_scanner_cycle=7",
|
||||
"&ns_scanner_leader_epoch=9",
|
||||
"&ns_scanner_body_sha256=c958f15ca28422275c1245399f4c44eaba628ca453fcd77d6b3d4484573e4387"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ns_scanner_capability_url_binds_version_and_challenge() {
|
||||
let challenge = Uuid::parse_str("12345678-1234-4234-8234-123456789abc").expect("challenge");
|
||||
let url = build_ns_scanner_capability_url(
|
||||
&NsScannerCapabilityRequest {
|
||||
endpoint: "http://node1:9000".to_string(),
|
||||
},
|
||||
challenge,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
format!(
|
||||
"http://node1:9000/rustfs/rpc/ns_scanner?ns_scanner_protocol={NS_SCANNER_PROTOCOL_VERSION}&ns_scanner_challenge={challenge}"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_config_defaults_to_tcp_http() {
|
||||
let transport = build_internode_data_transport(None).unwrap();
|
||||
|
||||
@@ -30,17 +30,19 @@ pub use client::{
|
||||
};
|
||||
pub use http_auth::{
|
||||
TONIC_RPC_PREFIX, build_auth_headers, gen_signature_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience,
|
||||
set_tonic_canonical_body_digest, sign_tonic_rpc_response_proof, verify_rpc_signature, verify_tonic_canonical_body_digest,
|
||||
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
|
||||
set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_tonic_rpc_response_proof, verify_ns_scanner_capability,
|
||||
verify_rpc_signature, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof,
|
||||
verify_tonic_rpc_signature,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
|
||||
pub use internode_data_transport::build_internode_data_transport_from_env;
|
||||
pub(crate) use peer_rest_client::TierConfigReloadOutcome;
|
||||
pub use peer_rest_client::{
|
||||
PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
||||
ScannerPeerActivity,
|
||||
PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, SERVICE_SIGNAL_REFRESH_CONFIG,
|
||||
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity,
|
||||
};
|
||||
pub(crate) use peer_s3_client::heal_bucket_local_on_disks;
|
||||
pub use peer_s3_client::{LocalPeerS3Client, PeerS3Client, S3PeerSys};
|
||||
pub use peer_s3_client::{LocalPeerS3Client, PeerS3Client, S3PeerSys, ScannerBucketListing, ScannerSetBucketListing};
|
||||
pub use remote_disk::RemoteDisk;
|
||||
pub use remote_locker::RemoteClient;
|
||||
|
||||
@@ -18,7 +18,11 @@ use crate::cluster::rpc::client::{
|
||||
};
|
||||
use crate::cluster::rpc::{set_tonic_canonical_body_digest, verify_tonic_rpc_response_proof};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::storage_api_contracts::internode::{
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION, SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION, SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
};
|
||||
use crate::{
|
||||
bucket::replication::BucketStats,
|
||||
disk::disk_store::{get_drive_active_check_interval, get_drive_active_check_timeout},
|
||||
layout::endpoints::EndpointServerPools,
|
||||
runtime::sources as runtime_sources,
|
||||
@@ -26,6 +30,7 @@ use crate::{
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use rmp_serde::{Deserializer, Serializer};
|
||||
use rustfs_config::{HEAL_SUB_SYS, SCANNER_SUB_SYS};
|
||||
use rustfs_madmin::{
|
||||
ServerProperties,
|
||||
health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysServices},
|
||||
@@ -34,14 +39,15 @@ use rustfs_madmin::{
|
||||
};
|
||||
use rustfs_protos::proto_gen::node_service::{
|
||||
BackgroundHealStatusRequest, CancelDecommissionRequest, ClearDecommissionRequest, DeleteBucketMetadataRequest,
|
||||
DeletePolicyRequest, DeleteServiceAccountRequest, DeleteUserRequest, GetCpusRequest, GetLiveEventsRequest, GetMemInfoRequest,
|
||||
GetMetricsRequest, GetNetInfoRequest, GetOsInfoRequest, GetPartitionsRequest, GetProcInfoRequest, GetSeLinuxInfoRequest,
|
||||
GetSysConfigRequest, GetSysErrorsRequest, HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest,
|
||||
LoadPolicyMappingRequest, LoadPolicyRequest, LoadRebalanceMetaRequest, LoadServiceAccountRequest,
|
||||
LoadTransitionTierConfigRequest, LoadUserRequest, LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest,
|
||||
ReloadSiteReplicationConfigRequest, ScannerActivityRequest, ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest,
|
||||
StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
|
||||
TierMutationControlResponse, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
|
||||
DeletePolicyRequest, DeleteServiceAccountRequest, DeleteUserRequest, GetBucketStatsDataRequest, GetBucketStatsDataResponse,
|
||||
GetCpusRequest, GetLiveEventsRequest, GetMemInfoRequest, GetMetricsRequest, GetNetInfoRequest, GetOsInfoRequest,
|
||||
GetPartitionsRequest, GetProcInfoRequest, GetSeLinuxInfoRequest, GetSysConfigRequest, GetSysErrorsRequest,
|
||||
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
|
||||
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
|
||||
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ScannerActivityRequest,
|
||||
ScannerActivityResponse, ServerInfoRequest, SignalServiceRequest, StartDecommissionRequest, StartProfilingRequest,
|
||||
StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse,
|
||||
TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
|
||||
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
||||
};
|
||||
use rustfs_protos::{TierMutationRpcPhase, evict_failed_connection};
|
||||
@@ -74,16 +80,55 @@ const HEAL_CONTROL_PAYLOAD_MAX_SIZE: usize = 64 * 1024;
|
||||
const PEER_REST_RECOVERY_MAX_ATTEMPTS: u32 = 60;
|
||||
const PEER_REST_RECOVERY_MAX_BACKOFF: Duration = Duration::from_secs(30);
|
||||
const SCANNER_ACTIVITY_MAX_MESSAGE_SIZE: usize = 1024;
|
||||
const REPLICATION_STATS_MAX_MESSAGE_SIZE: usize = 8 * 1024 * 1024;
|
||||
|
||||
fn decode_bucket_stats_response(response: GetBucketStatsDataResponse) -> Result<BucketStats> {
|
||||
if !response.success {
|
||||
return Err(Error::other(
|
||||
response
|
||||
.error_info
|
||||
.unwrap_or_else(|| "peer replication statistics provider is unavailable".to_string()),
|
||||
));
|
||||
}
|
||||
if response.bucket_stats.len() > REPLICATION_STATS_MAX_MESSAGE_SIZE {
|
||||
return Err(Error::other("peer replication statistics response exceeds size limit"));
|
||||
}
|
||||
let mut buf = Deserializer::new(Cursor::new(response.bucket_stats));
|
||||
let stats = BucketStats::deserialize(&mut buf).map_err(Error::from)?;
|
||||
if !stats.replication_stats.provider_available {
|
||||
return Err(Error::other("peer replication statistics provider is unavailable"));
|
||||
}
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
fn validate_signal_service_protocol(sig: u64, sub_sys: &str, protocol_version: u32) -> Result<()> {
|
||||
if sig == SERVICE_SIGNAL_RELOAD_DYNAMIC
|
||||
&& matches!(sub_sys, SCANNER_SUB_SYS | HEAL_SUB_SYS)
|
||||
&& protocol_version < rustfs_protos::DYNAMIC_CONFIG_PROTOCOL_VERSION
|
||||
{
|
||||
return Err(Error::other(format!("peer does not support dynamic {sub_sys} config convergence")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ScannerPeerActivity {
|
||||
pub instance_id: String,
|
||||
pub namespace_generation: u64,
|
||||
pub maintenance_generation: u64,
|
||||
pub protocol_version: u32,
|
||||
pub topology_digest: Option<[u8; 32]>,
|
||||
pub data_movement_active: Option<bool>,
|
||||
pub dirty_usage_generation: Option<u64>,
|
||||
pub dirty_usage_pending: Option<bool>,
|
||||
}
|
||||
|
||||
fn decode_scanner_activity(response: ScannerActivityResponse) -> Result<ScannerPeerActivity> {
|
||||
let instance_id = response.instance_id;
|
||||
fn decode_scanner_activity_with_verifier(
|
||||
response: ScannerActivityResponse,
|
||||
challenge: &[u8; 16],
|
||||
verify_proof: impl FnOnce(&[u8], &[u8]) -> Result<()>,
|
||||
) -> Result<ScannerPeerActivity> {
|
||||
let instance_id = &response.instance_id;
|
||||
if instance_id.len() != 32
|
||||
|| !instance_id
|
||||
.as_bytes()
|
||||
@@ -92,10 +137,80 @@ fn decode_scanner_activity(response: ScannerActivityResponse) -> Result<ScannerP
|
||||
{
|
||||
return Err(Error::other("peer returned an invalid scanner activity instance ID"));
|
||||
}
|
||||
let (topology_digest, data_movement_active, dirty_usage_generation, dirty_usage_pending) = match response.protocol_version {
|
||||
// RUSTFS_COMPAT_TODO(ns-scanner-rpc-v3): legacy response fields are unauthenticated. Remove after protocol v0 peers are unsupported.
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION
|
||||
if response.topology_digest.is_empty()
|
||||
&& response.response_proof.is_empty()
|
||||
&& !response.data_movement_active
|
||||
&& response.dirty_usage_generation == 0
|
||||
&& !response.dirty_usage_pending =>
|
||||
{
|
||||
(None, None, None, None)
|
||||
}
|
||||
SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION => {
|
||||
return Err(Error::other("legacy scanner activity peer returned unexpected extended fields"));
|
||||
}
|
||||
SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION => {
|
||||
if response.dirty_usage_generation != 0 || response.dirty_usage_pending {
|
||||
return Err(Error::other("scanner activity protocol v4 peer returned unauthenticated v5 fields"));
|
||||
}
|
||||
let canonical = rustfs_protos::canonical_scanner_activity_v4_response_body(challenge, &response)
|
||||
.map_err(|_| Error::other("peer scanner activity response is too large to authenticate"))?;
|
||||
verify_proof(&canonical, &response.response_proof)?;
|
||||
(
|
||||
Some(
|
||||
response
|
||||
.topology_digest
|
||||
.as_ref()
|
||||
.try_into()
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner topology digest"))?,
|
||||
),
|
||||
Some(response.data_movement_active),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
SCANNER_ACTIVITY_PROTOCOL_VERSION => {
|
||||
if response.dirty_usage_pending && response.dirty_usage_generation == 0 {
|
||||
return Err(Error::other("scanner activity peer returned pending dirty usage without a generation"));
|
||||
}
|
||||
let canonical = rustfs_protos::canonical_scanner_activity_response_body(challenge, &response)
|
||||
.map_err(|_| Error::other("peer scanner activity response is too large to authenticate"))?;
|
||||
verify_proof(&canonical, &response.response_proof)?;
|
||||
(
|
||||
Some(
|
||||
response
|
||||
.topology_digest
|
||||
.as_ref()
|
||||
.try_into()
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner topology digest"))?,
|
||||
),
|
||||
Some(response.data_movement_active),
|
||||
Some(response.dirty_usage_generation),
|
||||
Some(response.dirty_usage_pending),
|
||||
)
|
||||
}
|
||||
version => {
|
||||
return Err(Error::other(format!("peer returned unsupported scanner activity protocol {version}")));
|
||||
}
|
||||
};
|
||||
Ok(ScannerPeerActivity {
|
||||
instance_id,
|
||||
instance_id: response.instance_id,
|
||||
namespace_generation: response.namespace_generation,
|
||||
maintenance_generation: response.maintenance_generation,
|
||||
protocol_version: response.protocol_version,
|
||||
topology_digest,
|
||||
data_movement_active,
|
||||
dirty_usage_generation,
|
||||
dirty_usage_pending,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_scanner_activity(response: ScannerActivityResponse, challenge: &[u8; 16]) -> Result<ScannerPeerActivity> {
|
||||
decode_scanner_activity_with_verifier(response, challenge, |canonical, proof| {
|
||||
verify_tonic_rpc_response_proof(canonical, proof)
|
||||
.map_err(|_| Error::other("peer returned an invalid scanner activity response proof"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -219,31 +334,62 @@ impl PeerRestClient {
|
||||
recovery_running: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
pub async fn new_clients(eps: EndpointServerPools) -> (Vec<Option<Self>>, Vec<Option<Self>>) {
|
||||
if !runtime_sources::setup_is_dist_erasure().await {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
|
||||
let eps = eps.clone();
|
||||
let hosts = eps.hosts_sorted();
|
||||
let mut remote = Vec::with_capacity(hosts.len());
|
||||
let mut all = vec![None; hosts.len()];
|
||||
for (i, hs_host) in hosts.iter().enumerate() {
|
||||
if let Some(host) = hs_host
|
||||
&& let Some(grid_host) = eps.find_grid_hosts_from_peer(host)
|
||||
{
|
||||
let client = PeerRestClient::new(host.clone(), grid_host);
|
||||
fn build_clients_from_slots(
|
||||
slots: Vec<(String, Option<String>, bool)>,
|
||||
) -> (Vec<Option<Self>>, Vec<Option<Self>>, Vec<String>) {
|
||||
let mut remote = Vec::with_capacity(slots.len().saturating_sub(1));
|
||||
let mut all = vec![None; slots.len()];
|
||||
let mut remote_topology_hosts = Vec::with_capacity(slots.len().saturating_sub(1));
|
||||
|
||||
all[i] = Some(client.clone());
|
||||
remote.push(Some(client));
|
||||
for (idx, (peer_host_port, grid_host, is_local)) in slots.into_iter().enumerate() {
|
||||
if is_local {
|
||||
continue;
|
||||
}
|
||||
|
||||
let client = match grid_host {
|
||||
Some(grid_host) => match XHost::try_from(peer_host_port.clone()) {
|
||||
Ok(host) => Some(PeerRestClient::new(host, grid_host)),
|
||||
Err(err) => {
|
||||
warn!(peer = %peer_host_port, "Xhost parse failed while constructing peer client: {err:?}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
warn!(peer = %peer_host_port, "grid host is missing while constructing peer client");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
all[idx] = client.clone();
|
||||
remote.push(client);
|
||||
remote_topology_hosts.push(peer_host_port);
|
||||
}
|
||||
|
||||
(remote, all, remote_topology_hosts)
|
||||
}
|
||||
|
||||
pub async fn new_clients(eps: EndpointServerPools) -> (Vec<Option<Self>>, Vec<Option<Self>>) {
|
||||
let (remote, all, _) = Self::new_clients_with_topology(eps).await;
|
||||
(remote, all)
|
||||
}
|
||||
|
||||
pub async fn new_clients_with_topology(eps: EndpointServerPools) -> (Vec<Option<Self>>, Vec<Option<Self>>, Vec<String>) {
|
||||
if !runtime_sources::setup_is_dist_erasure().await {
|
||||
return (Vec::new(), Vec::new(), Vec::new());
|
||||
}
|
||||
|
||||
let (remote, all, remote_topology_hosts) = Self::build_clients_from_slots(eps.peer_grid_host_slots_sorted());
|
||||
|
||||
if all.len() != remote.len() + 1 {
|
||||
warn!("Expected number of all hosts ({}) to be remote +1 ({})", all.len(), remote.len());
|
||||
warn!(
|
||||
all_hosts = all.len(),
|
||||
remote_slots = remote.len(),
|
||||
"Expected number of all hosts to be remote slots + local node"
|
||||
);
|
||||
}
|
||||
|
||||
(remote, all)
|
||||
(remote, all, remote_topology_hosts)
|
||||
}
|
||||
|
||||
pub async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>> {
|
||||
@@ -788,9 +934,26 @@ impl PeerRestClient {
|
||||
Err(Error::NotImplemented)
|
||||
}
|
||||
|
||||
pub async fn get_bucket_stats(&self) -> Result<()> {
|
||||
warn!("get_bucket_stats is not implemented in PeerRestClient");
|
||||
Err(Error::NotImplemented)
|
||||
pub async fn get_bucket_stats(&self, bucket: &str) -> Result<BucketStats> {
|
||||
let response = self
|
||||
.finalize_result(
|
||||
async {
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await?
|
||||
.max_decoding_message_size(REPLICATION_STATS_MAX_MESSAGE_SIZE);
|
||||
let response = client
|
||||
.get_bucket_stats(Request::new(GetBucketStatsDataRequest {
|
||||
bucket: bucket.to_string(),
|
||||
}))
|
||||
.await?
|
||||
.into_inner();
|
||||
Ok(response)
|
||||
}
|
||||
.await,
|
||||
)
|
||||
.await?;
|
||||
decode_bucket_stats_response(response)
|
||||
}
|
||||
|
||||
pub async fn get_sr_metrics(&self) -> Result<()> {
|
||||
@@ -1261,6 +1424,7 @@ impl PeerRestClient {
|
||||
}
|
||||
return Err(Error::other(""));
|
||||
}
|
||||
validate_signal_service_protocol(sig, sub_sys, response.protocol_version)?;
|
||||
Ok(())
|
||||
}
|
||||
.await,
|
||||
@@ -1268,25 +1432,44 @@ impl PeerRestClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn scanner_activity(&self) -> Result<ScannerPeerActivity> {
|
||||
async fn scanner_activity_request(
|
||||
&self,
|
||||
acknowledge_instance_id: String,
|
||||
acknowledge_dirty_usage_generation: u64,
|
||||
) -> Result<ScannerPeerActivity> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
let challenge = Uuid::new_v4();
|
||||
let mut client = self
|
||||
.get_client()
|
||||
.await?
|
||||
.max_decoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE)
|
||||
.max_encoding_message_size(SCANNER_ACTIVITY_MAX_MESSAGE_SIZE);
|
||||
let response = client
|
||||
.scanner_activity(Request::new(ScannerActivityRequest {}))
|
||||
.await?
|
||||
.into_inner();
|
||||
decode_scanner_activity(response)
|
||||
let mut request = Request::new(ScannerActivityRequest {
|
||||
challenge: challenge.as_bytes().to_vec().into(),
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
acknowledge_instance_id,
|
||||
acknowledge_dirty_usage_generation,
|
||||
});
|
||||
let canonical = rustfs_protos::canonical_scanner_activity_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("scanner activity request is too large to authenticate"))?;
|
||||
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
||||
let response = client.scanner_activity(request).await?.into_inner();
|
||||
decode_scanner_activity(response, challenge.as_bytes())
|
||||
}
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn scanner_activity(&self) -> Result<ScannerPeerActivity> {
|
||||
self.scanner_activity_request(String::new(), 0).await
|
||||
}
|
||||
|
||||
pub async fn acknowledge_scanner_dirty_usage(&self, instance_id: String, generation: u64) -> Result<ScannerPeerActivity> {
|
||||
self.scanner_activity_request(instance_id, generation).await
|
||||
}
|
||||
|
||||
pub async fn get_metacache_listing(&self) -> Result<()> {
|
||||
warn!("get_metacache_listing is not implemented in PeerRestClient");
|
||||
Err(Error::NotImplemented)
|
||||
@@ -1448,35 +1631,177 @@ impl PeerRestClient {
|
||||
}
|
||||
|
||||
pub async fn load_transition_tier_config(&self) -> Result<()> {
|
||||
self.finalize_result(
|
||||
async {
|
||||
let mut client = self.get_client().await?;
|
||||
let request = Request::new(LoadTransitionTierConfigRequest {});
|
||||
|
||||
let response = client.load_transition_tier_config(request).await?.into_inner();
|
||||
if !response.success {
|
||||
if let Some(msg) = response.error_info {
|
||||
return Err(Error::other(msg));
|
||||
}
|
||||
return Err(Error::other(""));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
match self.load_transition_tier_config_outcome().await {
|
||||
TierConfigReloadOutcome::Success => Ok(()),
|
||||
TierConfigReloadOutcome::TransientReconnect(err) | TierConfigReloadOutcome::TransientRetrySameChannel(err) => {
|
||||
self.finalize_result(Err(err)).await
|
||||
}
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
TierConfigReloadOutcome::Terminal(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_transition_tier_config_outcome(&self) -> TierConfigReloadOutcome {
|
||||
let outcome = self.load_transition_tier_config_single_attempt_outcome().await;
|
||||
if outcome.is_transient() {
|
||||
return self.load_transition_tier_config_once_outcome().await;
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) async fn load_transition_tier_config_single_attempt_outcome(&self) -> TierConfigReloadOutcome {
|
||||
let outcome = self.load_transition_tier_config_once_outcome().await;
|
||||
if outcome.requires_reconnect() {
|
||||
self.prepare_retry().await;
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) async fn load_transition_tier_config_once_outcome(&self) -> TierConfigReloadOutcome {
|
||||
let mut client = match self.get_client().await {
|
||||
Ok(client) => client,
|
||||
Err(err) => return tier_config_reload_connection_outcome(err),
|
||||
};
|
||||
let mut request = Request::new(LoadTransitionTierConfigRequest {});
|
||||
request.set_timeout(rustfs_protos::heal_control_execution_timeout());
|
||||
|
||||
let response = match client.load_transition_tier_config(request).await {
|
||||
Ok(response) => response.into_inner(),
|
||||
Err(status) => return tier_config_reload_status_outcome(status),
|
||||
};
|
||||
if !response.success {
|
||||
return tier_config_reload_remote_failure(response.error_info);
|
||||
}
|
||||
|
||||
TierConfigReloadOutcome::Success
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum TierConfigReloadOutcome {
|
||||
Success,
|
||||
TransientReconnect(Error),
|
||||
TransientRetrySameChannel(Error),
|
||||
Terminal(Error),
|
||||
}
|
||||
|
||||
impl TierConfigReloadOutcome {
|
||||
fn is_transient(&self) -> bool {
|
||||
matches!(self, Self::TransientReconnect(_) | Self::TransientRetrySameChannel(_))
|
||||
}
|
||||
|
||||
fn requires_reconnect(&self) -> bool {
|
||||
matches!(self, Self::TransientReconnect(_))
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_config_reload_connection_outcome(err: Error) -> TierConfigReloadOutcome {
|
||||
if is_tier_config_reload_connection_failure(&err) {
|
||||
TierConfigReloadOutcome::TransientReconnect(err)
|
||||
} else {
|
||||
TierConfigReloadOutcome::Terminal(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tier_config_reload_connection_failure(err: &Error) -> bool {
|
||||
let message = err.to_string().to_ascii_lowercase();
|
||||
if message
|
||||
.split_once("can not get client, err:")
|
||||
.is_some_and(|(_, local_error)| local_error.contains("unavailable"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
[
|
||||
"temporarily offline",
|
||||
"transport error",
|
||||
"error trying to connect",
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"connection closed",
|
||||
"connection aborted",
|
||||
"broken pipe",
|
||||
"not connected",
|
||||
"unexpected eof",
|
||||
"timed out",
|
||||
"deadline has elapsed",
|
||||
"tcp connect error",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| message.contains(needle))
|
||||
}
|
||||
|
||||
fn tier_config_reload_remote_failure(error_info: Option<String>) -> TierConfigReloadOutcome {
|
||||
let error_info = error_info.unwrap_or_default();
|
||||
if matches!(error_info.as_str(), "errServerNotInitialized" | "ServerNotInitialized") {
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(Error::other(error_info))
|
||||
} else {
|
||||
TierConfigReloadOutcome::Terminal(Error::other(error_info))
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_config_reload_status_outcome(status: tonic::Status) -> TierConfigReloadOutcome {
|
||||
use tonic::Code;
|
||||
|
||||
if matches!(status.code(), Code::Unavailable | Code::DeadlineExceeded) {
|
||||
TierConfigReloadOutcome::TransientReconnect(status.into())
|
||||
} else if status.code() == Code::Unknown && status.message().starts_with("Service was not ready:") {
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(status.into())
|
||||
} else {
|
||||
TierConfigReloadOutcome::Terminal(status.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::com::STORAGE_CLASS_SUB_SYS;
|
||||
use serde_json::Value;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt};
|
||||
|
||||
#[test]
|
||||
fn replication_stats_response_decodes_valid_empty_provider() {
|
||||
let mut stats = BucketStats::default();
|
||||
stats.replication_stats.provider_available = true;
|
||||
let payload = rmp_serde::to_vec_named(&stats).expect("bucket statistics should encode");
|
||||
|
||||
let decoded = decode_bucket_stats_response(GetBucketStatsDataResponse {
|
||||
success: true,
|
||||
bucket_stats: payload.into(),
|
||||
error_info: None,
|
||||
})
|
||||
.expect("valid bucket statistics should decode");
|
||||
|
||||
assert!(decoded.replication_stats.provider_available);
|
||||
assert!(decoded.replication_stats.stats.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_stats_response_rejects_unavailable_malformed_and_oversized_payloads() {
|
||||
let unavailable = decode_bucket_stats_response(GetBucketStatsDataResponse {
|
||||
success: false,
|
||||
bucket_stats: Bytes::new(),
|
||||
error_info: Some("provider unavailable".to_string()),
|
||||
})
|
||||
.expect_err("unavailable provider must not become a zero snapshot");
|
||||
assert!(unavailable.to_string().contains("provider unavailable"));
|
||||
|
||||
let malformed = decode_bucket_stats_response(GetBucketStatsDataResponse {
|
||||
success: true,
|
||||
bucket_stats: Bytes::from_static(b"not-msgpack"),
|
||||
error_info: None,
|
||||
})
|
||||
.expect_err("malformed peer statistics must fail closed");
|
||||
assert!(!malformed.to_string().is_empty());
|
||||
|
||||
let oversized = decode_bucket_stats_response(GetBucketStatsDataResponse {
|
||||
success: true,
|
||||
bucket_stats: Bytes::from(vec![0; REPLICATION_STATS_MAX_MESSAGE_SIZE + 1]),
|
||||
error_info: None,
|
||||
})
|
||||
.expect_err("oversized peer statistics must fail closed");
|
||||
assert!(oversized.to_string().contains("size limit"));
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct CapturedLogs {
|
||||
buffer: Arc<Mutex<Vec<u8>>>,
|
||||
@@ -1536,15 +1861,129 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn decode_test_scanner_activity(response: ScannerActivityResponse) -> Result<ScannerPeerActivity> {
|
||||
decode_scanner_activity_with_verifier(response, &[9; 16], |_canonical, proof| {
|
||||
(proof == b"proof")
|
||||
.then_some(())
|
||||
.ok_or_else(|| Error::other("peer returned an invalid scanner activity response proof"))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_clients_from_slots_preserves_missing_remote_topology_slots() {
|
||||
let slots = vec![
|
||||
("127.0.0.1:9000".to_string(), None, true),
|
||||
("127.0.0.1:9001".to_string(), Some("http://127.0.0.1:9001".to_string()), false),
|
||||
("127.0.0.1:notaport".to_string(), Some("http://127.0.0.1:notaport".to_string()), false),
|
||||
("127.0.0.1:9003".to_string(), None, false),
|
||||
];
|
||||
|
||||
let (remote, all, remote_topology_hosts) = PeerRestClient::build_clients_from_slots(slots);
|
||||
|
||||
assert_eq!(remote.len(), 3, "local node is excluded but remote slots are not compacted away");
|
||||
assert_eq!(all.len(), 4, "all slots preserve the sorted cluster topology shape");
|
||||
assert_eq!(
|
||||
remote_topology_hosts,
|
||||
vec![
|
||||
"127.0.0.1:9001".to_string(),
|
||||
"127.0.0.1:notaport".to_string(),
|
||||
"127.0.0.1:9003".to_string()
|
||||
]
|
||||
);
|
||||
assert!(remote[0].is_some(), "valid remote peer should get a client");
|
||||
assert!(remote[1].is_none(), "unparseable remote peer should remain observable as a missing slot");
|
||||
assert!(remote[2].is_none(), "missing grid host should remain observable as a missing slot");
|
||||
assert!(all[0].is_none(), "local node is represented by the local server_info row");
|
||||
assert!(all[1].is_some());
|
||||
assert!(all[2].is_none());
|
||||
assert!(all[3].is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_activity_requires_restart_safe_peer_identity() {
|
||||
let legacy = decode_test_scanner_activity(ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION,
|
||||
topology_digest: Vec::new().into(),
|
||||
data_movement_active: false,
|
||||
response_proof: Vec::new().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
})
|
||||
.expect("legacy peers should retain their activity generations during a rolling upgrade");
|
||||
assert_eq!(
|
||||
legacy,
|
||||
ScannerPeerActivity {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION,
|
||||
topology_digest: None,
|
||||
data_movement_active: None,
|
||||
dirty_usage_generation: None,
|
||||
dirty_usage_pending: None,
|
||||
}
|
||||
);
|
||||
|
||||
let previous = decode_test_scanner_activity(ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: true,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
})
|
||||
.expect("protocol v4 peers should remain observable during a rolling upgrade");
|
||||
assert_eq!(
|
||||
previous,
|
||||
ScannerPeerActivity {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION,
|
||||
topology_digest: Some([7; 32]),
|
||||
data_movement_active: Some(true),
|
||||
dirty_usage_generation: None,
|
||||
dirty_usage_pending: None,
|
||||
}
|
||||
);
|
||||
|
||||
let malformed_topology = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 31].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(malformed_topology)
|
||||
.expect_err("activity topology digests must have the protocol-defined length")
|
||||
.to_string()
|
||||
.contains("topology digest")
|
||||
);
|
||||
|
||||
let missing_instance = ScannerActivityResponse {
|
||||
instance_id: String::new(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
};
|
||||
assert!(
|
||||
decode_scanner_activity(missing_instance)
|
||||
decode_test_scanner_activity(missing_instance)
|
||||
.expect_err("an empty instance ID is not restart safe")
|
||||
.to_string()
|
||||
.contains("instance ID")
|
||||
@@ -1554,18 +1993,30 @@ mod tests {
|
||||
instance_id: "ABCDEF0123456789ABCDEF0123456789".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
};
|
||||
assert!(
|
||||
decode_scanner_activity(malformed_instance)
|
||||
decode_test_scanner_activity(malformed_instance)
|
||||
.expect_err("activity instance IDs must use the canonical lowercase hex form")
|
||||
.to_string()
|
||||
.contains("instance ID")
|
||||
);
|
||||
|
||||
let activity = decode_scanner_activity(ScannerActivityResponse {
|
||||
let activity = decode_test_scanner_activity(ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: true,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
})
|
||||
.expect("complete activity responses should be accepted");
|
||||
assert_eq!(
|
||||
@@ -1574,8 +2025,123 @@ mod tests {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
topology_digest: Some([7; 32]),
|
||||
data_movement_active: Some(true),
|
||||
dirty_usage_generation: Some(11),
|
||||
dirty_usage_pending: Some(true),
|
||||
}
|
||||
);
|
||||
|
||||
let pending_without_generation = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: true,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(pending_without_generation)
|
||||
.expect_err("pending dirty usage must carry a nonzero generation")
|
||||
.to_string()
|
||||
.contains("without a generation")
|
||||
);
|
||||
|
||||
let previous_with_dirty_usage = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PREVIOUS_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(previous_with_dirty_usage)
|
||||
.expect_err("protocol v4 responses must not claim unauthenticated dirty usage fields")
|
||||
.to_string()
|
||||
.contains("unauthenticated v5 fields")
|
||||
);
|
||||
|
||||
let legacy_with_topology = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_LEGACY_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 0,
|
||||
dirty_usage_pending: false,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(legacy_with_topology)
|
||||
.expect_err("legacy protocol responses must not claim extended fields")
|
||||
.to_string()
|
||||
.contains("unexpected extended fields")
|
||||
);
|
||||
|
||||
let unsupported_protocol = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION + 1,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: b"proof".to_vec().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(unsupported_protocol)
|
||||
.expect_err("unknown activity protocols must fail closed")
|
||||
.to_string()
|
||||
.contains("unsupported scanner activity protocol")
|
||||
);
|
||||
|
||||
let missing_proof = ScannerActivityResponse {
|
||||
instance_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
namespace_generation: 7,
|
||||
maintenance_generation: 3,
|
||||
protocol_version: SCANNER_ACTIVITY_PROTOCOL_VERSION,
|
||||
topology_digest: vec![7; 32].into(),
|
||||
data_movement_active: false,
|
||||
response_proof: Vec::new().into(),
|
||||
dirty_usage_generation: 11,
|
||||
dirty_usage_pending: true,
|
||||
};
|
||||
assert!(
|
||||
decode_test_scanner_activity(missing_proof)
|
||||
.expect_err("unsigned scanner activity responses must fail closed")
|
||||
.to_string()
|
||||
.contains("response proof")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_scanner_config_requires_versioned_peer_acknowledgement() {
|
||||
for sub_system in [SCANNER_SUB_SYS, HEAL_SUB_SYS] {
|
||||
let err = validate_signal_service_protocol(SERVICE_SIGNAL_RELOAD_DYNAMIC, sub_system, 0)
|
||||
.expect_err("an unversioned peer must not claim scanner config convergence");
|
||||
assert!(err.to_string().contains("does not support dynamic"));
|
||||
validate_signal_service_protocol(
|
||||
SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
||||
sub_system,
|
||||
rustfs_protos::DYNAMIC_CONFIG_PROTOCOL_VERSION,
|
||||
)
|
||||
.expect("a current peer should support dynamic scanner config");
|
||||
}
|
||||
|
||||
validate_signal_service_protocol(SERVICE_SIGNAL_RELOAD_DYNAMIC, STORAGE_CLASS_SUB_SYS, 0)
|
||||
.expect("unrelated dynamic config keeps its existing compatibility contract");
|
||||
validate_signal_service_protocol(SERVICE_SIGNAL_REFRESH_CONFIG, SCANNER_SUB_SYS, 0)
|
||||
.expect("full refresh compatibility is guarded by its scanner preflight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1585,6 +2151,73 @@ mod tests {
|
||||
assert!(!PeerRestClient::is_network_like_error(&Error::NotImplemented));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_config_reload_outcome_keeps_tonic_and_remote_errors_typed() {
|
||||
assert!(matches!(
|
||||
tier_config_reload_status_outcome(tonic::Status::unavailable("peer offline")),
|
||||
TierConfigReloadOutcome::TransientReconnect(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_status_outcome(tonic::Status::deadline_exceeded("peer timeout")),
|
||||
TierConfigReloadOutcome::TransientReconnect(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_status_outcome(tonic::Status::permission_denied("bad signature")),
|
||||
TierConfigReloadOutcome::Terminal(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_status_outcome(tonic::Status::unknown("Service was not ready: test client")),
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_status_outcome(tonic::Status::unknown("peer response unknown")),
|
||||
TierConfigReloadOutcome::Terminal(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_status_outcome(tonic::Status::cancelled("request cancelled")),
|
||||
TierConfigReloadOutcome::Terminal(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_remote_failure(Some("backend unavailable".to_string())),
|
||||
TierConfigReloadOutcome::Terminal(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_remote_failure(Some("errServerNotInitialized".to_string())),
|
||||
TierConfigReloadOutcome::TransientRetrySameChannel(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_connection_outcome(Error::other("backend unavailable")),
|
||||
TierConfigReloadOutcome::Terminal(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
tier_config_reload_connection_outcome(Error::other("can not get client, err: connection unavailable")),
|
||||
TierConfigReloadOutcome::TransientReconnect(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_config_reload_single_attempt_clears_offline_gate_without_redial() {
|
||||
let client = test_peer_client();
|
||||
client.offline.store(true, Ordering::Release);
|
||||
|
||||
let outcome = client.load_transition_tier_config_single_attempt_outcome().await;
|
||||
|
||||
assert!(matches!(outcome, TierConfigReloadOutcome::TransientReconnect(_)));
|
||||
assert!(!client.offline.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_config_reload_readiness_retry_does_not_require_reconnect() {
|
||||
let client = test_peer_client();
|
||||
client.offline.store(true, Ordering::Release);
|
||||
|
||||
let outcome = tier_config_reload_status_outcome(tonic::Status::unknown("Service was not ready: startup"));
|
||||
|
||||
assert!(matches!(outcome, TierConfigReloadOutcome::TransientRetrySameChannel(_)));
|
||||
assert!(!outcome.requires_reconnect());
|
||||
assert!(client.offline.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peer_rest_client_fast_fails_when_marked_offline() {
|
||||
let client = test_peer_client();
|
||||
|
||||
@@ -49,6 +49,20 @@ use tracing::{debug, info, warn};
|
||||
|
||||
type Client = Arc<Box<dyn PeerS3Client>>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScannerBucketListing {
|
||||
pub buckets: Vec<BucketInfo>,
|
||||
pub set_buckets: Vec<ScannerSetBucketListing>,
|
||||
pub topology_complete: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ScannerSetBucketListing {
|
||||
pub pool_index: usize,
|
||||
pub set_index: usize,
|
||||
pub buckets: Vec<BucketInfo>,
|
||||
}
|
||||
|
||||
fn pool_participant_errors(clients: &[Client], errors: &[Option<Error>], pool_idx: usize) -> Vec<Option<Error>> {
|
||||
clients
|
||||
.iter()
|
||||
@@ -216,6 +230,10 @@ impl S3PeerSys {
|
||||
Ok(())
|
||||
}
|
||||
pub async fn list_bucket(&self, opts: &BucketOptions) -> Result<Vec<BucketInfo>> {
|
||||
Ok(self.list_bucket_for_scanner(opts).await?.buckets)
|
||||
}
|
||||
|
||||
pub async fn list_bucket_for_scanner(&self, opts: &BucketOptions) -> Result<ScannerBucketListing> {
|
||||
let mut futures = Vec::with_capacity(self.clients.len());
|
||||
for cli in self.clients.iter() {
|
||||
futures.push(cli.list_bucket(opts));
|
||||
@@ -239,9 +257,12 @@ impl S3PeerSys {
|
||||
}
|
||||
|
||||
let mut result_map: HashMap<&String, BucketInfo> = HashMap::new();
|
||||
let mut topology_complete = true;
|
||||
for i in 0..self.pools_count {
|
||||
let per_pool_errs = pool_participant_errors(&self.clients, &errors, i);
|
||||
let quorum = pool_write_quorum(per_pool_errs.len());
|
||||
topology_complete &=
|
||||
!per_pool_errs.is_empty() && per_pool_errs.iter().all(|participant_error| participant_error.is_none());
|
||||
|
||||
if let Some(pool_err) = reduce_pool_write_quorum_errs(&per_pool_errs) {
|
||||
tracing::error!("list_bucket per_pool_errs: {per_pool_errs:?}");
|
||||
@@ -261,20 +282,17 @@ impl S3PeerSys {
|
||||
}
|
||||
|
||||
for bucket in buckets.iter() {
|
||||
if result_map.contains_key(&bucket.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// incr bucket_map count create if not exists
|
||||
let count = bucket_map.entry(&bucket.name).or_insert(0usize);
|
||||
*count += 1;
|
||||
|
||||
if *count >= quorum {
|
||||
result_map.insert(&bucket.name, bucket.clone());
|
||||
result_map.entry(&bucket.name).or_insert_with(|| bucket.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
topology_complete &= bucket_map.values().all(|count| *count >= quorum);
|
||||
// TODO: MRF
|
||||
}
|
||||
|
||||
@@ -282,7 +300,11 @@ impl S3PeerSys {
|
||||
|
||||
buckets.sort_by_key(|b| b.name.clone());
|
||||
|
||||
Ok(buckets)
|
||||
Ok(ScannerBucketListing {
|
||||
buckets,
|
||||
set_buckets: Vec::new(),
|
||||
topology_complete,
|
||||
})
|
||||
}
|
||||
pub async fn delete_bucket(&self, bucket: &str, opts: &DeleteBucketOptions) -> Result<()> {
|
||||
let mut futures = Vec::with_capacity(self.clients.len());
|
||||
@@ -1645,6 +1667,86 @@ mod tests {
|
||||
assert_eq!(buckets[0].name, bucket.name);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_bucket_listing_marks_quorum_result_incomplete_when_a_peer_is_missing() {
|
||||
let bucket = BucketInfo {
|
||||
name: "bucket-hidden-by-quorum".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let peer_sys = S3PeerSys {
|
||||
clients: vec![
|
||||
test_peer_with_list_bucket(&[0], Ok(vec![bucket])),
|
||||
test_peer_with_list_bucket(&[0], Ok(Vec::new())),
|
||||
test_peer_with_list_bucket(&[0], Ok(Vec::new())),
|
||||
test_peer_with_list_bucket(&[0], Err(Error::DiskAccessDenied)),
|
||||
],
|
||||
pools_count: 1,
|
||||
};
|
||||
|
||||
let listing = peer_sys
|
||||
.list_bucket_for_scanner(&BucketOptions::default())
|
||||
.await
|
||||
.expect("peer quorum should still produce a scanner candidate listing");
|
||||
|
||||
assert!(listing.buckets.is_empty());
|
||||
assert!(!listing.topology_complete);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_bucket_listing_marks_divergent_successful_peers_incomplete() {
|
||||
let bucket = BucketInfo {
|
||||
name: "bucket-below-quorum".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let peer_sys = S3PeerSys {
|
||||
clients: vec![
|
||||
test_peer_with_list_bucket(&[0], Ok(vec![bucket.clone()])),
|
||||
test_peer_with_list_bucket(&[0], Ok(vec![bucket])),
|
||||
test_peer_with_list_bucket(&[0], Ok(Vec::new())),
|
||||
test_peer_with_list_bucket(&[0], Ok(Vec::new())),
|
||||
],
|
||||
pools_count: 1,
|
||||
};
|
||||
|
||||
let listing = peer_sys
|
||||
.list_bucket_for_scanner(&BucketOptions::default())
|
||||
.await
|
||||
.expect("successful peer responses should still produce a scanner candidate listing");
|
||||
|
||||
assert!(listing.buckets.is_empty());
|
||||
assert!(!listing.topology_complete);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_bucket_listing_checks_same_bucket_in_every_pool() {
|
||||
let bucket = BucketInfo {
|
||||
name: "shared-bucket".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let peer_sys = S3PeerSys {
|
||||
clients: vec![
|
||||
test_peer_with_list_bucket(&[0], Ok(vec![bucket.clone()])),
|
||||
test_peer_with_list_bucket(&[0], Ok(vec![bucket.clone()])),
|
||||
test_peer_with_list_bucket(&[0], Ok(vec![bucket.clone()])),
|
||||
test_peer_with_list_bucket(&[0], Ok(vec![bucket.clone()])),
|
||||
test_peer_with_list_bucket(&[1], Ok(vec![bucket.clone()])),
|
||||
test_peer_with_list_bucket(&[1], Ok(vec![bucket.clone()])),
|
||||
test_peer_with_list_bucket(&[1], Ok(Vec::new())),
|
||||
test_peer_with_list_bucket(&[1], Ok(Vec::new())),
|
||||
],
|
||||
pools_count: 2,
|
||||
};
|
||||
|
||||
let listing = peer_sys
|
||||
.list_bucket_for_scanner(&BucketOptions::default())
|
||||
.await
|
||||
.expect("a bucket visible in one pool should remain a scan candidate");
|
||||
|
||||
assert_eq!(listing.buckets.len(), 1);
|
||||
assert_eq!(listing.buckets[0].name, bucket.name);
|
||||
assert!(!listing.topology_complete);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_bucket_fails_when_any_pool_misses_write_quorum() {
|
||||
let peer_sys = S3PeerSys {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,10 @@
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_io_metrics::internode_metrics::{
|
||||
INTERNODE_MSGPACK_DIRECTION_RESPONSE, INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
|
||||
INTERNODE_OPERATION_GRPC_WRITE_ALL, INTERNODE_OPERATION_PUT_FILE_STREAM, INTERNODE_OPERATION_READ_FILE_STREAM,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC, INTERNODE_TRANSPORT_BACKEND_TCP_HTTP, global_internode_metrics,
|
||||
INTERNODE_MSGPACK_CODEC_JSON, INTERNODE_MSGPACK_CODEC_MSGPACK, INTERNODE_MSGPACK_DIRECTION_RESPONSE,
|
||||
INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_OPERATION_GRPC_READ_MULTIPLE, INTERNODE_OPERATION_GRPC_WRITE_ALL,
|
||||
INTERNODE_OPERATION_PUT_FILE_STREAM, INTERNODE_OPERATION_READ_FILE_STREAM, INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
INTERNODE_TRANSPORT_BACKEND_TCP_HTTP, global_internode_metrics,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -126,6 +127,38 @@ pub(crate) fn record_response_json_fallback(message: &'static str) {
|
||||
global_internode_metrics().record_msgpack_json_fallback(INTERNODE_MSGPACK_DIRECTION_RESPONSE, message);
|
||||
}
|
||||
|
||||
pub(crate) fn record_response_msgpack_decode(message: &'static str) {
|
||||
global_internode_metrics().record_msgpack_json_decode(
|
||||
INTERNODE_MSGPACK_DIRECTION_RESPONSE,
|
||||
message,
|
||||
INTERNODE_MSGPACK_CODEC_MSGPACK,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_response_json_decode(message: &'static str) {
|
||||
global_internode_metrics().record_msgpack_json_decode(
|
||||
INTERNODE_MSGPACK_DIRECTION_RESPONSE,
|
||||
message,
|
||||
INTERNODE_MSGPACK_CODEC_JSON,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_response_msgpack_decode_error(message: &'static str) {
|
||||
global_internode_metrics().record_msgpack_json_decode_error(
|
||||
INTERNODE_MSGPACK_DIRECTION_RESPONSE,
|
||||
message,
|
||||
INTERNODE_MSGPACK_CODEC_MSGPACK,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_response_json_decode_error(message: &'static str) {
|
||||
global_internode_metrics().record_msgpack_json_decode_error(
|
||||
INTERNODE_MSGPACK_DIRECTION_RESPONSE,
|
||||
message,
|
||||
INTERNODE_MSGPACK_CODEC_JSON,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_internode_metrics_for_test() {
|
||||
global_internode_metrics().reset_for_test();
|
||||
@@ -135,3 +168,8 @@ pub(crate) fn reset_internode_metrics_for_test() {
|
||||
pub(crate) fn internode_metrics_snapshot_for_test() -> InternodeMetricsSnapshot {
|
||||
global_internode_metrics().snapshot()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn internode_msgpack_json_decode_error_total_for_test() -> u64 {
|
||||
global_internode_metrics().msgpack_json_decode_error_total_for_test()
|
||||
}
|
||||
|
||||
+1341
-53
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ use rustfs_config::{
|
||||
oidc::{
|
||||
OIDC_CLAIM_NAME, OIDC_CLAIM_PREFIX, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_CONFIG_URL, OIDC_DEFAULT_CLAIM_NAME,
|
||||
OIDC_DEFAULT_EMAIL_CLAIM, OIDC_DEFAULT_GROUPS_CLAIM, OIDC_DEFAULT_ROLES_CLAIM, OIDC_DEFAULT_SCOPES,
|
||||
OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_OTHER_AUDIENCES,
|
||||
OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_ISSUER, OIDC_OTHER_AUDIENCES,
|
||||
OIDC_REDIRECT_URI, OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_ROLES_CLAIM, OIDC_SCOPES, OIDC_USERNAME_CLAIM,
|
||||
},
|
||||
};
|
||||
@@ -37,6 +37,11 @@ pub static DEFAULT_IDENTITY_OPENID_KVS: LazyLock<KVS> = LazyLock::new(|| {
|
||||
value: "".to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_ISSUER.to_owned(),
|
||||
value: "".to_owned(),
|
||||
hidden_if_empty: false,
|
||||
},
|
||||
KV {
|
||||
key: OIDC_CLIENT_ID.to_owned(),
|
||||
value: "".to_owned(),
|
||||
|
||||
@@ -46,6 +46,34 @@ pub const OUTPOSTS: &str = "OUTPOSTS";
|
||||
pub const SNOW: &str = "SNOW";
|
||||
pub const STANDARD_IA: &str = "STANDARD_IA";
|
||||
|
||||
/// Version of the client-discoverable storage-class write contract.
|
||||
pub const CAPABILITY_CONTRACT_VERSION: u32 = 1;
|
||||
/// Storage classes whose write semantics RustFS implements.
|
||||
pub const SUPPORTED_WRITE_CLASSES: [&str; 2] = [STANDARD, RRS];
|
||||
/// Stable S3 error code returned for unsupported write classes.
|
||||
pub const UNSUPPORTED_WRITE_ERROR: &str = "InvalidStorageClass";
|
||||
/// Compatibility behavior applied to historical label-only object metadata.
|
||||
pub const LEGACY_LABEL_BEHAVIOR: &str = "normalized_to_effective_class";
|
||||
|
||||
/// Returns whether a client may select this storage class for a write.
|
||||
pub fn is_supported_write_class(storage_class: &str) -> bool {
|
||||
SUPPORTED_WRITE_CLASSES.contains(&storage_class)
|
||||
}
|
||||
|
||||
/// Resolves the storage class that truthfully describes the stored object.
|
||||
///
|
||||
/// A completed lifecycle transition is a real storage tier and therefore keeps
|
||||
/// its tier name. For local objects, only RRS has distinct layout semantics;
|
||||
/// historical AWS class labels otherwise describe the effective STANDARD
|
||||
/// layout.
|
||||
pub fn effective_class<'a>(stored_class: Option<&'a str>, transitioned_tier: Option<&'a str>) -> &'a str {
|
||||
if let Some(tier) = transitioned_tier.filter(|tier| !tier.is_empty()) {
|
||||
return tier;
|
||||
}
|
||||
|
||||
if stored_class == Some(RRS) { RRS } else { STANDARD }
|
||||
}
|
||||
|
||||
// Standard constants for config info storage class
|
||||
pub const CLASS_STANDARD: &str = "standard";
|
||||
pub const CLASS_RRS: &str = "rrs";
|
||||
@@ -512,6 +540,91 @@ mod tests {
|
||||
StorageClassEnvOverrides::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_inline_preserves_exact_default_shard_boundaries() {
|
||||
let config = Config::default();
|
||||
|
||||
for (case, shard_size, versioned, expected) in [
|
||||
("unversioned below", 128 * 1024 - 1, false, true),
|
||||
("unversioned exact", 128 * 1024, false, true),
|
||||
("unversioned above", 128 * 1024 + 1, false, false),
|
||||
("versioned below", 16 * 1024 - 1, true, true),
|
||||
("versioned exact", 16 * 1024, true, true),
|
||||
("versioned above", 16 * 1024 + 1, true, false),
|
||||
("negative", -1, false, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
config.should_inline(shard_size, versioned),
|
||||
expected,
|
||||
"{case}: shard_size={shard_size}, versioned={versioned}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_inline_preserves_exact_default_ec_2_2_object_boundaries() {
|
||||
let config = Config::default();
|
||||
let erasure = crate::erasure::coding::Erasure::new(2, 2, 1024 * 1024);
|
||||
|
||||
for (case, object_size, versioned, expected_shard_size, expected) in [
|
||||
("unversioned below", 256 * 1024 - 1, false, 128 * 1024, true),
|
||||
("unversioned exact", 256 * 1024, false, 128 * 1024, true),
|
||||
("unversioned above", 256 * 1024 + 1, false, 128 * 1024 + 1, false),
|
||||
("versioned below", 32 * 1024 - 1, true, 16 * 1024, true),
|
||||
("versioned exact", 32 * 1024, true, 16 * 1024, true),
|
||||
("versioned above", 32 * 1024 + 1, true, 16 * 1024 + 1, false),
|
||||
] {
|
||||
let shard_size = erasure.shard_file_size(object_size);
|
||||
assert_eq!(shard_size, expected_shard_size, "{case}: object_size={object_size}");
|
||||
assert_eq!(
|
||||
config.should_inline(shard_size, versioned),
|
||||
expected,
|
||||
"{case}: object_size={object_size}, shard_size={shard_size}, versioned={versioned}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_capability_contract_only_accepts_implemented_layouts() {
|
||||
assert_eq!(SUPPORTED_WRITE_CLASSES, [STANDARD, RRS]);
|
||||
assert!(is_supported_write_class(STANDARD));
|
||||
assert!(is_supported_write_class(RRS));
|
||||
|
||||
for label_only_class in [
|
||||
DEEP_ARCHIVE,
|
||||
EXPRESS_ONEZONE,
|
||||
GLACIER,
|
||||
GLACIER_IR,
|
||||
INTELLIGENT_TIERING,
|
||||
ONEZONE_IA,
|
||||
OUTPOSTS,
|
||||
SNOW,
|
||||
STANDARD_IA,
|
||||
] {
|
||||
assert!(
|
||||
!is_supported_write_class(label_only_class),
|
||||
"{label_only_class} must not be advertised as a supported write class"
|
||||
);
|
||||
}
|
||||
assert!(!is_supported_write_class(""));
|
||||
assert!(!is_supported_write_class("standard"));
|
||||
assert!(!is_supported_write_class("UNKNOWN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_class_normalizes_legacy_labels_and_preserves_real_tiers() {
|
||||
assert_eq!(effective_class(None, None), STANDARD);
|
||||
assert_eq!(effective_class(Some(STANDARD), None), STANDARD);
|
||||
assert_eq!(effective_class(Some(RRS), None), RRS);
|
||||
assert_eq!(effective_class(Some(STANDARD_IA), None), STANDARD);
|
||||
assert_eq!(effective_class(Some(GLACIER), None), STANDARD);
|
||||
assert_eq!(effective_class(Some("UNKNOWN"), None), STANDARD);
|
||||
|
||||
assert_eq!(effective_class(Some(STANDARD_IA), Some("WARM-TIER")), "WARM-TIER");
|
||||
assert_eq!(effective_class(Some(STANDARD), Some(STANDARD_IA)), STANDARD_IA);
|
||||
assert_eq!(effective_class(Some(RRS), Some("CUSTOM-RRS-TIER")), "CUSTOM-RRS-TIER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_parity_is_resolved_per_pool() {
|
||||
let cfg = lookup_config_for_pools_with_env(&KVS::new(), &[4, 2], no_env_overrides())
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
use crate::disk::error_reduce::count_errs;
|
||||
use crate::error::{Error, Result};
|
||||
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::{
|
||||
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
|
||||
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
|
||||
@@ -49,7 +50,10 @@ use rustfs_filemeta::FileInfo;
|
||||
use rustfs_lock::NamespaceLockWrapper;
|
||||
use rustfs_madmin::heal_commands::HealResultItem;
|
||||
use rustfs_utils::{crc_hash, path::path_join_buf, sip_hash};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::broadcast::{Receiver, Sender};
|
||||
use tokio::time::Duration;
|
||||
@@ -63,6 +67,8 @@ type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
const LIST_MULTIPART_SETS_CONCURRENCY: usize = 4;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sets {
|
||||
pub id: Uuid,
|
||||
@@ -799,9 +805,52 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for Sets {
|
||||
delimiter: Option<String>,
|
||||
max_uploads: usize,
|
||||
) -> Result<ListMultipartsInfo> {
|
||||
self.get_disks_by_key(prefix)
|
||||
.list_multipart_uploads(bucket, prefix, key_marker, upload_id_marker, delimiter, max_uploads)
|
||||
.await
|
||||
let per_set_limit = max_uploads.saturating_add(1);
|
||||
let results = futures::stream::iter(self.disk_set.iter().cloned())
|
||||
.map(|set| {
|
||||
let key_marker = key_marker.clone();
|
||||
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
|
||||
}
|
||||
})
|
||||
.buffer_unordered(LIST_MULTIPART_SETS_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let mut uploads = Vec::new();
|
||||
let mut common_prefixes = HashSet::new();
|
||||
let mut source_truncated = false;
|
||||
for result in results {
|
||||
let page = result?;
|
||||
uploads.extend(page.uploads);
|
||||
common_prefixes.extend(page.common_prefixes);
|
||||
source_truncated |= page.is_truncated;
|
||||
}
|
||||
|
||||
let page = paginate_multipart_listing(
|
||||
uploads,
|
||||
common_prefixes.into_iter().collect(),
|
||||
key_marker.as_deref(),
|
||||
key_marker.as_ref().and(upload_id_marker.as_deref()),
|
||||
max_uploads,
|
||||
source_truncated,
|
||||
);
|
||||
|
||||
Ok(ListMultipartsInfo {
|
||||
key_marker,
|
||||
upload_id_marker,
|
||||
next_key_marker: page.next_key_marker,
|
||||
next_upload_id_marker: page.next_upload_id_marker,
|
||||
max_uploads,
|
||||
is_truncated: page.is_truncated,
|
||||
uploads: page.uploads,
|
||||
common_prefixes: page.common_prefixes,
|
||||
prefix: prefix.to_owned(),
|
||||
delimiter,
|
||||
})
|
||||
}
|
||||
#[tracing::instrument(skip(self))]
|
||||
async fn new_multipart_upload(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<MultipartUploadResult> {
|
||||
@@ -1111,6 +1160,7 @@ mod tests {
|
||||
use crate::layout::endpoints::SetupType;
|
||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::list::ListOperations as _;
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations as _;
|
||||
use rustfs_lock::client::local::LocalClient;
|
||||
use serial_test::serial;
|
||||
|
||||
@@ -1248,6 +1298,194 @@ mod tests {
|
||||
assert_eq!(result, (Some(3), Some(1), Some(0)));
|
||||
}
|
||||
|
||||
async fn multipart_listing_test_sets() -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
||||
let format = FormatV3::new(2, 2);
|
||||
let mut temp_dirs = Vec::new();
|
||||
let mut all_endpoints = Vec::new();
|
||||
let mut disk_sets = Vec::new();
|
||||
|
||||
for set_index in 0..2 {
|
||||
let mut endpoints = Vec::new();
|
||||
let mut disks = Vec::new();
|
||||
for disk_index in 0..2 {
|
||||
let temp_dir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("tempdir path should be utf8"))
|
||||
.expect("endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(set_index);
|
||||
endpoint.set_disk_index(disk_index);
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("disk should be created");
|
||||
let mut disk_format = format.clone();
|
||||
disk_format.erasure.this = format.erasure.sets[set_index][disk_index];
|
||||
save_format_file(&Some(disk.clone()), &Some(disk_format))
|
||||
.await
|
||||
.expect("format should be saved");
|
||||
temp_dirs.push(temp_dir);
|
||||
all_endpoints.push(endpoint.clone());
|
||||
endpoints.push(endpoint);
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
disk_sets.push(
|
||||
SetDisks::new(
|
||||
"test-owner".to_string(),
|
||||
Arc::new(RwLock::new(disks)),
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
set_index,
|
||||
endpoints,
|
||||
format.clone(),
|
||||
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
let sets = Arc::new(Sets {
|
||||
id: format.id,
|
||||
disk_set: disk_sets,
|
||||
pool_idx: 0,
|
||||
endpoints: PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 2,
|
||||
drives_per_set: 2,
|
||||
endpoints: Endpoints::from(all_endpoints),
|
||||
cmd_line: String::new(),
|
||||
platform: String::new(),
|
||||
},
|
||||
format,
|
||||
parity_count: 1,
|
||||
set_count: 2,
|
||||
set_drive_count: 2,
|
||||
default_parity_count: 1,
|
||||
distribution_algo: DistributionAlgoVersion::V1,
|
||||
exit_signal: None,
|
||||
ctx: bootstrap_ctx(),
|
||||
});
|
||||
(temp_dirs, sets)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn list_multipart_uploads_merges_all_sets_without_pagination_loss() {
|
||||
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
|
||||
let (_temp_dirs, sets) = multipart_listing_test_sets().await;
|
||||
let bucket = format!("multipart-list-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created");
|
||||
|
||||
let mut keys_by_set = [Vec::new(), Vec::new()];
|
||||
for index in 0..100 {
|
||||
let key = format!("logs/{index:03}.bin");
|
||||
let set_index = sets.get_hashed_set_index(&key);
|
||||
if keys_by_set[set_index].len() < 2 {
|
||||
keys_by_set[set_index].push(key);
|
||||
}
|
||||
if keys_by_set.iter().all(|keys| keys.len() == 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(keys_by_set.iter().all(|keys| keys.len() == 2), "test keys must span both sets");
|
||||
|
||||
let repeated_key = keys_by_set[0][0].clone();
|
||||
let mut expected = Vec::new();
|
||||
for key in keys_by_set.iter().flatten() {
|
||||
let upload = sets
|
||||
.new_multipart_upload(&bucket, key, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("multipart upload should be created");
|
||||
expected.push((key.clone(), upload.upload_id));
|
||||
}
|
||||
let second = sets
|
||||
.new_multipart_upload(&bucket, &repeated_key, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("second upload for the same key should be created");
|
||||
expected.push((repeated_key, second.upload_id));
|
||||
expected.sort();
|
||||
|
||||
let mut actual = Vec::new();
|
||||
let mut key_marker = None;
|
||||
let mut upload_id_marker = None;
|
||||
for _ in 0..expected.len() + 1 {
|
||||
let page = sets
|
||||
.list_multipart_uploads(&bucket, "logs/", key_marker.clone(), upload_id_marker.clone(), None, 2)
|
||||
.await
|
||||
.expect("multipart page should list across every set");
|
||||
assert!(page.uploads.len() <= 2);
|
||||
actual.extend(
|
||||
page.uploads
|
||||
.iter()
|
||||
.map(|upload| (upload.object.clone(), upload.upload_id.clone())),
|
||||
);
|
||||
if !page.is_truncated {
|
||||
break;
|
||||
}
|
||||
key_marker = page.next_key_marker;
|
||||
upload_id_marker = page.next_upload_id_marker;
|
||||
}
|
||||
|
||||
assert_eq!(actual, 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");
|
||||
|
||||
let mut nested_by_set = [None, None];
|
||||
for index in 0..100 {
|
||||
let key = format!("nested/group-{index:03}/file.bin");
|
||||
let set_index = sets.get_hashed_set_index(&key);
|
||||
nested_by_set[set_index].get_or_insert(key);
|
||||
if nested_by_set.iter().all(Option::is_some) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for key in nested_by_set.iter().flatten() {
|
||||
sets.new_multipart_upload(&bucket, key, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("nested multipart upload should be created");
|
||||
}
|
||||
let mut expected_prefixes = nested_by_set
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|key| {
|
||||
key.rsplit_once('/')
|
||||
.expect("nested key should contain a delimiter")
|
||||
.0
|
||||
.to_string()
|
||||
+ "/"
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
expected_prefixes.sort();
|
||||
|
||||
let first = sets
|
||||
.list_multipart_uploads(&bucket, "nested/", None, None, Some("/".to_string()), 1)
|
||||
.await
|
||||
.expect("first delimiter page should list across every set");
|
||||
assert!(first.is_truncated);
|
||||
assert_eq!(first.common_prefixes, expected_prefixes[..1]);
|
||||
let second = sets
|
||||
.list_multipart_uploads(
|
||||
&bucket,
|
||||
"nested/",
|
||||
first.next_key_marker,
|
||||
first.next_upload_id_marker,
|
||||
Some("/".to_string()),
|
||||
1,
|
||||
)
|
||||
.await
|
||||
.expect("second delimiter page should list across every set");
|
||||
assert!(!second.is_truncated);
|
||||
assert_eq!(second.common_prefixes, expected_prefixes[1..]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn sets_list_objects_v2_lists_objects_within_the_pool() {
|
||||
|
||||
@@ -560,17 +560,22 @@ pub(crate) async fn cleanup_source_entry_if_unchanged(
|
||||
|
||||
ensure_source_cleanup_versions_unchanged(set.clone(), bucket, object, expected, allowed_missing, op_label).await?;
|
||||
|
||||
set.delete_object(
|
||||
bucket,
|
||||
cleanup_key.as_str(),
|
||||
ObjectOptions {
|
||||
delete_prefix: true,
|
||||
delete_prefix_object: true,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.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;
|
||||
if result.is_ok() {
|
||||
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn should_check_data_movement_resume_target(src_pool_idx: usize, target_pool_idx: usize) -> bool {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user