Compare commits

..

1 Commits

Author SHA1 Message Date
overtrue 94f9c5b718 fix(test): make 4 tests resilient to shared global state in full suite
The scanner_activity, background_heal_status, and two capacity dirty
scope tests assumed no global AppContext or object store was registered.
When run as part of the full test binary, shared_gating_ecstore()
initialises global state (ECStore + notification system) that other tests
pick up via runtime_sources::current_app_context().

- scanner_activity: accept either Unavailable (no storage) or a valid
  response (storage available from shared env)
- background_heal_status: accept either success=false (no storage) or
  success=true with non-empty state
- capacity dirty scope: use filter_map on canonicalize to skip dirty
  disks whose paths are not resolvable on the local filesystem
2026-07-20 21:10:39 +08:00
113 changed files with 1017 additions and 16742 deletions
+3 -3
View File
@@ -42,7 +42,7 @@ e2e-reliability = { max-threads = 1 }
# --- default profile (local): serialize the flaky groups, never retry --------
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(/^store::bucket::tests::bucket_delete_(mark_delete_marks|purge_removes|default_s3_delete)/))'
test-group = 'ecstore-serial-flaky'
# Serialize the multipart crash-consistency scenarios (dist-2, backlog#1150):
@@ -99,7 +99,7 @@ retries = 2
# QUARANTINE: OPEN backlog#937 — store::bucket::tests::bucket_delete_* race
# make_bucket into InsufficientWriteQuorum via shared global state under load.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/)'
filter = 'package(rustfs-ecstore) & test(/^store::bucket::tests::bucket_delete_(mark_delete_marks|purge_removes|default_s3_delete)/)'
test-group = 'ecstore-serial-flaky'
retries = 2
@@ -192,7 +192,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_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)_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::/)
@@ -1744,7 +1744,7 @@
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_objects_count{job=~\"$job\", bucket=~\"$bucket\"})",
"expr": "sum by (bucket) (rustfs_bucket_api_objects_total{job=~\"$job\", bucket=~\"$bucket\"})",
"legendFormat": "{{bucket}}",
"range": true,
"refId": "A"
@@ -1844,7 +1844,7 @@
"uid": "${datasource}"
},
"editorMode": "code",
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_total_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
"expr": "sum by (bucket) (rustfs_bucket_api_usage_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
"legendFormat": "{{bucket}}",
"range": true,
"refId": "A"
@@ -11583,7 +11583,7 @@
"text": "All",
"value": "$__all"
},
"definition": "label_values(rustfs_cluster_usage_buckets_objects_count,bucket)",
"definition": "label_values(rustfs_bucket_api_objects_total,bucket)",
"includeAll": true,
"label": "Bucket",
"multi": true,
@@ -11591,7 +11591,7 @@
"options": [],
"query": {
"qryType": 1,
"query": "label_values(rustfs_cluster_usage_buckets_objects_count,bucket)",
"query": "label_values(rustfs_bucket_api_objects_total,bucket)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 2,
@@ -18,7 +18,6 @@ set -eu
ACCESS_KEY="${RUSTFS_SITE_REPL_ACCESS_KEY:-rustfsadmin}"
SECRET_KEY="${RUSTFS_SITE_REPL_SECRET_KEY:-rustfsadmin}"
BUCKET="${RUSTFS_SITE_REPL_FLOW_BUCKET:-site-repl-flow-check}"
DELETE_BUCKET="${RUSTFS_SITE_REPL_DELETE_BUCKET:-site-repl-delete-$(date +%Y%m%d-%H%M%S)-$$}"
PREFIX="${RUSTFS_SITE_REPL_FLOW_PREFIX:-flow-$(date +%Y%m%d-%H%M%S)}"
WAIT_ATTEMPTS="${RUSTFS_SITE_REPL_WAIT_ATTEMPTS:-90}"
WAIT_SLEEP_SECONDS="${RUSTFS_SITE_REPL_WAIT_SLEEP_SECONDS:-2}"
@@ -86,39 +85,17 @@ wait_for_object() {
wait_for_bucket() {
site="$1"
bucket="${2:-$BUCKET}"
attempt=1
while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do
if mc stat "$site/$bucket" >/dev/null 2>&1; then
if mc stat "$site/$BUCKET" >/dev/null 2>&1; then
return 0
fi
sleep "$WAIT_SLEEP_SECONDS"
attempt=$((attempt + 1))
done
echo "bucket was not replicated in time: $site/$bucket" >&2
return 1
}
wait_for_bucket_delete() {
site="$1"
bucket="$2"
attempt=1
while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do
if result="$(mc stat --json "$site/$bucket" 2>&1)"; then
:
else
case "$result" in
*NoSuchBucket*) return 0 ;;
esac
fi
sleep "$WAIT_SLEEP_SECONDS"
attempt=$((attempt + 1))
done
echo "bucket deletion was not replicated in time: $site/$bucket" >&2
echo "bucket was not replicated in time: $site/$BUCKET" >&2
return 1
}
@@ -209,20 +186,6 @@ EOF
echo "verified replicated downloads for $object_name"
done
echo "creating empty bucket for replicated delete check: $DELETE_BUCKET"
mc mb "site1/$DELETE_BUCKET" >/dev/null
for site in site1 site2 site3; do
wait_for_bucket "$site" "$DELETE_BUCKET"
done
echo "deleting empty bucket on site1: $DELETE_BUCKET"
mc rb "site1/$DELETE_BUCKET" >/dev/null
for site in site1 site2 site3; do
wait_for_bucket_delete "$site" "$DELETE_BUCKET"
done
echo "site replication object flow check passed"
echo "bucket: $BUCKET"
echo "prefix: $PREFIX"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Generated
+82 -101
View File
@@ -694,9 +694,9 @@ dependencies = [
[[package]]
name = "async-nats"
version = "0.50.0"
version = "0.49.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83a251fa1a4c9d0fe6e816b7acd60549e473e08d14f27a1d992c2675abff05f"
checksum = "fad3cd6df81292728e2a8cb1f1dcb4d7e7a1ab59b80c14fbbcba2baf9d5cf86a"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -769,7 +769,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.1",
]
[[package]]
@@ -1519,7 +1519,7 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array 0.14.7",
"generic-array 0.14.9",
]
[[package]]
@@ -1538,7 +1538,7 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array 0.14.7",
"generic-array 0.14.9",
]
[[package]]
@@ -1861,7 +1861,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"crypto-common 0.1.6",
"inout 0.1.4",
]
@@ -1879,9 +1879,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.6.3"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776"
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
dependencies = [
"clap_builder",
"clap_derive",
@@ -1901,9 +1901,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.3"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
@@ -2319,7 +2319,7 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array 0.14.7",
"generic-array 0.14.9",
"rand_core 0.6.4",
"subtle",
"zeroize",
@@ -2344,11 +2344,11 @@ dependencies = [
[[package]]
name = "crypto-common"
version = "0.1.7"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array 0.14.7",
"generic-array 0.14.9",
"typenum",
]
@@ -2437,16 +2437,15 @@ dependencies = [
[[package]]
name = "curve25519-dalek"
version = "5.0.0"
version = "5.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"curve25519-dalek-derive",
"digest 0.11.3",
"fiat-crypto 0.3.0",
"rand_core 0.10.1",
"rustc_version",
"subtle",
"zeroize",
@@ -3484,9 +3483,9 @@ dependencies = [
[[package]]
name = "dial9-tokio-telemetry"
version = "0.3.15"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b511dfd54f5191f7eb86856fe19d8e3c8f71673bd256e1bfa1c8128fbbc0cdb"
checksum = "4ae338262e40cf9036474cc3c1f0dc67c09421674ced255b7c643060829a3f5d"
dependencies = [
"arc-swap",
"bon",
@@ -3545,7 +3544,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.7",
"crypto-common 0.1.6",
"subtle",
]
@@ -3768,11 +3767,11 @@ dependencies = [
[[package]]
name = "ed25519-dalek"
version = "3.0.0"
version = "3.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849"
dependencies = [
"curve25519-dalek 5.0.0",
"curve25519-dalek 5.0.0-rc.1",
"ed25519 3.0.0",
"rand_core 0.10.1",
"serde",
@@ -3798,7 +3797,7 @@ dependencies = [
"crypto-bigint 0.5.5",
"digest 0.10.7",
"ff 0.13.1",
"generic-array 0.14.7",
"generic-array 0.14.9",
"group 0.13.0",
"hkdf 0.12.4",
"pem-rfc7468 0.7.0",
@@ -3866,9 +3865,9 @@ dependencies = [
[[package]]
name = "enumset"
version = "1.1.14"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0"
checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d"
dependencies = [
"enumset_derive",
]
@@ -4244,9 +4243,9 @@ dependencies = [
[[package]]
name = "generic-array"
version = "0.14.7"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
dependencies = [
"typenum",
"version_check",
@@ -4259,7 +4258,7 @@ version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b"
dependencies = [
"generic-array 0.14.7",
"generic-array 0.14.9",
"rustversion",
"typenum",
]
@@ -5021,9 +5020,8 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
version = "1.10.1"
source = "git+https://github.com/hyperium/hyper.git?rev=ccc1e850dc0cda3e71b0acd11f60ca3d48d09034#ccc1e850dc0cda3e71b0acd11f60ca3d48d09034"
dependencies = [
"atomic-waker",
"bytes",
@@ -5259,7 +5257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array 0.14.7",
"generic-array 0.14.9",
]
[[package]]
@@ -5710,9 +5708,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.187"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7743783ea728ef5c31194c6590797eed286449b4a4e87d626d8a51f0a94e732"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libflate"
@@ -6256,7 +6254,7 @@ dependencies = [
"module-lattice",
"pkcs8 0.11.0",
"rand_core 0.10.1",
"sha3 0.11.0",
"sha3",
]
[[package]]
@@ -6292,9 +6290,9 @@ dependencies = [
[[package]]
name = "mqttbytes-core-next"
version = "0.33.3"
version = "0.33.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff7ae19c74aba9e0ed6e4071cd52aa364e020076fa3cc6ef17e43662f756f3c"
checksum = "6d9e8f1593f6a8affb39a2df042cc28df033ba18907858cde71993e5780d29a3"
dependencies = [
"bytes",
"thiserror 2.0.19",
@@ -6674,7 +6672,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.22.1",
"base64 0.21.7",
"chrono",
"getrandom 0.2.17",
"http 1.4.2",
@@ -7037,9 +7035,9 @@ dependencies = [
[[package]]
name = "p256"
version = "0.14.0"
version = "0.14.0-rc.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
checksum = "b6bb40a5099e2c38a09dd29321a7a7f045f165a54317679c7cdfb0cbaf8f6b1e"
dependencies = [
"ecdsa 0.17.0",
"elliptic-curve 0.14.1",
@@ -7062,9 +7060,9 @@ dependencies = [
[[package]]
name = "p384"
version = "0.14.0"
version = "0.14.0-rc.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f"
checksum = "492f329d7eb11d22dadc988626b9ea1f503b4ab043a8b1e4e2cc4ae45dabdd70"
dependencies = [
"ecdsa 0.17.0",
"elliptic-curve 0.14.1",
@@ -7076,9 +7074,9 @@ dependencies = [
[[package]]
name = "p521"
version = "0.14.0"
version = "0.14.0-rc.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449"
checksum = "ff42e4ace5424e3b6d7cb82514be89866b85af87015f80341e37dcc21a66ce6e"
dependencies = [
"base16ct 1.0.0",
"ecdsa 0.17.0",
@@ -7796,7 +7794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck",
"itertools 0.14.0",
"itertools 0.10.5",
"log",
"multimap",
"once_cell",
@@ -7816,7 +7814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [
"heck",
"itertools 0.14.0",
"itertools 0.10.5",
"log",
"multimap",
"petgraph 0.8.3",
@@ -7837,7 +7835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.14.0",
"itertools 0.10.5",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -7850,7 +7848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools 0.14.0",
"itertools 0.10.5",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -8394,7 +8392,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.1",
]
[[package]]
@@ -8596,9 +8594,9 @@ dependencies = [
[[package]]
name = "rumqttc-core-next"
version = "0.33.3"
version = "0.33.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d7d9205738dd41a2546e82d27a634d07d8b303dcf7558565ff70caf3ceb0f9c"
checksum = "eaa975ffc1af5aac70a0e139491a1fc553ab48f378f909dad5d43fd4b2ad28db"
dependencies = [
"async-tungstenite",
"futures-io",
@@ -8614,18 +8612,18 @@ dependencies = [
[[package]]
name = "rumqttc-next"
version = "0.33.3"
version = "0.33.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed1bad2180ff539da671da9a996152a921bc5316eb6d8a9cc3bd441653138b08"
checksum = "df63fbd71ba5830b5bd0952ae37761e3af64aeae8f6213eb6d31d6e72a16d71e"
dependencies = [
"rumqttc-v5-next",
]
[[package]]
name = "rumqttc-v5-next"
version = "0.33.3"
version = "0.33.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229576cbedfa9089f90c17c9454e9429ac1e89cdd223bac5cb39d837593f79bc"
checksum = "6417b354aa28ad72b271e6a5cea7fdb1594d18cb040ebed35fa87bb683cf364f"
dependencies = [
"async-tungstenite",
"bytes",
@@ -8648,9 +8646,9 @@ dependencies = [
[[package]]
name = "russh"
version = "0.62.3"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "059dd24c0fe20721639f7acad7b82cd51ec3dd3254ed8cf7a0b7df6c20eaff1c"
checksum = "ca6c3c1dd0a9ad3a9915a2f6e907d158f9a7e9ac32ddc772b003faafc027d9a8"
dependencies = [
"aes 0.9.1",
"aws-lc-rs",
@@ -8662,13 +8660,13 @@ dependencies = [
"cipher 0.5.2",
"crypto-bigint 0.7.5",
"ctr",
"curve25519-dalek 5.0.0",
"curve25519-dalek 5.0.0-rc.1",
"data-encoding",
"delegate",
"der 0.8.1",
"digest 0.11.3",
"ecdsa 0.17.0",
"ed25519-dalek 3.0.0",
"ed25519-dalek 3.0.0-rc.1",
"elliptic-curve 0.14.1",
"enum_dispatch",
"flate2",
@@ -8686,8 +8684,8 @@ dependencies = [
"ml-kem",
"module-lattice",
"num-bigint",
"p256 0.14.0",
"p384 0.14.0",
"p256 0.14.0-rc.15",
"p384 0.14.0-rc.15",
"p521",
"pageant",
"pbkdf2 0.13.0",
@@ -8705,7 +8703,7 @@ dependencies = [
"sec1 0.8.1",
"sha1 0.11.0",
"sha2 0.11.0",
"sha3 0.12.0",
"sha3",
"signature 3.0.0",
"spki 0.8.0",
"ssh-encoding",
@@ -10455,7 +10453,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct 0.2.0",
"der 0.7.10",
"generic-array 0.14.7",
"generic-array 0.14.9",
"pkcs8 0.10.2",
"subtle",
"zeroize",
@@ -10561,14 +10559,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.1",
]
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"indexmap 2.14.0",
"itoa",
@@ -10615,7 +10613,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.1",
]
[[package]]
@@ -10757,17 +10755,6 @@ dependencies = [
"keccak",
]
[[package]]
name = "sha3"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
dependencies = [
"digest 0.11.3",
"keccak",
"sponge-cursor",
]
[[package]]
name = "shadow-rs"
version = "2.0.0"
@@ -11040,12 +11027,6 @@ dependencies = [
"der 0.8.1",
]
[[package]]
name = "sponge-cursor"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
[[package]]
name = "sqlparser"
version = "0.62.0"
@@ -11110,11 +11091,11 @@ dependencies = [
"argon2",
"bcrypt-pbkdf",
"ctutils",
"ed25519-dalek 3.0.0",
"ed25519-dalek 3.0.0-rc.1",
"hex",
"hmac 0.13.0",
"p256 0.14.0",
"p384 0.14.0",
"p256 0.14.0-rc.15",
"p384 0.14.0-rc.15",
"p521",
"rand_core 0.10.1",
"rsa 0.10.0-rc.18",
@@ -11320,9 +11301,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.2"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
checksum = "5edbec4ed188954a10c12c038215f8ce7606b2d5c973cd8dc43e8795065c5f2f"
dependencies = [
"proc-macro2",
"quote",
@@ -11541,7 +11522,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.1",
]
[[package]]
@@ -11555,9 +11536,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.54"
version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [
"deranged",
"js-sys",
@@ -11578,9 +11559,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
dependencies = [
"num-conv",
"time-core",
@@ -11665,9 +11646,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.53.1"
version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [
"bytes",
"libc",
@@ -12921,18 +12902,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.55"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.55"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
+29 -11
View File
@@ -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 = "0.49.1"
axum = "0.8.9"
futures = "0.3.33"
futures-core = "0.3.33"
@@ -149,17 +149,17 @@ futures-util = "0.3.33"
pollster = "1.0.1"
pulsar = { default-features = false, version = "6.8.0" }
lapin = { default-features = false, version = "4.10.0" }
hyper = { version = "1.11.0" }
hyper = { version = "1.10.1" }
hyper-rustls = { default-features = false, version = "0.27.9" }
hyper-util = { version = "0.1.20" }
http = "1.4.2"
http-body = "1.1.0"
http-body-util = "0.1.4"
minlz = "1.2.3"
reqwest = "0.13.4"
reqwest = { default-features = false, version = "0.13.4" }
rustfs-kafka-async = { version = "1.2.0" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.53.1" }
tokio = { version = "1.53.0" }
tokio-rustls = { default-features = false, version = "0.26.4" }
tokio-stream = { version = "0.1.18" }
tokio-test = "0.4.5"
@@ -182,7 +182,7 @@ quick-xml = "0.41.0"
rmp = { version = "0.8.15" }
rmp-serde = { version = "1.3.1" }
serde = { version = "1.0.229" }
serde_json = { version = "1.0.151" }
serde_json = { version = "1.0.150" }
serde_urlencoded = "0.7.1"
# Cryptography and Security
@@ -212,7 +212,7 @@ zeroize = { version = "1.9.0" }
chrono = { version = "0.4.45" }
humantime = "2.4.0"
jiff = { version = "0.2.34" }
time = { version = "0.3.54" }
time = { version = "0.3.53" }
# Database
deadpool-postgres = { version = "0.14" }
@@ -234,7 +234,7 @@ aws-smithy-types = { version = "1.6.1" }
base64 = "0.22.1"
base64-simd = "0.8.0"
brotli = "8.0.4"
clap = { version = "4.6.3" }
clap = { version = "4.6.2" }
const-str = { version = "1.1.0" }
convert_case = "0.11.0"
criterion = { version = "0.8" }
@@ -244,7 +244,7 @@ crossbeam-deque = "0.8.7"
crossbeam-utils = "0.8.22"
datafusion = { default-features = false, git = "https://github.com/apache/datafusion.git", rev = "dae03ee062b2abf986de8df12ea82fb1578a2d99" }
derive_builder = "0.20.2"
enumset = "1.1.14"
enumset = "1.1.13"
faster-hex = "0.10.0"
flate2 = "1.1.9"
glob = "0.3.3"
@@ -256,7 +256,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.186"
libsystemd = "0.7.2"
local-ip-address = "0.6.13"
memmap2 = "0.9.11"
@@ -281,7 +281,7 @@ rayon = "1.12.0"
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.0" }
reed-solomon-simd = "3.1.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
rumqttc = { package = "rumqttc-next", version = "0.33.2" }
redis = { version = "1.4.1" }
rustix = { version = "1.1.4" }
rust-embed = { version = "8.12.0" }
@@ -333,7 +333,7 @@ libunftp = { version = "0.23.0" }
unftp-core = "0.1.0"
suppaftp = { version = "10.0.1" }
rcgen = "0.14.8"
russh = { version = "0.62.3" }
russh = { version = "0.62.2" }
russh-sftp = "2.3.0"
# WebDAV
@@ -370,3 +370,21 @@ inherits = "release"
inherits = "release"
debug = true
strip = "none"
# Pin hyper to a revision that carries the HTTP/1 "flush buffered data before
# shutdown" fix (hyperium/hyper#4018, commit 72046cc7). This lands as a
# `[patch.crates-io]` entry — not on the `hyper` workspace dependency — so that
# every consumer in the tree, including the transitive `hyper-util` server path
# (`conn::auto` / `GracefulShutdown`) that actually drives our connections,
# resolves to the fixed hyper rather than the buggy crates.io copy.
#
# hyper <= 1.10.1 can call `poll_shutdown()` on the socket while response bytes
# are still buffered (a prior `poll_flush()` returned `Poll::Pending` and the
# result was discarded). A backpressured / slow-reading peer then receives a
# graceful FIN before the full Content-Length body is flushed, which standard S3
# clients (minio-go / warp) report as `unexpected EOF` on large-object GET under
# load. The fix is not in any crates.io release yet as of hyper 1.10.1; drop
# this patch once a released version (> 1.10.1) contains commit 72046cc7.
# See rustfs/backlog#1232.
[patch.crates-io]
hyper = { git = "https://github.com/hyperium/hyper.git", rev = "ccc1e850dc0cda3e71b0acd11f60ca3d48d09034" }
-5
View File
@@ -39,11 +39,6 @@ pub const DEFAULT_DRIVE_WALKDIR_TIMEOUT_SECS: u64 = 5;
pub const ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS";
pub const DEFAULT_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: u64 = 5;
/// Maximum time the metacache merge consumer waits for the next visible
/// `walk_dir()` entry from a reader before detaching it from the merge.
pub const ENV_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS";
pub const DEFAULT_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS: u64 = 10;
/// Interval in seconds between active health probes for local and remote drives.
pub const ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: &str = "RUSTFS_DRIVE_ACTIVE_CHECK_INTERVAL_SECS";
pub const DEFAULT_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: u64 = 15;
+1 -1
View File
@@ -57,7 +57,7 @@ http.workspace = true
http-body-util.workspace = true
hyper = { workspace = true, features = ["http2", "http1", "server"] }
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
reqwest = { workspace = true, default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "multipart"] }
rustfs-signer.workspace = true
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
-79
View File
@@ -1,79 +0,0 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use http::header::HOST;
use reqwest::StatusCode;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::sign_v4;
use s3s::Body;
use serde::Deserialize;
use std::error::Error;
#[derive(Debug, Deserialize)]
struct PoolListItem {
id: usize,
cmdline: String,
status: String,
}
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 single_drive_pools_list_succeeds_without_enabling_decommission_status() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let response = signed_admin_get(&env, "/rustfs/admin/v3/pools/list").await?;
let status = response.status();
let body = response.bytes().await?;
assert_eq!(status, StatusCode::OK, "pools list failed: {}", String::from_utf8_lossy(&body));
let pools: Vec<PoolListItem> = serde_json::from_slice(&body)?;
assert_eq!(pools.len(), 1);
assert_eq!(pools[0].id, 0);
assert_eq!(pools[0].cmdline, env.temp_dir);
assert_eq!(pools[0].status, "active");
let response = signed_admin_get(&env, "/rustfs/admin/v3/decommission/status").await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(
status,
StatusCode::NOT_IMPLEMENTED,
"decommission status changed for a single pool: {body}"
);
assert!(body.contains("NotImplemented"), "unexpected decommission error body: {body}");
Ok(())
}
-3
View File
@@ -242,9 +242,6 @@ mod console_smoke_test;
#[cfg(test)]
mod admin_iam_crud_test;
#[cfg(test)]
mod admin_pools_test;
// Replication extension end-to-end regression tests
#[cfg(test)]
mod replication_extension_test;
@@ -43,12 +43,6 @@ use s3s::Body;
use serde_json::Value;
use serial_test::serial;
use std::error::Error;
use std::path::Path;
use std::sync::{
Arc, Once,
atomic::{AtomicBool, Ordering},
};
use std::thread;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
@@ -156,83 +150,6 @@ async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Valu
Ok((format!("http://{endpoint_ip}.nip.io:{port}/events"), rx, handle))
}
fn spawn_https_event_collector(ca_path: &Path) -> Result<(String, Arc<AtomicBool>, thread::JoinHandle<()>), BoxError> {
use rustls::{
ServerConfig,
pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},
};
use std::io::ErrorKind;
use std::net::TcpListener as StdTcpListener;
static INSTALL_CRYPTO_PROVIDER: Once = Once::new();
INSTALL_CRYPTO_PROVIDER.call_once(|| {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
});
let listener = StdTcpListener::bind("0.0.0.0:0")?;
listener.set_nonblocking(true)?;
let addr = listener.local_addr()?;
let endpoint_ip = local_ip()?;
let endpoint_host = format!("{endpoint_ip}.nip.io");
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec![endpoint_host.clone()])?;
std::fs::write(ca_path, cert.pem())?;
let cert_chain = vec![cert.der().clone()];
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
let server_config = Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key_der)?,
);
let running = Arc::new(AtomicBool::new(true));
let server_running = Arc::clone(&running);
let handle = thread::spawn(move || {
while server_running.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let config = Arc::clone(&server_config);
handle_https_probe(stream, config);
}
Err(err) if err.kind() == ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(20)),
Err(_) => break,
}
}
});
Ok((format!("https://{endpoint_host}:{}/events", addr.port()), running, handle))
}
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;
};
let mut tls_stream = rustls::StreamOwned::new(connection, stream);
let mut buf = [0u8; 1024];
if tls_stream.read(&mut buf).is_err() {
return;
}
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();
}
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));
}
handle.join().map_err(|_| "https event collector thread panicked")?;
Ok(())
}
/// Decoded object key of the first record in an event envelope.
fn event_key(envelope: &Value) -> Option<String> {
let raw = envelope["Records"][0]["s3"]["object"]["key"].as_str()?;
@@ -367,24 +284,13 @@ async fn enable_notify_module(env: &RustFSTestEnvironment) -> TestResult {
/// Registers a webhook notification target with a persistent queue directory, so
/// delivery goes through the durable store-and-forward path.
async fn configure_webhook_target(env: &RustFSTestEnvironment, target_name: &str, endpoint: &str) -> TestResult {
configure_webhook_target_with_key_values(env, target_name, vec![("endpoint", endpoint.to_string())]).await
}
async fn configure_webhook_target_with_key_values(
env: &RustFSTestEnvironment,
target_name: &str,
mut key_values: Vec<(&str, String)>,
) -> TestResult {
let queue_dir = format!("{}/notify-queue-{target_name}", env.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
if !key_values.iter().any(|(key, _)| *key == "queue_dir") {
key_values.push(("queue_dir", queue_dir));
}
let payload = serde_json::json!({
"key_values": key_values
.into_iter()
.map(|(key, value)| serde_json::json!({ "key": key, "value": value }))
.collect::<Vec<_>>(),
"key_values": [
{ "key": "endpoint", "value": endpoint },
{ "key": "queue_dir", "value": queue_dir },
]
});
let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{target_name}", env.url);
let response = signed_admin_request(env, http::Method::PUT, &url, Some(payload.to_string().into_bytes())).await?;
@@ -415,28 +321,6 @@ 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 {
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?)?;
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()
})
});
if listed {
return Ok(());
}
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
Err(format!("target {target_name} was not listed in admin targets").into())
}
/// Binds a bucket to a webhook target for ObjectCreated:*/ObjectRemoved:* events,
/// filtered to `prefix` + `suffix`.
async fn put_notification_config(client: &Client, bucket: &str, target_name: &str, prefix: &str, suffix: &str) -> TestResult {
@@ -483,38 +367,6 @@ fn trimmed_etag(value: Option<&str>) -> Option<String> {
// Tests
// ---------------------------------------------------------------------------
/// 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.
#[tokio::test]
#[serial]
async fn test_https_webhook_target_lists_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 target = "peri1https";
configure_webhook_target_with_key_values(
&env,
target,
vec![
("endpoint", endpoint.clone()),
("client_ca", ca_path.to_string_lossy().into_owned()),
],
)
.await?;
wait_for_target_listed(&env, target).await?;
env.stop_server();
stop_https_event_collector(&endpoint, running, handle)?;
Ok(())
}
/// PUT / multipart-complete / DELETE each deliver one event with correct fields,
/// and the prefix/suffix filter drops non-matching keys.
#[tokio::test]
@@ -1971,10 +1971,7 @@ async fn wait_for_site_replication_enabled(
) -> Result<SiteReplicationInfo, Box<dyn Error + Send + Sync>> {
for _ in 0..40 {
let info = site_replication_info(env).await?;
if info.enabled
&& info.sites.len() == expected_sites
&& info.sites.iter().all(|peer| peer.sync_state == SyncStatus::Enable)
{
if info.enabled && info.sites.len() == expected_sites {
return Ok(info);
}
sleep(Duration::from_millis(250)).await;
+1 -1
View File
@@ -123,7 +123,7 @@ libc.workspace = true
# (backlog#1178); "fs" comes from the workspace default.
rustix = { workspace = true, features = ["process", "fs"] }
rustfs-madmin.workspace = true
reqwest = { workspace = true }
reqwest = { workspace = true, default-features = false, features = ["rustls", "http2", "system-proxy"] }
aes-gcm = { workspace = true, features = ["rand_core"] }
chacha20poly1305.workspace = true
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
+4 -13
View File
@@ -67,8 +67,6 @@ pub mod bucket {
}
pub mod tier_delete_journal {
#[cfg(feature = "test-util")]
pub use crate::bucket::lifecycle::tier_delete_journal::recover_tier_delete_journal_entries;
pub use crate::bucket::lifecycle::tier_delete_journal::{
persist_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
};
@@ -426,7 +424,7 @@ pub mod tier {
pub mod tier_config {
pub use crate::services::tier::tier_config::{
ServicePrincipalAuth, TierAliyun, TierAzure, TierConfig, TierGCS, TierHuaweicloud, TierMinIO, TierR2, TierRustFS,
TierS3, TierTencent, TierType, TierWasabi,
TierS3, TierTencent, TierType,
};
}
@@ -437,13 +435,6 @@ pub mod tier {
};
}
pub mod tier_mutation_peer {
pub use crate::services::tier::tier_mutation_peer::{
MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE, TierMutationPeerError, TierMutationPeerOutcome, TierMutationPeerResult,
TierMutationPeerState, handle_tier_mutation_peer_request,
};
}
pub mod warm_backend {
pub use crate::services::tier::warm_backend::{
WarmBackend, WarmBackendGetOpts, WarmBackendImpl, build_transition_put_options, check_warm_backend, new_warm_backend,
@@ -453,9 +444,9 @@ pub mod tier {
#[cfg(feature = "test-util")]
pub mod test_util {
pub use crate::services::tier::test_util::{
FaultConfig, MockStoredObject, MockWarmBackend, MockWarmOp, TransitionCleanupStoreBarrier, TransitionMeta,
assert_transition_meta_consistent, free_version_count, read_transition_meta, register_mock_tier,
register_mock_tier_backend, wait_for_free_version_absence,
FaultConfig, MockStoredObject, MockWarmBackend, MockWarmOp, TransitionMeta, assert_transition_meta_consistent,
free_version_count, read_transition_meta, register_mock_tier, register_mock_tier_backend,
wait_for_free_version_absence,
};
}
}
File diff suppressed because it is too large Load Diff
@@ -28,4 +28,3 @@ pub mod tier_delete_journal;
pub mod tier_free_version_recovery;
pub mod tier_last_day_stats;
pub mod tier_sweeper;
pub mod transition_transaction;
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::{future::Future, sync::Arc, time::Duration};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -38,11 +38,8 @@ const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
const EVENT_LIFECYCLE_TIER_DELETE_JOURNAL: &str = "lifecycle_tier_delete_journal";
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 1_000;
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;
pub(crate) const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
const TIER_DELETE_JOURNAL_PREFIX: &str = "ilm/tier-delete-journal/";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
@@ -53,16 +50,12 @@ struct PersistedTierDeleteJournalEntry {
tier_name: String,
#[serde(default)]
backend_identity: Option<[u8; 32]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
version_id_exact: Option<bool>,
}
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() {
version: if je.backend_identity.is_some() {
TIER_DELETE_JOURNAL_VERSION
} else {
1
@@ -71,7 +64,6 @@ impl PersistedTierDeleteJournalEntry {
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),
}
}
@@ -84,32 +76,12 @@ 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) {
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),
TIER_DELETE_JOURNAL_VERSION => (
Some(
self.backend_identity
.ok_or_else(|| Error::other("tier delete journal v2 entry is missing its backend identity"))?,
),
false,
let backend_identity = match self.version {
1 => None,
TIER_DELETE_JOURNAL_VERSION => Some(
self.backend_identity
.ok_or_else(|| Error::other("tier delete journal v2 entry is missing its backend identity"))?,
),
TIER_DELETE_JOURNAL_EXACT_VERSION => {
if self.version_id.is_empty() || self.version_id_exact != Some(true) {
return Err(Error::other("tier delete journal v3 entry is missing its exact version constraint"));
}
(
Some(
self.backend_identity
.ok_or_else(|| Error::other("tier delete journal v3 entry is missing its backend identity"))?,
),
true,
)
}
version => return Err(Error::other(format!("unsupported tier delete journal version {version}"))),
};
Ok(Jentry {
@@ -117,7 +89,6 @@ impl PersistedTierDeleteJournalEntry {
version_id: self.version_id,
tier_name: self.tier_name,
backend_identity,
version_id_exact,
})
}
}
@@ -142,10 +113,6 @@ pub(crate) fn tier_delete_journal_object_name(je: &Jentry) -> String {
hasher.update([0]);
hasher.update(backend_identity);
}
if je.version_id_exact {
hasher.update([0]);
hasher.update(b"exact-version-id");
}
format!(
"{TIER_DELETE_JOURNAL_PREFIX}{}.json",
rustfs_utils::crypto::hex(hasher.finalize().as_slice())
@@ -218,7 +185,6 @@ pub async fn process_tier_delete_journal_entry(api: Arc<ECStore>, je: &Jentry) -
&je.tier_name,
backend_identity,
&api.tier_config_mgr(),
je.version_id_exact,
)
.await?;
remove_tier_delete_journal_entry(api, je).await
@@ -328,33 +294,16 @@ pub async fn recover_tier_delete_journal_entries(
}
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
let mut marker: Option<String> = None;
loop {
#[cfg(test)]
tokio::select! {
biased;
_ = cancel_token.cancelled() => return,
_ = interval.tick() => {},
_ = api.ctx.wait_for_tier_delete_journal_recovery() => {},
}
#[cfg(not(test))]
tokio::select! {
biased;
_ = cancel_token.cancelled() => return,
_ = interval.tick() => {},
_ = interval.tick() => {}
}
let recovery =
recover_tier_delete_journal_entries(api.clone(), DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT, marker.clone());
let Some(result) =
await_tier_delete_journal_recovery(&cancel_token, TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT, recovery).await
else {
return;
};
match result {
match recover_tier_delete_journal_entries(api.clone(), DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT, marker.clone()).await {
Ok(stats) => {
marker = stats.next_marker;
debug!(
@@ -383,36 +332,13 @@ pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_tok
}
}
async fn await_tier_delete_journal_recovery<T, F>(
cancel_token: &CancellationToken,
timeout: Duration,
recovery: F,
) -> Option<Result<T>>
where
F: Future<Output = Result<T>>,
{
tokio::select! {
_ = cancel_token.cancelled() => None,
result = tokio::time::timeout(timeout, recovery) => Some(match result {
Ok(result) => result,
Err(_) => Err(Error::other(format!(
"tier delete journal recovery timed out after {} seconds",
timeout.as_secs()
))),
}),
}
}
#[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,
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;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
fn journal_entry() -> Jentry {
Jentry {
@@ -420,7 +346,6 @@ mod tests {
version_id: "remote-version".to_string(),
tier_name: "WARM".to_string(),
backend_identity: Some([7; 32]),
version_id_exact: false,
}
}
@@ -435,82 +360,6 @@ mod tests {
assert_eq!(decoded.version_id, je.version_id);
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);
}
#[test]
fn tier_delete_journal_roundtrips_exact_put_response_constraint() {
let mut exact = journal_entry();
exact.version_id = uuid::Uuid::nil().to_string();
exact.version_id_exact = true;
let mut normalized = exact.clone();
normalized.version_id_exact = false;
let encoded = encode_tier_delete_journal_entry(&exact).expect("exact journal entry should encode");
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_id_exact"], true);
assert!(decoded.version_id_exact);
assert_ne!(tier_delete_journal_object_name(&exact), tier_delete_journal_object_name(&normalized));
}
#[test]
fn tier_delete_journal_rejects_invalid_exact_version_constraints() {
let identity = vec![7_u8; 32];
let invalid = [
serde_json::json!({
"version": 1,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"version_id_exact": true,
}),
serde_json::json!({
"version": 2,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"backend_identity": identity,
"version_id_exact": true,
}),
serde_json::json!({
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
"obj_name": "remote/object",
"version_id": "",
"tier_name": "WARM",
"backend_identity": identity,
"version_id_exact": true,
}),
serde_json::json!({
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"backend_identity": identity,
}),
serde_json::json!({
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"backend_identity": identity,
"version_id_exact": false,
}),
serde_json::json!({
"version": TIER_DELETE_JOURNAL_EXACT_VERSION,
"obj_name": "remote/object",
"version_id": "exact-version",
"tier_name": "WARM",
"version_id_exact": true,
}),
];
for persisted in invalid {
let encoded = serde_json::to_vec(&persisted).expect("invalid journal fixture should encode");
decode_tier_delete_journal_entry(&encoded).expect_err("invalid exact journal constraint must fail closed");
}
}
#[test]
@@ -627,29 +476,4 @@ mod tests {
assert!(err.to_string().contains("decode tier delete journal failed"));
}
#[tokio::test]
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
let result = await_tier_delete_journal_recovery(
&CancellationToken::new(),
Duration::from_millis(10),
std::future::pending::<Result<()>>(),
)
.await
.expect("an elapsed timeout should return a recovery error")
.expect_err("a permanently pending recovery must time out");
assert!(result.to_string().contains("recovery timed out"), "{result}");
}
#[tokio::test]
async fn tier_delete_journal_recovery_drops_in_flight_work_on_shutdown() {
let cancel = CancellationToken::new();
cancel.cancel();
let result =
await_tier_delete_journal_recovery(&cancel, Duration::from_secs(30), std::future::pending::<Result<()>>()).await;
assert!(result.is_none(), "shutdown must cancel the in-flight recovery future");
}
}
@@ -13,14 +13,11 @@
// limitations under the License.
use std::sync::Arc;
#[cfg(test)]
use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::Result;
use crate::object_api::ObjectInfo;
@@ -34,78 +31,10 @@ 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)]
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, crate::error::Error>;
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
#[cfg(test)]
pub(super) enum RecoveryWalkTestAction {
SendItemsThenError(Vec<ObjectInfo>, crate::error::Error),
SendItemsThenHang(Vec<ObjectInfo>, Arc<tokio::sync::Notify>),
SendItemsUntilReceiverCloses(Arc<tokio::sync::Notify>),
ReturnError(crate::error::Error),
WaitForCancellation(Arc<tokio::sync::Notify>),
}
#[cfg(test)]
type RecoveryWalkTestHook = Box<dyn Fn(&str) -> Option<RecoveryWalkTestAction> + Send + Sync>;
#[cfg(test)]
static RECOVERY_WALK_TEST_HOOK: Mutex<Option<RecoveryWalkTestHook>> = Mutex::new(None);
#[cfg(test)]
static RECOVERY_BUCKET_LIST_WAIT_HOOK: Mutex<Option<Arc<tokio::sync::Notify>>> = Mutex::new(None);
#[cfg(test)]
pub(super) struct RecoveryWalkHookGuard;
#[cfg(test)]
impl Drop for RecoveryWalkHookGuard {
fn drop(&mut self) {
let mut hook = RECOVERY_WALK_TEST_HOOK
.lock()
.expect("recovery walk test hook lock should not poison");
*hook = None;
}
}
#[cfg(test)]
pub(super) fn set_recovery_walk_test_hook(
hook_fn: impl Fn(&str) -> Option<RecoveryWalkTestAction> + Send + Sync + 'static,
) -> RecoveryWalkHookGuard {
let mut hook = RECOVERY_WALK_TEST_HOOK
.lock()
.expect("recovery walk test hook lock should not poison");
*hook = Some(Box::new(hook_fn));
RecoveryWalkHookGuard
}
#[cfg(test)]
pub(super) struct RecoveryBucketListWaitHookGuard;
#[cfg(test)]
impl Drop for RecoveryBucketListWaitHookGuard {
fn drop(&mut self) {
let mut hook = RECOVERY_BUCKET_LIST_WAIT_HOOK
.lock()
.expect("recovery bucket-list test hook lock should not poison");
*hook = None;
}
}
#[cfg(test)]
pub(super) fn set_recovery_bucket_list_wait_hook(started: Arc<tokio::sync::Notify>) -> RecoveryBucketListWaitHookGuard {
let mut hook = RECOVERY_BUCKET_LIST_WAIT_HOOK
.lock()
.expect("recovery bucket-list test hook lock should not poison");
*hook = Some(started);
RecoveryBucketListWaitHookGuard
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FreeVersionRecoveryStats {
pub scanned: usize,
@@ -139,22 +68,12 @@ pub async fn recover_tier_free_versions(
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
) -> Result<FreeVersionRecoveryStats> {
recover_tier_free_versions_with_cancel(api, limit, bucket_marker, object_marker, CancellationToken::new()).await
}
pub(super) async fn recover_tier_free_versions_with_cancel(
api: Arc<ECStore>,
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
) -> Result<FreeVersionRecoveryStats> {
if limit == 0 {
return Err(std::io::Error::other("free-version recovery limit must be greater than zero").into());
}
let page = list_tier_free_versions(api, limit, bucket_marker.clone(), object_marker.clone(), cancel_token.clone()).await?;
let page = list_tier_free_versions(api, limit, bucket_marker.clone(), object_marker.clone()).await?;
let mut stats = FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
@@ -168,11 +87,8 @@ pub(super) async fn recover_tier_free_versions_with_cancel(
let mut retry_cursor = RetryCursor::new(bucket_marker, object_marker);
for oi in page.items {
if cancel_token.is_cancelled() {
return Err(tier_free_version_recovery_cancelled());
}
retry_cursor.visit(&oi);
if !record_recovered_free_version_enqueue(&mut stats, enqueue_recovered_free_version(oi).await) {
if !record_recovered_free_version_enqueue(&mut stats, queue_recovered_free_version(oi).await) {
let (bucket_marker, object_marker) = retry_cursor.retry_markers();
stats.truncated = true;
stats.next_bucket_marker = bucket_marker;
@@ -184,18 +100,6 @@ pub(super) async fn recover_tier_free_versions_with_cancel(
Ok(stats)
}
fn tier_free_version_recovery_cancelled() -> crate::error::Error {
std::io::Error::new(std::io::ErrorKind::Interrupted, "tier free-version recovery cancelled").into()
}
fn tier_free_version_recovery_walk_shutdown_timed_out() -> crate::error::Error {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"tier free-version recovery walk did not stop after cancellation",
)
.into()
}
fn record_recovered_free_version_enqueue(stats: &mut FreeVersionRecoveryStats, queued: bool) -> bool {
stats.scanned += 1;
if queued {
@@ -207,6 +111,10 @@ fn record_recovered_free_version_enqueue(stats: &mut FreeVersionRecoveryStats, q
}
}
async fn queue_recovered_free_version(oi: ObjectInfo) -> bool {
crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version(oi).await
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RetryCursor {
input_bucket_marker: Option<String>,
@@ -252,12 +160,11 @@ impl RetryCursor {
}
}
pub(super) async fn list_tier_free_versions(
async fn list_tier_free_versions(
api: Arc<ECStore>,
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
) -> Result<FreeVersionRecoveryPage> {
let mut page = FreeVersionRecoveryPage {
items: Vec::new(),
@@ -272,42 +179,21 @@ pub(super) async fn list_tier_free_versions(
return Ok(page);
}
let bucket_options = BucketOptions::default();
let list_buckets = async {
#[cfg(test)]
let wait_hook = RECOVERY_BUCKET_LIST_WAIT_HOOK
.lock()
.expect("recovery bucket-list test hook lock should not poison")
.clone();
#[cfg(test)]
if let Some(started) = wait_hook {
started.notify_one();
std::future::pending::<()>().await;
}
api.list_bucket(&bucket_options).await
};
tokio::pin!(list_buckets);
let buckets = tokio::select! {
biased;
_ = cancel_token.cancelled() => return Err(tier_free_version_recovery_cancelled()),
result = &mut list_buckets => result?,
};
let buckets = api.list_bucket(&BucketOptions::default()).await?;
let mut bucket_seen = bucket_marker.is_none();
let mut truncated_after: Option<RecoveryCursor> = None;
let walk_scan_limit = recovery_walk_scan_limit(limit);
for bucket in buckets {
if cancel_token.is_cancelled() {
return Err(tier_free_version_recovery_cancelled());
}
if bucket.name == RUSTFS_META_BUCKET {
continue;
}
if !bucket_seen {
if bucket_marker.as_deref().is_some_and(|marker| bucket.name.as_str() < marker) {
if bucket_marker.as_deref() == Some(bucket.name.as_str()) {
bucket_seen = true;
} else {
continue;
}
bucket_seen = true;
}
page.buckets_scanned += 1;
@@ -318,94 +204,16 @@ pub(super) async fn list_tier_free_versions(
};
let (tx, mut rx) = mpsc::channel::<ObjectInfoOrErr>(100);
let cancel = cancel_token.child_token();
let cancel = CancellationToken::new();
let mut draining_after_truncation = false;
let mut drain_deadline = None;
let mut last_seen_object: Option<String> = None;
let mut scanned_objects = 0usize;
let mut walk = tokio::spawn({
let walk = tokio::spawn({
let api = api.clone();
let bucket_name = bucket.name.clone();
let object_marker = bucket_object_marker.clone();
let cancel = cancel.clone();
async move {
#[cfg(test)]
let test_action = {
let hook = RECOVERY_WALK_TEST_HOOK
.lock()
.expect("recovery walk test hook lock should not poison");
hook.as_ref().and_then(|hook| hook(&bucket_name))
};
#[cfg(test)]
if let Some(action) = test_action {
match action {
RecoveryWalkTestAction::SendItemsThenError(items, err) => {
for item in items {
if tx
.send(ObjectInfoOrErr {
item: Some(item),
err: None,
})
.await
.is_err()
{
return Ok(());
}
}
let _ = tx
.send(ObjectInfoOrErr {
item: None,
err: Some(err),
})
.await;
return Ok(());
}
RecoveryWalkTestAction::SendItemsThenHang(items, started) => {
for item in items {
if tx
.send(ObjectInfoOrErr {
item: Some(item),
err: None,
})
.await
.is_err()
{
return Ok(());
}
}
started.notify_one();
return std::future::pending().await;
}
RecoveryWalkTestAction::SendItemsUntilReceiverCloses(started) => {
started.notify_one();
let mut index = 0usize;
loop {
if tx
.send(ObjectInfoOrErr {
item: Some(ObjectInfo {
bucket: bucket_name.clone(),
name: format!("nonrecoverable-{index:08}"),
..Default::default()
}),
err: None,
})
.await
.is_err()
{
return Ok(());
}
index = index.saturating_add(1);
}
}
RecoveryWalkTestAction::ReturnError(err) => return Err(err),
RecoveryWalkTestAction::WaitForCancellation(started) => {
started.notify_one();
cancel.cancelled().await;
return Err(tier_free_version_recovery_cancelled());
}
}
}
api.walk(
cancel,
&bucket_name,
@@ -423,88 +231,47 @@ pub(super) async fn list_tier_free_versions(
}
});
let mut receive_error = None;
loop {
let item = tokio::select! {
biased;
_ = cancel_token.cancelled() => {
cancel.cancel();
receive_error = Some(tier_free_version_recovery_cancelled());
break;
}
_ = async {
if let Some(deadline) = drain_deadline {
tokio::time::sleep_until(deadline).await;
} else {
std::future::pending::<()>().await;
}
}, if drain_deadline.is_some() => {
receive_error = Some(tier_free_version_recovery_walk_shutdown_timed_out());
break;
}
item = rx.recv() => match item {
Some(item) => item,
None => break,
},
};
while let Some(item) = rx.recv().await {
page.scanned_entries += 1;
if let Some(err) = item.err {
cancel.cancel();
receive_error = Some(err);
break;
}
if draining_after_truncation {
continue;
}
if let Some(err) = item.err {
cancel.cancel();
walk.await.map_err(|err| std::io::Error::other(err.to_string()))??;
return Err(err);
}
let Some(oi) = item.item else {
continue;
};
record_scanned_object(&mut last_seen_object, &mut scanned_objects, &oi.name);
if let Some(cursor) = &truncated_after
&& (cursor.bucket.as_str() != bucket.name.as_str() || cursor.object.as_str() != oi.name.as_str())
&& cursor.object != oi.name
{
page.truncated = true;
cancel.cancel();
draining_after_truncation = true;
drain_deadline = Some(tokio::time::Instant::now() + BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
continue;
}
if is_recoverable_tier_free_version(&oi) {
let current_cursor = RecoveryCursor {
let cursor = RecoveryCursor {
bucket: bucket.name.clone(),
object: oi.name.clone(),
};
page.items.push(oi);
page.next_bucket_marker = Some(current_cursor.bucket.clone());
page.next_object_marker = Some(current_cursor.object.clone());
page.next_bucket_marker = Some(cursor.bucket.clone());
page.next_object_marker = Some(cursor.object.clone());
if page.items.len() >= limit && truncated_after.is_none() {
truncated_after = Some(current_cursor);
truncated_after = Some(cursor);
}
}
}
drop(rx);
let walk_shutdown_timeout = drain_deadline
.map(|deadline| deadline.saturating_duration_since(tokio::time::Instant::now()))
.unwrap_or(BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
let walk_result = match tokio::time::timeout(walk_shutdown_timeout, &mut walk).await {
Ok(result) => result.map_err(|err| std::io::Error::other(err.to_string()))?,
Err(_) => {
walk.abort();
let _ = walk.await;
if let Some(err) = receive_error {
return Err(err);
}
return Err(tier_free_version_recovery_walk_shutdown_timed_out());
}
};
if let Some(err) = receive_error {
return Err(err);
}
walk_result?;
walk.await.map_err(|err| std::io::Error::other(err.to_string()))??;
mark_scan_truncated_if_needed(&mut page, scanned_objects, walk_scan_limit, &bucket.name, last_seen_object.as_deref());
if page.truncated {
page.next_bucket_marker = Some(bucket.name.clone());
break;
}
}
@@ -249,7 +249,6 @@ impl ObjSweeper {
version_id: self.transition_version_id.clone(),
tier_name: self.transition_tier.clone(),
backend_identity: None,
version_id_exact: false,
});
}
None
@@ -285,7 +284,6 @@ pub struct Jentry {
pub(crate) version_id: String,
pub(crate) tier_name: String,
pub(crate) backend_identity: Option<TierDestinationId>,
pub(crate) version_id_exact: bool,
}
impl ExpiryOp for Jentry {
@@ -330,14 +328,13 @@ 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).await
}
async fn delete_object_from_remote_tier_raw_with_lease(
obj_name: &str,
rv_id: &str,
lease: &TierOperationLease,
version_id_exact: bool,
) -> Result<(), std::io::Error> {
if remote_delete_breaker_is_open(Instant::now()).await {
metrics::counter!(METRIC_DELETE_REMOTE_BREAKER_TOTAL).increment(1);
@@ -350,11 +347,7 @@ async fn delete_object_from_remote_tier_raw_with_lease(
.map_err(|_| std::io::Error::other(ERR_REMOTE_DELETE_LIMITER_CLOSED))?;
let _inflight = RemoteDeleteInflightGuard::new();
if version_id_exact {
lease.remove_exact(obj_name, rv_id).await
} else {
lease.remove(obj_name, rv_id).await
}
lease.remove(obj_name, rv_id).await
}
#[cfg(test)]
@@ -366,30 +359,6 @@ fn run_remote_tier_delete_test_hook(obj_name: &str, rv_id: &str, tier_name: &str
.map(|hook| hook(obj_name, rv_id, tier_name))
}
#[cfg(test)]
pub(super) struct RemoteTierDeleteHookGuard;
#[cfg(test)]
impl Drop for RemoteTierDeleteHookGuard {
fn drop(&mut self) {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = None;
}
}
#[cfg(test)]
pub(super) fn set_remote_tier_delete_test_hook(
hook_fn: impl Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync + 'static,
) -> RemoteTierDeleteHookGuard {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = Some(Box::new(hook_fn));
RemoteTierDeleteHookGuard
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteTierDeleteOutcome {
Deleted,
@@ -419,21 +388,19 @@ pub(crate) async fn delete_object_from_remote_tier_idempotent_with_manager_and_i
tier_name: &str,
backend_identity: TierDestinationId,
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
version_id_exact: bool,
) -> 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_object_from_remote_tier_with_lease_idempotent(obj_name, rv_id, &lease, version_id_exact).await
delete_object_from_remote_tier_with_lease_idempotent(obj_name, rv_id, &lease).await
}
pub(crate) async fn delete_object_from_remote_tier_with_lease_idempotent(
obj_name: &str,
rv_id: &str,
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 {
match delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, lease).await {
Ok(()) => Ok(RemoteTierDeleteOutcome::Deleted),
Err(err) if is_remote_tier_not_found_error(&err) => Ok(RemoteTierDeleteOutcome::AlreadyRemoved),
Err(err) => {
@@ -483,7 +450,6 @@ 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,
})
}
@@ -492,14 +458,35 @@ 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,
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,
ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, REMOTE_TIER_DELETE_TEST_HOOK, RemoteDeleteBreaker,
RemoteTierDeleteOutcome, 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, should_record_remote_delete_failure,
};
use std::io::{Error, ErrorKind};
use std::time::{Duration, Instant};
struct RemoteTierDeleteHookGuard;
impl Drop for RemoteTierDeleteHookGuard {
fn drop(&mut self) {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = None;
}
}
fn set_remote_tier_delete_test_hook(
hook_fn: impl Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync + 'static,
) -> RemoteTierDeleteHookGuard {
let mut hook = REMOTE_TIER_DELETE_TEST_HOOK
.lock()
.expect("remote tier delete test hook lock should not poison");
*hook = Some(Box::new(hook_fn));
RemoteTierDeleteHookGuard
}
#[test]
fn signer_header_error_detection_matches_utf8_failures() {
let err = Error::new(
@@ -587,7 +574,6 @@ mod test {
"WARM",
mismatched,
&manager,
false,
)
.await
.expect_err("journal recovery must fail closed when the tier name was rebound");
@@ -595,33 +581,6 @@ mod test {
assert!(err.to_string().contains("identity no longer matches"));
}
#[cfg(feature = "test-util")]
#[tokio::test]
async fn journal_delete_dispatches_an_exact_version_constraint() {
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);
let outcome = delete_object_from_remote_tier_idempotent_with_manager_and_identity(
"remote/object",
"exact-version",
"WARM",
identity,
&manager,
true,
)
.await
.expect("an exact journal delete should reach the backend");
assert_eq!(outcome, RemoteTierDeleteOutcome::Deleted);
assert_eq!(backend.exact_remove_count(), 1);
assert_eq!(backend.remove_count().await, 1);
}
#[test]
fn breaker_opens_at_threshold_and_recovers_after_window() {
let mut breaker = RemoteDeleteBreaker::new(3, Duration::from_secs(30));
File diff suppressed because it is too large Load Diff
@@ -412,10 +412,6 @@ impl BucketMetadataSys {
}
}
pub(crate) fn object_store(&self) -> Arc<ECStore> {
self.api.clone()
}
pub async fn init(&mut self, buckets: Vec<String>) {
let _ = self.init_internal(buckets).await;
}
+37 -86
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::disk::disk_store::{get_drive_walkdir_peek_timeout, get_drive_walkdir_stall_timeout};
use crate::disk::disk_store::get_drive_walkdir_stall_timeout;
use crate::disk::error::DiskError;
use crate::disk::{self, DiskAPI, DiskStore, WalkDirOptions};
use metrics::counter;
@@ -175,10 +175,6 @@ pub(crate) enum TestReaderBehavior {
ProducerError(DiskError),
PrimaryErrorThenFallback(DiskError),
PartialThenTimeout(Vec<MetaCacheEntry>),
DelayedEntries {
delay: Duration,
entries: Vec<MetaCacheEntry>,
},
}
#[cfg(test)]
@@ -343,13 +339,6 @@ async fn list_path_raw_inner(
drop(out);
Some(err)
}
TestReaderBehavior::DelayedEntries { delay, entries } => {
tokio::time::sleep(delay).await;
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
out.write(&entries).await.expect("delayed test entries should be written");
out.close().await.expect("delayed test entries should close");
return Ok(());
}
}
} else {
None
@@ -484,17 +473,6 @@ async fn list_path_raw_inner(
record_producer_error(&producer_errs_clone, disk_idx, &err);
return Err(err);
}
TestReaderBehavior::DelayedEntries { delay, entries } => {
tokio::time::sleep(delay).await;
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
out.write(&entries)
.await
.expect("delayed test fallback entries should be written");
out.close().await.expect("delayed test fallback entries should close");
need_fallback = false;
last_err = None;
continue;
}
}
}
@@ -631,23 +609,47 @@ async fn list_path_raw_inner(
// Consumer-side peek timeout, and a caveat worth understanding
// (rustfs/backlog#1217).
//
// This budget must not be stricter than the producer-side walk stall
// budget. The two measure different things:
// This budget is the SAME SOURCE and SAME VALUE as the producer-side
// walk stall budget: both default to `walkdir_stall_timeout` and fall
// back to `get_drive_walkdir_stall_timeout()` (5s). But the two measure
// different things:
// * producer stall: bounds a single drive READ inside the walk (see
// `with_walk_stall_deadline` in `disk/local.rs`);
// * this consumer peek: bounds the interval between two ADJACENT
// entries arriving from a drive's reader (`peek_with_timeout`).
//
// A drive can spend one stall budget walking a dense non-listable region
// before it can publish the next visible entry. Use an independent,
// profile-aware consumer budget so the merge does not detach that reader
// before the producer itself would have failed.
let producer_stall_timeout = opts.walkdir_stall_timeout.unwrap_or_else(get_drive_walkdir_stall_timeout);
let configured_peek_timeout = get_drive_walkdir_peek_timeout().max(producer_stall_timeout);
#[cfg(not(test))]
let peek_timeout = configured_peek_timeout;
#[cfg(test)]
let peek_timeout = opts.peek_timeout.unwrap_or(configured_peek_timeout);
// Because they are coupled to the same value, the consumer cannot wait
// meaningfully longer for the next entry than the producer is allowed to
// spend producing one. When a drive walks a region dense with
// non-listable internal items (many entries the producer filters out
// before emitting the next visible one), a HEALTHY drive can take longer
// than one budget to hand the consumer its next entry. The consumer then
// classifies it as `PeekOutcome::TimedOut` and DETACHES that reader (it
// is replaced by a drained duplex below), dropping a good drive from the
// merge. That caps the "large prefix always succeeds" guarantee: a wide
// enough non-listable stretch can knock healthy drives out of quorum.
//
// This is left documented, not decoupled. Giving the consumer peek an
// independent, strictly-larger budget would reduce these false detaches,
// but it also delays detaching a genuinely dead drive by the same amount
// and shifts listing tail-latency semantics; that trade-off wants soak
// data before it changes the default, so it is deferred to a follow-up.
// The constraint for any such change: the consumer peek must be >= the
// producer stall (never stricter), so it can never declare a drive
// stalled before the producer itself would have failed.
let peek_timeout = opts
.walkdir_stall_timeout
.or({
#[cfg(test)]
{
opts.peek_timeout
}
#[cfg(not(test))]
{
None
}
})
.unwrap_or_else(get_drive_walkdir_stall_timeout);
let mut errs: Vec<Option<DiskError>> = Vec::with_capacity(readers.len());
for _ in 0..readers.len() {
errs.push(None);
@@ -1244,57 +1246,6 @@ mod tests {
assert_eq!(err, DiskError::Timeout);
}
#[tokio::test]
async fn list_path_raw_waits_past_producer_stall_for_slow_progressing_reader() {
let entry = MetaCacheEntry {
name: "bucket/visible-object".to_string(),
metadata: vec![1, 2, 3],
cached: None,
reusable: false,
};
let seen = Arc::new(Mutex::new(Vec::new()));
let seen_clone = seen.clone();
timeout(
Duration::from_secs(1),
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None],
min_disks: 2,
walkdir_stall_timeout: Some(Duration::from_millis(20)),
test_reader_behaviors: vec![
TestReaderBehavior::DelayedEntries {
delay: Duration::from_millis(60),
entries: vec![entry.clone()],
},
TestReaderBehavior::Entries(vec![entry]),
],
partial: Some(Box::new(move |entries: MetaCacheEntries, _: &[Option<DiskError>]| {
let seen = seen_clone.clone();
Box::pin(async move {
let mut names = entries.0.iter().flatten().map(|entry| entry.name.as_str());
if let (Some(first), Some(second), None) = (names.next(), names.next(), names.next())
&& first == second
{
seen.lock().expect("seen mutex poisoned").push(first.to_owned());
}
})
})),
..Default::default()
},
),
)
.await
.expect("slow-progressing reader should complete inside the consumer peek budget")
.expect("slow-progressing reader should not be detached by the producer stall budget");
assert_eq!(
seen.lock().expect("seen mutex poisoned").as_slice(),
&["bucket/visible-object".to_string()]
);
}
#[tokio::test]
async fn list_path_raw_prefers_timeout_for_mixed_errors_beyond_quorum_budget() {
let err = list_path_raw(
@@ -103,7 +103,7 @@ pub fn http_resp_to_error_response(
object_name: &str,
) -> ErrorResponse {
let err_body = String::from_utf8_lossy(&b).to_string();
if h.is_empty() || !(resp_status.is_client_error() || resp_status.is_server_error()) {
if h.is_empty() || resp_status.is_client_error() || resp_status.is_server_error() {
return ErrorResponse {
status_code: resp_status,
code: S3ErrorCode::ResponseInterrupted,
@@ -329,21 +329,4 @@ mod tests {
assert_eq!(value["RequestId"], Value::String("req-xml-123".to_string()));
assert!(value.get("request_id").is_none(), "external error contract must not expose request_id");
}
#[test]
fn parses_s3_error_code_from_client_error_response() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-request-id", "request-id".parse().expect("request ID header should parse"));
let response = http_resp_to_error_response(
StatusCode::NOT_FOUND,
&headers,
b"<Error><Code>NoSuchVersion</Code><Message>remote detail</Message></Error>".to_vec(),
"bucket",
"object",
);
assert_eq!(response.code, S3ErrorCode::NoSuchVersion);
assert_eq!(response.status_code, StatusCode::NOT_FOUND);
}
}
+16 -123
View File
@@ -18,7 +18,7 @@
#![allow(unused_must_use)]
#![allow(clippy::all)]
use http::{HeaderMap, HeaderValue, StatusCode};
use http::{HeaderMap, HeaderValue};
use http_body_util::BodyExt;
use hyper::body::Body;
use hyper::body::Bytes;
@@ -31,62 +31,13 @@ use uuid::Uuid;
use crate::client::{
api_error_response::{ErrorResponse, err_invalid_argument, http_resp_to_error_response},
api_get_options::GetObjectOptions,
transition_api::{
ObjectInfo, ReadCloser, ReaderImpl, RequestMetadata, TransitionClient, collect_response_body, to_object_info_for_provider,
},
transition_api::{ObjectInfo, ReadCloser, ReaderImpl, RequestMetadata, TransitionClient, to_object_info_for_provider},
};
use s3s::{
dto::{BucketVersioningStatus, MFADelete, VersioningConfiguration},
dto::VersioningConfiguration,
header::{X_AMZ_DELETE_MARKER, X_AMZ_VERSION_ID},
};
const S3_XML_NAMESPACE: &str = "http://s3.amazonaws.com/doc/2006-03-01/";
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StrictVersioningConfiguration {
#[serde(rename = "@xmlns")]
namespace: Option<String>,
#[serde(rename = "MfaDelete")]
mfa_delete: Option<String>,
#[serde(rename = "Status")]
status: Option<String>,
}
#[derive(serde::Deserialize)]
enum StrictVersioningResponse {
VersioningConfiguration(StrictVersioningConfiguration),
}
fn parse_bucket_versioning_response(
status: StatusCode,
headers: &HeaderMap,
body: Vec<u8>,
bucket_name: &str,
) -> Result<VersioningConfiguration, std::io::Error> {
if status != StatusCode::OK {
return Err(std::io::Error::other(http_resp_to_error_response(status, headers, body, bucket_name, "")));
}
let StrictVersioningResponse::VersioningConfiguration(parsed) =
quick_xml::de::from_reader(body.as_slice()).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
if parsed
.namespace
.as_deref()
.is_some_and(|namespace| namespace != S3_XML_NAMESPACE)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"unexpected GetBucketVersioning XML namespace",
));
}
Ok(VersioningConfiguration {
mfa_delete: parsed.mfa_delete.map(MFADelete::from),
status: parsed.status.map(BucketVersioningStatus::from),
..Default::default()
})
}
impl TransitionClient {
pub async fn bucket_exists(&self, bucket_name: &str) -> Result<bool, std::io::Error> {
let resp = self
@@ -172,8 +123,19 @@ impl TransitionClient {
let resp_status = resp.status();
let h = resp.headers().clone();
let body_vec = collect_response_body(resp.into_body(), rustfs_config::MAX_S3_CLIENT_RESPONSE_SIZE).await?;
parse_bucket_versioning_response(resp_status, &h, body_vec, bucket_name)
let mut body_vec = Vec::new();
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
if let Some(data) = frame.data_ref() {
body_vec.extend_from_slice(data);
}
}
let resperr = http_resp_to_error_response(resp_status, &h, body_vec, bucket_name, "");
warn!("get bucket versioning, resperr: {:?}", resperr);
Ok(VersioningConfiguration::default())
}
Err(err) => Err(std::io::Error::other(err)),
@@ -273,72 +235,3 @@ impl TransitionClient {
}
}
}
#[cfg(test)]
mod tests {
use super::parse_bucket_versioning_response;
use http::{HeaderMap, StatusCode};
use s3s::dto::BucketVersioningStatus;
#[test]
fn parses_bucket_versioning_statuses_mfa_delete_and_unversioned_state() {
for (xml, expected) in [
(
br#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>"#
.as_slice(),
Some(BucketVersioningStatus::ENABLED),
),
(
br#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Suspended</Status></VersioningConfiguration>"#
.as_slice(),
Some(BucketVersioningStatus::SUSPENDED),
),
(
br#"<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"/>"#.as_slice(),
None,
),
] {
let config = parse_bucket_versioning_response(StatusCode::OK, &HeaderMap::new(), xml.to_vec(), "tier-bucket")
.expect("valid GetBucketVersioning response should parse");
assert_eq!(config.status.as_ref().map(|status| status.as_str()), expected);
}
let config = parse_bucket_versioning_response(
StatusCode::OK,
&HeaderMap::new(),
br#"<VersioningConfiguration><MfaDelete>Enabled</MfaDelete></VersioningConfiguration>"#.to_vec(),
"tier-bucket",
)
.expect("valid MfaDelete should parse");
assert_eq!(config.mfa_delete.as_ref().map(|status| status.as_str()), Some("Enabled"));
}
#[test]
fn rejects_non_ok_and_malformed_bucket_versioning_responses() {
let valid = br#"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"#.to_vec();
for status in [StatusCode::NO_CONTENT, StatusCode::PARTIAL_CONTENT] {
let status_err = parse_bucket_versioning_response(status, &HeaderMap::new(), valid.clone(), "tier-bucket")
.expect_err("GetBucketVersioning must return exactly HTTP 200");
assert_eq!(status_err.kind(), std::io::ErrorKind::Other);
}
let parse_err = parse_bucket_versioning_response(
StatusCode::OK,
&HeaderMap::new(),
b"<VersioningConfiguration>".to_vec(),
"tier-bucket",
)
.expect_err("malformed GetBucketVersioning XML must fail closed");
assert_eq!(parse_err.kind(), std::io::ErrorKind::InvalidData);
for xml in [
b"<VersioningConfiguration><Statuz>Enabled</Statuz></VersioningConfiguration>".as_slice(),
b"<WrongRoot><Status>Enabled</Status></WrongRoot>".as_slice(),
b"<VersioningConfiguration xmlns=\"https://example.invalid\"/>".as_slice(),
] {
let strict_err = parse_bucket_versioning_response(StatusCode::OK, &HeaderMap::new(), xml.to_vec(), "tier-bucket")
.expect_err("unknown GetBucketVersioning XML must fail closed");
assert_eq!(strict_err.kind(), std::io::ErrorKind::InvalidData);
}
}
}
@@ -60,7 +60,6 @@ impl ProviderVersionCapabilities {
|| tier_type.eq_ignore_ascii_case("rustfs")
|| tier_type.eq_ignore_ascii_case("minio")
|| tier_type.eq_ignore_ascii_case("r2")
|| tier_type.eq_ignore_ascii_case("wasabi")
{
Self {
raw_version_header: Some(X_AMZ_VERSION_ID),
@@ -157,8 +156,6 @@ mod tests {
("MinIO", "x-amz-version-id"),
("r2", "x-amz-version-id"),
("R2", "x-amz-version-id"),
("wasabi", "x-amz-version-id"),
("Wasabi", "x-amz-version-id"),
("aliyun", "x-oss-version-id"),
("Aliyun", "x-oss-version-id"),
("tencent", "x-cos-version-id"),
+50 -83
View File
@@ -42,7 +42,7 @@ use http::{
request::{Builder, Request},
};
use http_body::Body;
use http_body_util::{BodyExt, LengthLimitError, Limited};
use http_body_util::BodyExt;
use hyper::body::Bytes;
use hyper::body::Incoming;
use hyper_rustls::{ConfigBuilderExt, HttpsConnector};
@@ -55,7 +55,9 @@ use rustfs_rio::HashReader;
use rustfs_utils::HashAlgorithm;
use rustfs_utils::{
net::get_endpoint_url,
retry::{DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, MAX_RETRY, RetryTimer},
retry::{
DEFAULT_RETRY_CAP, DEFAULT_RETRY_UNIT, MAX_JITTER, MAX_RETRY, RetryTimer, is_http_status_retryable, is_s3code_retryable,
},
};
use rustls_pki_types::PrivateKeyDer;
use rustls_pki_types::pem::PemObject;
@@ -80,25 +82,9 @@ use url::{Url, form_urlencoded};
use uuid::Uuid;
const C_USER_AGENT: &str = "RustFS (linux; x86)";
pub(crate) const MAX_S3_ERROR_RESPONSE_SIZE: usize = 64 * 1024;
const SUCCESS_STATUS: [StatusCode; 3] = [StatusCode::OK, StatusCode::NO_CONTENT, StatusCode::PARTIAL_CONTENT];
pub(crate) async fn collect_response_body<B>(body: B, limit: usize) -> Result<Vec<u8>, std::io::Error>
where
B: Body<Data = Bytes>,
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
let body = Limited::new(body, limit).collect().await.map_err(|err| {
if err.is::<LengthLimitError>() {
std::io::Error::new(std::io::ErrorKind::InvalidData, "remote tier response body exceeds limit")
} else {
std::io::Error::other(err)
}
})?;
Ok(body.to_bytes().to_vec())
}
const C_UNKNOWN: i32 = -1;
const C_OFFLINE: i32 = 0;
const C_ONLINE: i32 = 1;
@@ -411,17 +397,20 @@ impl TransitionClient {
pub async fn doit(&self, req: Request<s3s::Body>) -> Result<Response<Incoming>, std::io::Error> {
let req_method;
let req_uri;
let req_headers;
let resp;
let http_client = self.http_client.clone();
{
req_method = req.method().clone();
req_uri = req.uri().clone();
req_headers = req.headers().clone();
debug!("endpoint_url: {}", self.endpoint_url.as_str().to_string());
resp = http_client.request(req);
}
let resp = resp.await;
debug!("http_client url: {} {}", req_method, req_uri);
debug!("http_client headers: {:?}", req_headers);
if let Err(err) = resp {
error!("http_client call error: {:?}", err);
return Err(std::io::Error::other(err));
@@ -431,23 +420,28 @@ impl TransitionClient {
Ok(r) => r,
Err(_) => return Err(std::io::Error::other("Unexpected error in response")),
};
debug!(status = %resp.status(), "remote tier response received");
debug!("http_resp: {:?}", resp);
//let b = resp.body_mut().store_all_unlimited().await.unwrap().to_vec();
//debug!("http_resp_body: {}", String::from_utf8(b).unwrap());
//if self.is_trace_enabled && !(self.trace_errors_only && resp.status() == StatusCode::OK) {
if !resp.status().is_success() {
let status = resp.status();
let request_id = resp
.headers()
.get("x-amz-request-id")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
warn!(status = %status, request_id, "remote tier request rejected");
//self.dump_http(&cloned_req, &resp)?;
let mut body_vec = Vec::new();
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
if let Some(data) = frame.data_ref() {
body_vec.extend_from_slice(data);
}
}
let body_str = String::from_utf8_lossy(&body_vec);
warn!("err_body: {}", body_str);
Err(std::io::Error::other(format!("http_client call error: {}", body_str)))
} else {
Ok(resp)
}
Ok(resp)
}
pub async fn execute_method(
@@ -489,22 +483,17 @@ impl TransitionClient {
let resp_status = resp.status();
let h = resp.headers().clone();
let body_vec = collect_response_body(resp.into_body(), MAX_S3_ERROR_RESPONSE_SIZE).await?;
let parsed_error =
http_resp_to_error_response(resp_status, &h, body_vec, &metadata.bucket_name, &metadata.object_name);
let routing_region = parsed_error.region;
let code = match parsed_error.code {
S3ErrorCode::Custom(_) => S3ErrorCode::ResponseInterrupted,
code => code,
};
let err_response = ErrorResponse {
status_code: resp_status,
message: format!("remote tier request failed with status {resp_status}: {}", code.as_str()),
code,
bucket_name: metadata.bucket_name.clone(),
key: metadata.object_name.clone(),
..Default::default()
};
let mut body_vec = Vec::new();
let mut body = resp.into_body();
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
if let Some(data) = frame.data_ref() {
body_vec.extend_from_slice(data);
}
}
let mut err_response =
http_resp_to_error_response(resp_status, &h, body_vec.clone(), &metadata.bucket_name, &metadata.object_name);
err_response.message = format!("remote tier error: {}", err_response.message);
if self.region == "" {
return match err_response.code {
@@ -513,20 +502,20 @@ impl TransitionClient {
Err(std::io::Error::other(err_response))
}
S3ErrorCode::AccessDenied => {
if routing_region.is_empty() {
if err_response.region == "" {
return Err(std::io::Error::other(err_response));
}
if metadata.bucket_name != "" {
if let Ok(mut bucket_loc_cache) = self.bucket_loc_cache.lock() {
if let Some(location) = bucket_loc_cache.get(&metadata.bucket_name) {
if location != routing_region {
bucket_loc_cache.set(&metadata.bucket_name, &routing_region);
if location != err_response.region {
bucket_loc_cache.set(&metadata.bucket_name, &err_response.region);
//continue;
}
}
}
} else if routing_region != metadata.bucket_location {
metadata.bucket_location = routing_region;
} else if err_response.region != metadata.bucket_location {
metadata.bucket_location = err_response.region.clone();
//continue;
}
Err(std::io::Error::other(err_response))
@@ -537,10 +526,18 @@ impl TransitionClient {
};
}
return Err(std::io::Error::other(err_response));
if is_s3code_retryable(err_response.code.as_str()) {
continue;
}
if is_http_status_retryable(&resp_status) {
continue;
}
break;
}
Err(std::io::Error::other("remote tier request did not produce a response"))
Err(std::io::Error::other("resp err"))
}
async fn new_request(
@@ -1411,43 +1408,13 @@ pub struct CreateBucketConfiguration {
#[cfg(test)]
mod tests {
use super::{
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,
SignatureType, build_tls_config, signer_error_to_io_error, to_object_info_for_provider, validate_header_values,
with_rustls_init_guard,
};
use crate::client::provider_versions::ProviderVersionCapabilities;
use http::{HeaderMap, HeaderValue};
use http_body_util::Full;
use hyper::body::Bytes;
use uuid::Uuid;
#[tokio::test]
async fn response_body_limit_rejects_the_first_byte_past_the_boundary() {
let exact = collect_response_body(
Full::new(Bytes::from(vec![0_u8; MAX_S3_CLIENT_RESPONSE_SIZE])),
MAX_S3_CLIENT_RESPONSE_SIZE,
)
.await
.expect("a response exactly at the limit should be accepted");
assert_eq!(exact.len(), MAX_S3_CLIENT_RESPONSE_SIZE);
let err = collect_response_body(
Full::new(Bytes::from(vec![0_u8; MAX_S3_CLIENT_RESPONSE_SIZE + 1])),
MAX_S3_CLIENT_RESPONSE_SIZE,
)
.await
.expect_err("oversized remote response must fail closed");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
let err = collect_response_body(
Full::new(Bytes::from(vec![0_u8; MAX_S3_ERROR_RESPONSE_SIZE + 1])),
MAX_S3_ERROR_RESPONSE_SIZE,
)
.await
.expect_err("oversized S3 error response must use the smaller error limit");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn rustls_guard_converts_panics_to_io_errors() {
let err = with_rustls_init_guard(|| -> Result<(), std::io::Error> { panic!("missing provider") })
+7 -62
View File
@@ -32,9 +32,6 @@ const FAILURE_DOMAIN_NOT_REPORTED: &str = "failure domain labels not reported by
const NUMA_NOT_WIRED: &str = "NUMA topology not wired into runtime";
const PROFILING_NOT_WIRED: &str = "profiling capability not wired into ECStore";
const PEER_HEALTH_NOT_REPORTED: &str = "peer health not reported by endpoints";
const PEER_HEALTH_LOCAL_NODE: &str = "local node does not require peer health probing";
const PEER_HEALTH_REACHABLE: &str = "peer marked reachable by internode health tracker";
const PEER_HEALTH_UNREACHABLE: &str = "peer marked unreachable by internode health tracker";
const CONTROL_RPC_SEPARATED: &str = "control RPC remains on the gRPC control plane";
const DATA_STREAM_RPC_SEPARATED: &str = "remote disk data streams remain on the internode data transport";
@@ -344,24 +341,12 @@ pub fn peer_health_snapshot_from_membership(membership: &ClusterMembershipSnapsh
.map(|node| ClusterPeerHealth {
node_id: node.node_id.clone(),
is_local: node.is_local,
status: peer_health_status_for_node(node),
status: CapabilityStatus::unknown().with_reason(PEER_HEALTH_NOT_REPORTED),
})
.collect(),
}
}
fn peer_health_status_for_node(node: &ClusterNodeMembership) -> CapabilityStatus {
if node.is_local {
return CapabilityStatus::supported().with_reason(PEER_HEALTH_LOCAL_NODE);
}
match rustfs_io_metrics::internode_metrics::cluster_peer_observed_online_status(&node.grid_host) {
Some(true) => CapabilityStatus::supported().with_reason(PEER_HEALTH_REACHABLE),
Some(false) => CapabilityStatus::unknown().with_reason(PEER_HEALTH_UNREACHABLE),
None => CapabilityStatus::disabled().with_reason(PEER_HEALTH_NOT_REPORTED),
}
}
pub fn rpc_boundary_snapshot() -> ClusterRpcBoundarySnapshot {
ClusterRpcBoundarySnapshot {
control_channels: ["metadata", "lock", "health", "administrative"]
@@ -513,7 +498,6 @@ mod tests {
use std::collections::BTreeSet;
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
use crate::storage_api_contracts::topology::CapabilityState;
#[test]
fn topology_snapshot_maps_endpoint_sets_without_local_paths() {
@@ -604,55 +588,16 @@ mod tests {
}
#[test]
fn peer_health_snapshot_reports_observed_peer_status() {
let online_host = "http://cluster-control-plane-peer-online-test:9000";
let offline_host = "http://cluster-control-plane-peer-offline-test:9000";
rustfs_io_metrics::internode_metrics::record_peer_reachable(online_host);
rustfs_io_metrics::internode_metrics::record_peer_unreachable(offline_host, 1);
let membership = ClusterMembershipSnapshot {
nodes: vec![
ClusterNodeMembership {
node_id: LOCAL_NODE_ID.to_string(),
grid_host: String::new(),
is_local: true,
pools: vec![0],
},
ClusterNodeMembership {
node_id: "cluster-control-plane-peer-online-test:9000".to_string(),
grid_host: online_host.to_string(),
is_local: false,
pools: vec![0],
},
ClusterNodeMembership {
node_id: "cluster-control-plane-peer-offline-test:9000".to_string(),
grid_host: offline_host.to_string(),
is_local: false,
pools: vec![0],
},
ClusterNodeMembership {
node_id: "cluster-control-plane-peer-unknown-test:9000".to_string(),
grid_host: "http://cluster-control-plane-peer-unknown-test:9000".to_string(),
is_local: false,
pools: vec![0],
},
],
drives: Vec::new(),
};
fn peer_health_snapshot_reports_static_unknown_status() {
let membership = membership_snapshot_from_endpoint_pools(&sample_url_endpoint_pools());
let snapshot = peer_health_snapshot_from_membership(&membership);
assert_eq!(snapshot.peers.len(), 4);
assert_eq!(snapshot.peers[0].node_id, LOCAL_NODE_ID);
assert_eq!(snapshot.peers.len(), 2);
assert_eq!(snapshot.peers[0].node_id, "node1.example:9000");
assert!(snapshot.peers[0].is_local);
assert!(snapshot.peers[0].status.state.is_supported());
assert_eq!(snapshot.peers[0].status.reason.as_deref(), Some(PEER_HEALTH_LOCAL_NODE));
assert_eq!(snapshot.peers[1].node_id, "cluster-control-plane-peer-online-test:9000");
assert_eq!(snapshot.peers[0].status.reason.as_deref(), Some(PEER_HEALTH_NOT_REPORTED));
assert_eq!(snapshot.peers[1].node_id, "node2.example:9000");
assert!(!snapshot.peers[1].is_local);
assert!(snapshot.peers[1].status.state.is_supported());
assert_eq!(snapshot.peers[1].status.reason.as_deref(), Some(PEER_HEALTH_REACHABLE));
assert_eq!(snapshot.peers[2].status.reason.as_deref(), Some(PEER_HEALTH_UNREACHABLE));
assert!(!snapshot.peers[2].status.state.is_supported());
assert_eq!(snapshot.peers[3].status.reason.as_deref(), Some(PEER_HEALTH_NOT_REPORTED));
assert_eq!(snapshot.peers[3].status.state, CapabilityState::Disabled);
}
#[test]
+1 -19
View File
@@ -19,10 +19,7 @@ use crate::runtime::sources as runtime_sources;
use http::Uri;
use rustfs_protos::{
ChannelClass, create_new_channel, get_channel_for_class,
proto_gen::node_service::{
heal_control_service_client::HealControlServiceClient, node_service_client::NodeServiceClient,
tier_mutation_control_service_client::TierMutationControlServiceClient,
},
proto_gen::node_service::{heal_control_service_client::HealControlServiceClient, node_service_client::NodeServiceClient},
};
use std::{error::Error, io::ErrorKind};
use tonic::{service::interceptor::InterceptedService, transport::Channel};
@@ -56,21 +53,6 @@ pub async fn heal_control_time_out_client(
.max_encoding_message_size(max_message_size))
}
pub async fn tier_mutation_control_time_out_client(
addr: &str,
interceptor: TonicInterceptor,
) -> Result<TierMutationControlServiceClient<InterceptedService<Channel, TonicInterceptor>>, Box<dyn Error>> {
let interceptor = interceptor.with_rpc_audience(addr)?;
let channel = match runtime_sources::cached_node_channel(addr).await {
Some(channel) => channel,
None => create_new_channel(addr).await?,
};
let max_message_size = rustfs_protos::TIER_MUTATION_RPC_MAX_MESSAGE_SIZE;
Ok(TierMutationControlServiceClient::with_interceptor(channel, interceptor)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size))
}
/// Build a `NodeServiceClient` bound to the [`ChannelClass`]-appropriate channel for `addr`.
///
/// Bulk `bytes`-carrying RPCs (ReadAll/WriteAll/ReadMultiple/BatchReadVersion) pass
@@ -1144,56 +1144,6 @@ mod tests {
assert_eq!(tampered.to_string(), "RPC content SHA-256 mismatch");
}
#[test]
fn tier_mutation_rpc_contract_requires_method_bound_v2_body_digest() {
ensure_test_rpc_secret();
let mutation_id = uuid::uuid!("12345678-1234-5678-9abc-def012345678");
let body = rustfs_protos::canonical_tier_mutation_rpc_body(
rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
rustfs_protos::TierMutationRpcPhase::Prepare,
mutation_id,
b"canonical-tier-mutation-prepare",
)
.expect("small tier mutation body 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.TierMutationControlService",
"PrepareTierMutation",
content_sha256,
)
.expect("body-bound tier mutation auth headers should build");
request.metadata_mut().as_mut().extend(headers.clone());
assert!(
verify_tonic_rpc_signature("node-a:9000", "/node_service.TierMutationControlService/PrepareTierMutation", &headers)
.is_ok(),
"tier mutation RPC signature must bind destination, service, method, nonce, and body digest"
);
let method_replay =
verify_tonic_rpc_signature("node-a:9000", "/node_service.TierMutationControlService/CommitTierMutation", &headers)
.expect_err("prepare auth must not replay to commit");
assert_eq!(method_replay.to_string(), "Invalid RPC v2 signature");
let service_replay = verify_tonic_rpc_signature("node-a:9000", "/node_service.NodeService/PrepareTierMutation", &headers)
.expect_err("tier mutation auth must not replay to the legacy node service path");
assert_eq!(service_replay.to_string(), "Invalid RPC v2 signature");
let tampered_body = rustfs_protos::canonical_tier_mutation_rpc_body(
rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
rustfs_protos::TierMutationRpcPhase::Commit,
mutation_id,
b"canonical-tier-mutation-prepare",
)
.expect("small tier mutation body should encode");
let tampered =
verify_tonic_canonical_body_digest(&request, &tampered_body).expect_err("commit body must not match prepare digest");
assert_eq!(tampered.to_string(), "RPC content SHA-256 mismatch");
}
#[test]
fn partial_v2_metadata_fails_closed() {
ensure_test_rpc_secret();
@@ -14,7 +14,6 @@
use crate::cluster::rpc::client::{
TonicInterceptor, gen_tonic_signature_interceptor, heal_control_time_out_client, node_service_time_out_client,
tier_mutation_control_time_out_client,
};
use crate::cluster::rpc::{set_tonic_canonical_body_digest, verify_tonic_rpc_response_proof};
use crate::error::{Error, Result};
@@ -24,7 +23,6 @@ use crate::{
runtime::sources as runtime_sources,
services::metrics_realtime::{CollectMetricsOpts, MetricType},
};
use bytes::Bytes;
use rmp_serde::{Deserializer, Serializer};
use rustfs_madmin::{
ServerProperties,
@@ -32,6 +30,7 @@ use rustfs_madmin::{
metrics::RealtimeMetrics,
net::NetInfo,
};
use rustfs_protos::evict_failed_connection;
use rustfs_protos::proto_gen::node_service::{
BackgroundHealStatusRequest, CancelDecommissionRequest, ClearDecommissionRequest, DeleteBucketMetadataRequest,
DeletePolicyRequest, DeleteServiceAccountRequest, DeleteUserRequest, GetCpusRequest, GetLiveEventsRequest, GetMemInfoRequest,
@@ -40,11 +39,8 @@ use rustfs_protos::proto_gen::node_service::{
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,
StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest, node_service_client::NodeServiceClient,
};
use rustfs_protos::{TierMutationRpcPhase, evict_failed_connection};
use rustfs_utils::XHost;
use serde::{Deserialize, Serialize as _};
use std::{
@@ -61,7 +57,6 @@ use tonic::Request;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;
use tracing::{debug, info, warn};
use uuid::Uuid;
pub const PEER_RESTSIGNAL: &str = "signal";
pub const PEER_RESTSUB_SYS: &str = "sub-sys";
@@ -124,82 +119,6 @@ pub struct PeerRestClient {
recovery_running: Arc<AtomicBool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PeerTierMutationState {
Prepared,
Committed,
Aborted,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PeerTierMutationOutcome {
pub state: PeerTierMutationState,
pub applied: bool,
}
fn validate_tier_mutation_response_proof(
version: u32,
phase: TierMutationRpcPhase,
mutation_id: Uuid,
canonical_payload: &[u8],
response: &TierMutationControlResponse,
) -> Result<()> {
let canonical_response =
rustfs_protos::canonical_tier_mutation_rpc_response_body(rustfs_protos::TierMutationRpcResponseProofInput {
version,
phase,
mutation_id,
canonical_payload,
success: response.success,
state: response.state,
applied: response.applied,
error_info: response.error_info.as_deref(),
})
.map_err(|_| Error::other("tier mutation response length cannot be represented"))?;
verify_tonic_rpc_response_proof(&canonical_response, &response.response_proof)
.map_err(|_| Error::other("peer returned an invalid tier mutation response proof"))
}
fn decode_tier_mutation_peer_state(state: i32) -> Result<PeerTierMutationState> {
match TierMutationPeerState::try_from(state).map_err(|_| Error::other("peer returned an invalid tier mutation state"))? {
TierMutationPeerState::Prepared => Ok(PeerTierMutationState::Prepared),
TierMutationPeerState::Committed => Ok(PeerTierMutationState::Committed),
TierMutationPeerState::Aborted => Ok(PeerTierMutationState::Aborted),
TierMutationPeerState::Unspecified => Err(Error::other("peer returned an unspecified tier mutation state")),
}
}
fn validate_tier_mutation_payload_len(phase: TierMutationRpcPhase, payload_len: usize) -> Result<()> {
let limit = match phase {
TierMutationRpcPhase::Prepare => rustfs_protos::TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE,
TierMutationRpcPhase::Commit => rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE,
TierMutationRpcPhase::Abort => {
if payload_len == 0 {
return Ok(());
}
return Err(Error::other("tier mutation abort payload must be empty"));
}
_ => return Err(Error::other("tier mutation rpc phase is unsupported")),
};
if payload_len > limit {
return Err(Error::other("tier mutation payload exceeds size limit"));
}
Ok(())
}
fn tier_mutation_phase_label(phase: TierMutationRpcPhase) -> &'static str {
match phase {
TierMutationRpcPhase::Prepare => "prepare",
TierMutationRpcPhase::Commit => "commit",
TierMutationRpcPhase::Abort => "abort",
_ => "unknown",
}
}
fn tier_mutation_control_status_error(phase: TierMutationRpcPhase, status: tonic::Status) -> Error {
Error::other(format!("peer tier mutation {} RPC failed: {status}", tier_mutation_phase_label(phase)))
}
impl PeerRestClient {
fn recovery_monitor_span(grid_host: &str) -> tracing::Span {
tracing::info_span!(
@@ -286,25 +205,6 @@ impl PeerRestClient {
})
}
async fn get_tier_mutation_control_client(
&self,
) -> Result<TierMutationControlServiceClient<InterceptedService<Channel, TonicInterceptor>>> {
if self.offline.load(Ordering::Acquire) {
self.mark_offline_and_spawn_recovery();
return Err(Error::other(format!("peer {} is temporarily offline", self.grid_host)));
}
tier_mutation_control_time_out_client(&self.grid_host, TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
.await
.map_err(|err| {
let storage_err = Error::other(format!("can not get tier mutation control client, err: {err}"));
if Self::is_network_like_error(&storage_err) {
self.mark_offline_and_spawn_recovery();
}
storage_err
})
}
/// Evict the connection to this peer from the global cache.
/// This should be called when communication with this peer fails.
pub async fn evict_connection(&self) {
@@ -835,101 +735,6 @@ impl PeerRestClient {
.await
}
pub async fn prepare_tier_mutation(&self, mutation_id: Uuid, canonical_payload: Bytes) -> Result<PeerTierMutationOutcome> {
self.tier_mutation_control(TierMutationRpcPhase::Prepare, mutation_id, canonical_payload)
.await
}
pub async fn commit_tier_mutation(&self, mutation_id: Uuid, canonical_payload: Bytes) -> Result<PeerTierMutationOutcome> {
self.tier_mutation_control(TierMutationRpcPhase::Commit, mutation_id, canonical_payload)
.await
}
pub async fn abort_tier_mutation(&self, mutation_id: Uuid) -> Result<PeerTierMutationOutcome> {
self.tier_mutation_control(TierMutationRpcPhase::Abort, mutation_id, Bytes::new())
.await
}
async fn tier_mutation_control(
&self,
phase: TierMutationRpcPhase,
mutation_id: Uuid,
canonical_payload: Bytes,
) -> Result<PeerTierMutationOutcome> {
validate_tier_mutation_payload_len(phase, canonical_payload.len())?;
let version = rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION;
self.finalize_result(
async {
let mut client = self
.get_tier_mutation_control_client()
.await?
.max_encoding_message_size(rustfs_protos::TIER_MUTATION_RPC_MAX_MESSAGE_SIZE)
.max_decoding_message_size(rustfs_protos::TIER_MUTATION_RPC_MAX_MESSAGE_SIZE);
let canonical_body =
rustfs_protos::canonical_tier_mutation_rpc_body(version, phase, mutation_id, canonical_payload.as_ref())
.map_err(|_| Error::other("tier mutation request length cannot be represented"))?;
let mutation_id_text = mutation_id.to_string();
let response = match phase {
TierMutationRpcPhase::Prepare => {
let mut request = Request::new(TierMutationPrepareRequest {
version,
mutation_id: mutation_id_text.clone(),
canonical_payload: canonical_payload.clone(),
});
set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
client
.prepare_tier_mutation(request)
.await
.map_err(|status| tier_mutation_control_status_error(phase, status))?
.into_inner()
}
TierMutationRpcPhase::Commit => {
let mut request = Request::new(TierMutationCommitRequest {
version,
mutation_id: mutation_id_text.clone(),
canonical_payload: canonical_payload.clone(),
});
set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
client
.commit_tier_mutation(request)
.await
.map_err(|status| tier_mutation_control_status_error(phase, status))?
.into_inner()
}
TierMutationRpcPhase::Abort => {
let mut request = Request::new(TierMutationAbortRequest {
version,
mutation_id: mutation_id_text,
canonical_payload: canonical_payload.clone(),
});
set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
client
.abort_tier_mutation(request)
.await
.map_err(|status| tier_mutation_control_status_error(phase, status))?
.into_inner()
}
_ => return Err(Error::other("tier mutation rpc phase is unsupported")),
};
validate_tier_mutation_response_proof(version, phase, mutation_id, &canonical_payload, &response)?;
if !response.success {
return Err(Error::other(
response
.error_info
.unwrap_or_else(|| "peer tier mutation failed without an error".to_string()),
));
}
let state = decode_tier_mutation_peer_state(response.state)?;
Ok(PeerTierMutationOutcome {
state,
applied: response.applied,
})
}
.await,
)
.await
}
pub async fn heal_control(&self, version: u32, topology_fingerprint: String, command: Vec<u8>) -> Result<Vec<u8>> {
if topology_fingerprint.len() > HEAL_CONTROL_FINGERPRINT_MAX_SIZE {
return Err(Error::other("heal control topology fingerprint exceeds size limit"));
@@ -1638,190 +1443,6 @@ mod tests {
}
}
struct TierMutationResponseFixture<'a> {
version: u32,
phase: TierMutationRpcPhase,
mutation_id: Uuid,
canonical_payload: &'a [u8],
success: bool,
state: i32,
applied: bool,
error_info: Option<&'a str>,
}
fn signed_tier_mutation_response(input: TierMutationResponseFixture<'_>) -> TierMutationControlResponse {
let canonical =
rustfs_protos::canonical_tier_mutation_rpc_response_body(rustfs_protos::TierMutationRpcResponseProofInput {
version: input.version,
phase: input.phase,
mutation_id: input.mutation_id,
canonical_payload: input.canonical_payload,
success: input.success,
state: input.state,
applied: input.applied,
error_info: input.error_info,
})
.expect("small tier mutation response should encode");
let response_proof =
crate::cluster::rpc::sign_tonic_rpc_response_proof(&canonical).expect("tier mutation response should sign");
TierMutationControlResponse {
success: input.success,
state: input.state,
applied: input.applied,
error_info: input.error_info.map(str::to_string),
response_proof: response_proof.into(),
}
}
#[test]
fn tier_mutation_response_proof_binds_phase_payload_state_and_error() {
runtime_sources::ensure_test_rpc_secret();
let mutation_id = Uuid::new_v4();
let payload = b"tier-mutation-prepare";
let response = signed_tier_mutation_response(TierMutationResponseFixture {
version: rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: TierMutationRpcPhase::Prepare,
mutation_id,
canonical_payload: payload,
success: true,
state: TierMutationPeerState::Prepared as i32,
applied: true,
error_info: None,
});
validate_tier_mutation_response_proof(
rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
payload,
&response,
)
.expect("matching tier mutation response proof should verify");
for tampered in [
TierMutationControlResponse {
success: false,
error_info: Some("peer failed".to_string()),
..response.clone()
},
TierMutationControlResponse {
state: TierMutationPeerState::Committed as i32,
..response.clone()
},
TierMutationControlResponse {
applied: false,
..response.clone()
},
] {
let err = validate_tier_mutation_response_proof(
rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
payload,
&tampered,
)
.expect_err("tampered tier mutation response proof must fail");
assert!(err.to_string().contains("invalid tier mutation response proof"));
}
let err = validate_tier_mutation_response_proof(
rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
mutation_id,
payload,
&response,
)
.expect_err("response proof must bind request phase");
assert!(err.to_string().contains("invalid tier mutation response proof"));
}
#[test]
fn tier_mutation_peer_state_decode_fails_closed() {
assert_eq!(
decode_tier_mutation_peer_state(TierMutationPeerState::Prepared as i32).expect("prepared state should decode"),
PeerTierMutationState::Prepared
);
assert_eq!(
decode_tier_mutation_peer_state(TierMutationPeerState::Committed as i32).expect("committed state should decode"),
PeerTierMutationState::Committed
);
assert_eq!(
decode_tier_mutation_peer_state(TierMutationPeerState::Aborted as i32).expect("aborted state should decode"),
PeerTierMutationState::Aborted
);
assert!(decode_tier_mutation_peer_state(TierMutationPeerState::Unspecified as i32).is_err());
assert!(decode_tier_mutation_peer_state(99).is_err());
}
#[test]
fn tier_mutation_payload_guard_rejects_invalid_lengths() {
validate_tier_mutation_payload_len(
TierMutationRpcPhase::Prepare,
rustfs_protos::TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE,
)
.expect("max prepare payload should fit");
assert!(
validate_tier_mutation_payload_len(
TierMutationRpcPhase::Prepare,
rustfs_protos::TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE + 1,
)
.is_err()
);
validate_tier_mutation_payload_len(
TierMutationRpcPhase::Commit,
rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE,
)
.expect("max commit payload should fit");
assert!(
validate_tier_mutation_payload_len(
TierMutationRpcPhase::Commit,
rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE + 1,
)
.is_err()
);
validate_tier_mutation_payload_len(TierMutationRpcPhase::Abort, 0).expect("empty abort payload should fit");
assert!(validate_tier_mutation_payload_len(TierMutationRpcPhase::Abort, 1).is_err());
}
#[test]
fn tier_mutation_rpc_status_matrix_fails_closed_for_old_or_unresponsive_peers() {
for (phase, label) in [
(TierMutationRpcPhase::Prepare, "prepare"),
(TierMutationRpcPhase::Commit, "commit"),
(TierMutationRpcPhase::Abort, "abort"),
] {
for status in [
tonic::Status::unimplemented("old peer has no tier mutation control service"),
tonic::Status::deadline_exceeded("peer tier mutation control timed out"),
tonic::Status::unavailable("peer tier mutation control unavailable"),
] {
let err = tier_mutation_control_status_error(phase, status);
let rendered = err.to_string();
assert!(rendered.contains(&format!("peer tier mutation {label} RPC failed")), "{rendered}");
assert!(
rendered.contains("old peer")
|| rendered.contains("timed out")
|| rendered.contains("unavailable")
|| rendered.contains("Unavailable"),
"{rendered}"
);
}
}
}
#[tokio::test]
async fn peer_rest_client_rejects_oversized_tier_prepare_before_dialing() {
let client = test_peer_client();
let err = client
.prepare_tier_mutation(
Uuid::new_v4(),
Bytes::from(vec![0; rustfs_protos::TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE + 1]),
)
.await
.expect_err("oversized tier prepare should fail before dialing");
assert!(err.to_string().contains("tier mutation payload exceeds size limit"));
assert!(!client.offline.load(Ordering::Acquire));
}
#[tokio::test]
async fn peer_rest_client_prepare_retry_clears_offline_gate() {
// finalize_result sets the offline gate on a network error; without
@@ -23,7 +23,7 @@ use crate::disk::{DiskAPI, DiskStore, disk_store::get_max_timeout_duration};
use crate::runtime::instance::{InstanceContext, bootstrap_ctx};
use crate::runtime::sources as runtime_sources;
use crate::storage_api_contracts::bucket::{BucketInfo, BucketOptions, DeleteBucketOptions, MakeBucketOptions};
use crate::store::{has_xlmeta_files, utils::is_reserved_or_invalid_bucket};
use crate::store::utils::is_reserved_or_invalid_bucket;
use crate::{
disk::{
self, VolumeInfo,
@@ -614,24 +614,13 @@ impl PeerS3Client for LocalPeerS3Client {
return Err(Error::ErasureWriteQuorum);
}
let force = if opts.force_if_empty && !opts.force {
for disk in local_disks.iter() {
if has_xlmeta_files(&disk.path().join(bucket)).await.map_err(Error::Io)? {
return Err(Error::VolumeNotEmpty);
}
}
true
} else {
opts.force
};
let mut futures = Vec::with_capacity(local_disks.len());
for disk in local_disks.iter() {
// Non-force delete refuses a non-empty bucket (VolumeNotEmpty), which
// the recreate loop below turns into BucketNotEmpty; only an explicit
// force delete removes recursively (backlog#799 B1).
futures.push(disk.delete_volume(bucket, force));
futures.push(disk.delete_volume(bucket, opts.force));
}
let results = join_all(futures).await;
@@ -999,15 +988,13 @@ impl PeerS3Client for RemotePeerS3Client {
.await
}
async fn delete_bucket(&self, bucket: &str, opts: &DeleteBucketOptions) -> Result<()> {
async fn delete_bucket(&self, bucket: &str, _opts: &DeleteBucketOptions) -> Result<()> {
self.execute_with_timeout(
|| async {
let options = serde_json::to_string(opts)?;
let mut client = self.get_client().await?;
let request = Request::new(DeleteBucketRequest {
bucket: bucket.to_string(),
options,
});
let response = client.delete_bucket(request).await?.into_inner();
if !response.success {
-68
View File
@@ -209,16 +209,6 @@ pub fn get_drive_walkdir_stall_timeout() -> Duration {
)
}
pub fn get_drive_walkdir_peek_timeout() -> Duration {
let stall_timeout = get_drive_walkdir_stall_timeout();
let configured = get_drive_timeout_duration(
rustfs_config::ENV_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS,
rustfs_config::DEFAULT_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS,
Some(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY_SECS.saturating_mul(2)),
);
configured.max(stall_timeout)
}
pub fn get_object_disk_read_timeout() -> Duration {
get_drive_timeout_duration(
rustfs_config::ENV_OBJECT_DISK_READ_TIMEOUT,
@@ -1666,64 +1656,6 @@ mod tests {
});
}
#[test]
fn drive_walkdir_peek_timeout_uses_wider_default_when_unset() {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS, || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS, || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_TIMEOUT_PROFILE, || {
assert_eq!(
get_drive_walkdir_peek_timeout(),
Duration::from_secs(rustfs_config::DEFAULT_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS)
);
});
});
});
});
}
#[test]
fn drive_walkdir_peek_timeout_is_never_stricter_than_stall_timeout() {
temp_env::with_var(rustfs_config::ENV_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS, Some("3"), || {
temp_env::with_var(rustfs_config::ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS, Some("13"), || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, || {
assert_eq!(get_drive_walkdir_peek_timeout(), Duration::from_secs(13));
});
});
});
}
#[test]
fn drive_walkdir_peek_timeout_prefers_canonical_over_legacy() {
temp_env::with_var(rustfs_config::ENV_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS, Some("23"), || {
temp_env::with_var(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("17"), || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS, || {
assert_eq!(get_drive_walkdir_peek_timeout(), Duration::from_secs(23));
});
});
});
}
#[test]
fn drive_walkdir_peek_timeout_uses_high_latency_profile_default() {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS, || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS, || {
temp_env::with_var_unset(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, || {
temp_env::with_var(
rustfs_config::ENV_DRIVE_TIMEOUT_PROFILE,
Some(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY),
|| {
assert_eq!(
get_drive_walkdir_peek_timeout(),
Duration::from_secs(rustfs_config::DRIVE_TIMEOUT_PROFILE_HIGH_LATENCY_SECS * 2)
);
},
);
});
});
});
}
#[test]
fn drive_walkdir_timeout_prefers_canonical_over_legacy() {
temp_env::with_var(rustfs_config::ENV_DRIVE_WALKDIR_TIMEOUT_SECS, Some("11"), || {
+1 -103
View File
@@ -5172,25 +5172,7 @@ impl LocalDisk {
// larger `RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS` or the high-latency
// drive-timeout profile; a streaming readdir rewrite is a separate,
// higher-risk follow-up and is intentionally not done here.
let read_dir_started = rustfs_io_metrics::get_stage_metrics_enabled().then(std::time::Instant::now);
let read_dir_result = with_walk_stall_timeout(stall, self.list_dir("", &opts.bucket, &current, -1)).await;
if let Some(started) = read_dir_started {
rustfs_io_metrics::record_list_objects_local_read_dir(rustfs_io_metrics::ListObjectsLocalReadDirObservation {
outcome: if read_dir_result.is_ok() {
rustfs_io_metrics::LIST_OBJECTS_LOCAL_READ_DIR_OUTCOME_OK
} else {
rustfs_io_metrics::LIST_OBJECTS_LOCAL_READ_DIR_OUTCOME_ERROR
},
requested_count: -1,
returned_entries: read_dir_result.as_ref().map_or(0, Vec::len),
duration_ms: started.elapsed().as_secs_f64() * 1000.0,
is_root: current.trim_matches('/').is_empty(),
has_filter_prefix: !prefix.is_empty(),
has_forward: forward.is_some(),
});
}
let mut entries = match read_dir_result {
let mut entries = match with_walk_stall_timeout(stall, self.list_dir("", &opts.bucket, &current, -1)).await {
Ok(res) => res,
Err(e) => {
if e != DiskError::VolumeNotFound && e != Error::FileNotFound {
@@ -11981,90 +11963,6 @@ mod test {
assert!(names.contains(&"quux/thud".to_string()));
}
#[test]
#[serial_test::serial]
fn scan_dir_records_whole_parent_read_dir_before_page_limit() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should be created");
let recorder = crate::test_metrics::CapturingRecorder::default();
let previous_gate = rustfs_io_metrics::get_stage_metrics_enabled();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
metrics::with_local_recorder(&recorder, || {
runtime.block_on(async {
use rustfs_filemeta::MetacacheReader;
use tempfile::tempdir;
let dir = tempdir().expect("tempdir should be created");
let bucket = "test-bucket";
let bucket_dir = dir.path().join(bucket);
const OBJECTS: usize = 12;
for index in 0..OBJECTS {
let object_dir = bucket_dir.join(format!("object-{index:04}"));
fs::create_dir_all(&object_dir)
.await
.expect("object directory should be created");
fs::write(object_dir.join(STORAGE_FORMAT_FILE), b"meta")
.await
.expect("object metadata should be written");
}
let endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
let (reader, mut writer) = tokio::io::duplex(4096);
let mut out = MetacacheWriter::new(&mut writer);
let opts = WalkDirOptions {
bucket: bucket.to_string(),
base_dir: String::new(),
recursive: true,
limit: 1,
..Default::default()
};
let mut objs_returned = 0;
disk.scan_dir(String::new(), String::new(), &opts, &mut out, &mut objs_returned, false, None)
.await
.expect("scan_dir should succeed");
out.close().await.expect("metacache writer should close");
drop(out);
drop(writer);
let mut reader = MetacacheReader::new(reader);
let visible_objects = reader
.read_all()
.await
.expect("scan output should decode")
.into_iter()
.filter(|entry| !entry.metadata.is_empty())
.count();
assert_eq!(visible_objects, 1);
assert_eq!(objs_returned, 1);
});
});
rustfs_io_metrics::set_get_stage_metrics_enabled(previous_gate);
assert_eq!(
recorder.counter_value(
"rustfs_s3_list_objects_local_read_dir_total",
&[("outcome", "ok"), ("count_mode", "whole"), ("is_root", "true")]
),
1
);
assert_eq!(
recorder.histogram_values(
"rustfs_s3_list_objects_local_read_dir_entries",
&[("outcome", "ok"), ("count_mode", "whole")]
),
vec![12.0]
);
}
#[tokio::test]
async fn test_scan_dir_reports_base_dir_object_metadata() {
use rustfs_filemeta::MetacacheReader;
+1 -81
View File
@@ -50,7 +50,7 @@ use crate::services::event_notification::EventNotifier;
use crate::services::tier::tier::TierConfigMgr;
use rustfs_lock::{GlobalLockManager, get_global_lock_manager};
use s3s::region::Region;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use tokio::sync::{OnceCell, RwLock};
use tokio_util::sync::CancellationToken;
@@ -159,10 +159,6 @@ pub struct InstanceContext {
/// workers (scanner/heal/tier/lifecycle) without touching another instance.
/// Replaces the process-global cancel-token static.
background_cancel_token: OnceLock<CancellationToken>,
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
#[cfg(test)]
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
}
impl InstanceContext {
@@ -197,10 +193,6 @@ impl InstanceContext {
local_disk_set_drives: Arc::new(RwLock::new(Vec::new())),
bucket_metadata_sys: std::sync::Mutex::new(None),
background_cancel_token: OnceLock::new(),
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
#[cfg(test)]
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
}
}
@@ -361,34 +353,6 @@ impl InstanceContext {
self.background_cancel_token.get().cloned()
}
pub(crate) fn bind_background_cancel_token(&self, token: CancellationToken) -> CancellationToken {
self.background_cancel_token.get_or_init(|| token).clone()
}
pub(crate) fn mark_tier_delete_journal_recovery_started(&self, store_id: Uuid) -> bool {
self.tier_delete_journal_recovery_stores
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(store_id)
}
pub(crate) fn mark_transition_transaction_recovery_started(&self, store_id: Uuid) -> bool {
self.transition_transaction_recovery_stores
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(store_id)
}
#[cfg(test)]
pub(crate) fn wake_tier_delete_journal_recovery(&self) {
self.tier_delete_journal_recovery_wakeup.notify_one();
}
#[cfg(test)]
pub(crate) async fn wait_for_tier_delete_journal_recovery(&self) {
self.tier_delete_journal_recovery_wakeup.notified().await;
}
/// Update this instance's erasure setup type.
pub async fn update_erasure_type(&self, setup_type: SetupType) {
*self.erasure_kind.write().await = setup_type;
@@ -442,20 +406,6 @@ impl std::fmt::Debug for InstanceContext {
.field("replication_stats_set", &self.replication_stats.get().is_some())
.field("replication_pool_set", &self.replication_pool.get().is_some())
.field("background_cancel_token_set", &self.background_cancel_token.get().is_some())
.field(
"tier_delete_journal_recovery_store_count",
&self
.tier_delete_journal_recovery_stores
.lock()
.map_or(0, |stores| stores.len()),
)
.field(
"transition_transaction_recovery_store_count",
&self
.transition_transaction_recovery_stores
.lock()
.map_or(0, |stores| stores.len()),
)
.finish_non_exhaustive()
}
}
@@ -790,36 +740,6 @@ mod tests {
assert!(ctx_a.background_cancel_token().unwrap().is_cancelled());
}
#[test]
fn tier_delete_journal_recovery_is_deduplicated_per_store_and_instance() {
let ctx_a = InstanceContext::new();
let ctx_b = InstanceContext::new();
let store_a = Uuid::new_v4();
let store_b = Uuid::new_v4();
assert!(ctx_a.mark_tier_delete_journal_recovery_started(store_a));
assert!(!ctx_a.mark_tier_delete_journal_recovery_started(store_a));
assert!(ctx_a.mark_tier_delete_journal_recovery_started(store_b));
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_a));
}
#[test]
fn background_cancel_token_binds_the_provided_shutdown_token() {
let ctx = InstanceContext::new();
let shutdown = CancellationToken::new();
let first = ctx.bind_background_cancel_token(shutdown.clone());
let second = ctx.bind_background_cancel_token(CancellationToken::new());
shutdown.cancel();
assert!(first.is_cancelled());
assert!(second.is_cancelled());
assert!(
ctx.background_cancel_token()
.expect("shutdown token should be published")
.is_cancelled()
);
}
// Phase 5 acceptance (backlog#939): two independent instance contexts share
// NONE of the runtime state that used to live in process globals. This is
// the end-to-end proof that the object-graph isolation carrier works — every
@@ -21,7 +21,6 @@ use crate::runtime::sources as runtime_sources;
use crate::services::metrics_realtime::{CollectMetricsOpts, MetricType};
use crate::services::rebalance::RebalSaveOpt;
use crate::storage_api_contracts::admin::StorageAdminApi;
use bytes::Bytes;
use futures::future::join_all;
use lazy_static::lazy_static;
use rustfs_madmin::health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysServices};
@@ -36,7 +35,6 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime};
use tokio::time::timeout;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
/// After this many consecutive admin-call failures, mark the peer as offline.
const CONSECUTIVE_FAILURE_THRESHOLD: u32 = 3;
@@ -115,17 +113,6 @@ pub struct NotificationPeerErr {
pub err: Option<Error>,
}
fn notification_peer_result<T>(host: String, result: Result<T>) -> NotificationPeerErr {
NotificationPeerErr { host, err: result.err() }
}
fn unreachable_notification_peer_err() -> NotificationPeerErr {
NotificationPeerErr {
host: String::new(),
err: Some(Error::other("peer is not reachable")),
}
}
impl NotificationSys {
pub fn rest_client_from_hash(&self, s: &str) -> Option<PeerRestClient> {
if self.all_peer_clients.is_empty() {
@@ -1083,50 +1070,6 @@ impl NotificationSys {
}
join_all(futures).await
}
pub async fn prepare_tier_mutation(&self, mutation_id: Uuid, canonical_payload: Bytes) -> Vec<NotificationPeerErr> {
let mut futures = Vec::with_capacity(self.peer_clients.len());
for client in self.peer_clients.iter().cloned() {
let payload = canonical_payload.clone();
futures.push(async move {
if let Some(client) = client {
notification_peer_result(client.host.to_string(), client.prepare_tier_mutation(mutation_id, payload).await)
} else {
unreachable_notification_peer_err()
}
});
}
join_all(futures).await
}
pub async fn commit_tier_mutation(&self, mutation_id: Uuid, canonical_payload: Bytes) -> Vec<NotificationPeerErr> {
let mut futures = Vec::with_capacity(self.peer_clients.len());
for client in self.peer_clients.iter().cloned() {
let payload = canonical_payload.clone();
futures.push(async move {
if let Some(client) = client {
notification_peer_result(client.host.to_string(), client.commit_tier_mutation(mutation_id, payload).await)
} else {
unreachable_notification_peer_err()
}
});
}
join_all(futures).await
}
pub async fn abort_tier_mutation(&self, mutation_id: Uuid) -> Vec<NotificationPeerErr> {
let mut futures = Vec::with_capacity(self.peer_clients.len());
for client in self.peer_clients.iter().cloned() {
futures.push(async move {
if let Some(client) = client {
notification_peer_result(client.host.to_string(), client.abort_tier_mutation(mutation_id).await)
} else {
unreachable_notification_peer_err()
}
});
}
join_all(futures).await
}
}
async fn scanner_activity_with_timeout<F>(timeout_duration: Duration, host: &str, activity: F) -> Result<ScannerPeerActivity>
@@ -1783,36 +1726,6 @@ mod tests {
assert!(results[0].err.as_ref().unwrap().to_string().contains("peer is not reachable"));
}
#[tokio::test]
async fn tier_mutation_fanout_reports_unreachable_peers_fail_closed() {
let sys = NotificationSys {
peer_clients: vec![None],
all_peer_clients: Vec::new(),
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
};
let mutation_id = Uuid::from_u128(1);
let prepare = sys.prepare_tier_mutation(mutation_id, Bytes::from_static(b"prepare")).await;
assert_eq!(prepare.len(), 1);
assert!(prepare[0].host.is_empty());
assert!(
prepare[0]
.err
.as_ref()
.expect("unreachable prepare peer should carry an error")
.to_string()
.contains("peer is not reachable")
);
let commit = sys.commit_tier_mutation(mutation_id, Bytes::from_static(b"commit")).await;
assert_eq!(commit.len(), 1);
assert!(commit[0].err.is_some());
let abort = sys.abort_tier_mutation(mutation_id).await;
assert_eq!(abort.len(), 1);
assert!(abort[0].err.is_some());
}
// --- Tests for handle_peer_failure / handle_server_info_failure caching ---
#[test]
-3
View File
@@ -19,8 +19,6 @@ pub mod tier_admin;
pub mod tier_config;
pub mod tier_gen;
pub mod tier_handlers;
pub(crate) mod tier_mutation_intent;
pub mod tier_mutation_peer;
pub mod warm_backend;
pub mod warm_backend_aliyun;
pub mod warm_backend_azure;
@@ -32,4 +30,3 @@ pub mod warm_backend_rustfs;
pub mod warm_backend_s3;
pub mod warm_backend_s3sdk;
pub mod warm_backend_tencent;
pub mod warm_backend_wasabi;
+1 -131
View File
@@ -57,10 +57,7 @@
use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
@@ -78,21 +75,6 @@ use crate::services::tier::warm_backend::{WarmBackend, WarmBackendGetOpts, build
use rustfs_filemeta::FileMeta;
use rustfs_utils::path::path_join_buf;
/// One-shot barrier before rejected transition cleanup resolves its ECStore.
pub struct TransitionCleanupStoreBarrier(crate::set_disk::SetDiskTransitionCleanupStoreBarrier);
impl TransitionCleanupStoreBarrier {
/// Install the barrier for the next rejected transition cleanup.
pub fn install() -> Self {
Self(crate::set_disk::SetDiskTransitionCleanupStoreBarrier::install())
}
/// Wait until the rejected transition reaches cleanup-store resolution.
pub async fn wait_until_paused(&self) {
self.0.wait_until_paused().await;
}
}
/// Default polling cadence used by the `wait_for_*` helpers.
const POLL_INTERVAL: Duration = Duration::from_millis(50);
@@ -160,15 +142,11 @@ struct MockWarmBackendInner {
faults: Mutex<FaultConfig>,
put_read_limit: Mutex<Option<usize>>,
put_remote_version: Mutex<Option<String>>,
reject_non_empty_remote_versions: AtomicBool,
fail_remove: AtomicBool,
exact_remove_count: AtomicUsize,
op_log: Mutex<Vec<MockWarmOp>>,
put_versions: Mutex<Vec<(String, String)>>,
remove_versions: Mutex<Vec<(String, String)>>,
put_barrier: Mutex<Option<Arc<MockPutBarrierState>>>,
get_barrier: Mutex<Option<Arc<MockGetBarrierState>>>,
remove_barrier: Mutex<Option<Arc<MockRemoveBarrierState>>>,
}
#[derive(Default)]
@@ -184,23 +162,6 @@ struct MockGetBarrierState {
fail_after_release: bool,
}
#[derive(Default)]
struct MockRemoveBarrierState {
arrived: Notify,
release: Notify,
operation_dropped: Notify,
}
struct MockRemoveOperationGuard {
state: Arc<MockRemoveBarrierState>,
}
impl Drop for MockRemoveOperationGuard {
fn drop(&mut self) {
self.state.operation_dropped.notify_one();
}
}
/// One-shot barrier that pauses a mock tier PUT after storing its remote body.
pub struct MockPutBarrier {
state: Arc<MockPutBarrierState>,
@@ -251,38 +212,6 @@ impl Drop for MockGetBarrier {
}
}
/// One-shot barrier that pauses and then fails a mock tier DELETE.
pub struct MockRemoveBarrier {
state: Arc<MockRemoveBarrierState>,
}
impl MockRemoveBarrier {
/// Wait until DELETE reaches the deterministic failure point.
pub async fn wait_until_paused(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
.await
.expect("mock tier DELETE should reach the deterministic barrier");
}
/// Release the paused DELETE, which then returns an injected error.
pub fn release(&self) {
self.state.release.notify_one();
}
/// Wait until the paused DELETE future completes or is cancelled.
pub async fn wait_until_operation_dropped(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.operation_dropped.notified())
.await
.expect("mock tier DELETE operation should be dropped");
}
}
impl Drop for MockRemoveBarrier {
fn drop(&mut self) {
self.state.release.notify_one();
}
}
/// In-memory [`WarmBackend`] for lifecycle / tiering integration tests.
///
/// Cloning shares the same underlying storage, fault configuration, and
@@ -306,15 +235,6 @@ impl MockWarmBackend {
MockPutBarrier { state }
}
/// Pause and then fail the next DELETE after it reaches the backend.
pub async fn arm_failing_remove_barrier(&self) -> MockRemoveBarrier {
let state = Arc::new(MockRemoveBarrierState::default());
let mut barrier = self.inner.remove_barrier.lock().await;
assert!(barrier.is_none(), "mock tier DELETE barrier is already armed");
*barrier = Some(state.clone());
MockRemoveBarrier { state }
}
/// Arm a one-shot pause before the next tier GET, then return an error
/// after the test releases it.
pub async fn arm_failing_get_barrier(&self) -> MockGetBarrier {
@@ -326,14 +246,6 @@ impl MockWarmBackend {
MockGetBarrier { state }
}
/// Arm a one-shot pause before the next tier GET, then continue normally
/// after the test releases it.
pub async fn arm_get_barrier(&self) -> MockGetBarrier {
let state = Arc::new(MockGetBarrierState::default());
*self.inner.get_barrier.lock().await = Some(Arc::clone(&state));
MockGetBarrier { state }
}
// ---- fault injection -------------------------------------------------
/// Replace the entire fault configuration.
@@ -378,16 +290,6 @@ impl MockWarmBackend {
*self.inner.put_remote_version.lock().await = remote_version;
}
/// Reject non-empty remote versions before transition metadata is committed.
pub fn set_reject_non_empty_remote_versions(&self, reject: bool) {
self.inner.reject_non_empty_remote_versions.store(reject, Ordering::Release);
}
/// Enable or disable a persistent remove failure for durability tests.
pub fn set_remove_failure(&self, fail: bool) {
self.inner.fail_remove.store(fail, Ordering::Release);
}
async fn precondition(&self) -> Result<(), std::io::Error> {
let (latency, error) = {
let faults = self.inner.faults.lock().await;
@@ -429,11 +331,6 @@ impl MockWarmBackend {
.count()
}
/// Number of exact-version trait remove calls, including failed attempts.
pub fn exact_remove_count(&self) -> usize {
self.inner.exact_remove_count.load(Ordering::Acquire)
}
/// Return the exact object/version pairs produced by successful tier PUTs.
pub async fn put_versions(&self) -> Vec<(String, String)> {
self.inner.put_versions.lock().await.clone()
@@ -579,13 +476,6 @@ impl MockWarmBackend {
#[async_trait]
impl WarmBackend for MockWarmBackend {
fn validate_remote_version_id(&self, remote_version_id: &str) -> Result<(), std::io::Error> {
if self.inner.reject_non_empty_remote_versions.load(Ordering::Acquire) && !remote_version_id.is_empty() {
return Err(std::io::Error::other("mock warm backend requires an unversioned remote object"));
}
Ok(())
}
async fn put(&self, object: &str, r: ReaderImpl, _length: i64) -> Result<String, std::io::Error> {
self.precondition().await?;
let bytes = self.read_bytes(r).await?;
@@ -684,15 +574,6 @@ impl WarmBackend for MockWarmBackend {
async fn remove(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
self.precondition().await?;
if let Some(barrier) = self.inner.remove_barrier.lock().await.take() {
let _operation = MockRemoveOperationGuard { state: barrier.clone() };
barrier.arrived.notify_one();
barrier.release.notified().await;
return Err(std::io::Error::other("mock warm backend remove failure after barrier"));
}
if self.inner.fail_remove.load(Ordering::Acquire) {
return Err(std::io::Error::other("mock warm backend remove failure"));
}
let mut objects = self.inner.objects.lock().await;
if let Some(stored) = objects.get(object)
&& !rv.is_empty()
@@ -714,17 +595,6 @@ impl WarmBackend for MockWarmBackend {
Ok(())
}
async fn remove_exact(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
if rv.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"an exact mock tier delete requires a remote version ID",
));
}
self.inner.exact_remove_count.fetch_add(1, Ordering::AcqRel);
self.remove(object, rv).await
}
async fn in_use(&self) -> Result<bool, std::io::Error> {
self.precondition().await?;
self.record(MockWarmOp::InUse).await;
File diff suppressed because it is too large Load Diff
+1 -298
View File
@@ -13,22 +13,12 @@
// limitations under the License.
use serde::{Deserialize, Serialize};
use std::{fmt::Display, io};
use std::fmt::Display;
use tracing::info;
const C_TIER_CONFIG_VER: &str = "v1";
const ERR_TIER_NAME_EMPTY: &str = "remote tier name empty";
const WASABI_US_EAST_ENDPOINT: &str = "https://s3.wasabisys.com";
const WASABI_ALTERNATIVE_ENDPOINTS: &[(&str, &str)] = &[
("us-east-1", "https://s3.us-east-1.wasabisys.com"),
("eu-central-1", "https://s3.nl-1.wasabisys.com"),
("eu-central-2", "https://s3.de-1.wasabisys.com"),
("eu-west-1", "https://s3.uk-1.wasabisys.com"),
("eu-west-2", "https://s3.fr-1.wasabisys.com"),
("eu-west-3", "https://s3.uk-2.wasabisys.com"),
("eu-south-1", "https://s3.it-1.wasabisys.com"),
];
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub enum TierType {
@@ -36,8 +26,6 @@ pub enum TierType {
Unsupported,
#[serde(rename = "s3")]
S3,
#[serde(rename = "wasabi")]
Wasabi,
#[serde(rename = "rustfs")]
RustFS,
#[serde(rename = "minio")]
@@ -62,9 +50,6 @@ impl Display for TierType {
TierType::S3 => {
write!(f, "S3")
}
TierType::Wasabi => {
write!(f, "Wasabi")
}
TierType::RustFS => {
write!(f, "RustFS")
}
@@ -100,7 +85,6 @@ impl TierType {
pub fn new(sc_type: &str) -> Self {
match sc_type {
"S3" => TierType::S3,
"Wasabi" => TierType::Wasabi,
"RustFS" => TierType::RustFS,
"MinIO" => TierType::MinIO,
"Aliyun" => TierType::Aliyun,
@@ -116,7 +100,6 @@ impl TierType {
pub fn as_lowercase(&self) -> String {
match self {
TierType::S3 => "s3".to_string(),
TierType::Wasabi => "wasabi".to_string(),
TierType::RustFS => "rustfs".to_string(),
TierType::MinIO => "minio".to_string(),
TierType::Aliyun => "aliyun".to_string(),
@@ -141,8 +124,6 @@ pub struct TierConfig {
pub name: String,
#[serde(rename = "s3", skip_serializing_if = "Option::is_none")]
pub s3: Option<TierS3>,
#[serde(rename = "wasabi", skip_serializing_if = "Option::is_none")]
pub wasabi: Option<TierWasabi>,
#[serde(rename = "aliyun", skip_serializing_if = "Option::is_none")]
pub aliyun: Option<TierAliyun>,
#[serde(rename = "tencent", skip_serializing_if = "Option::is_none")]
@@ -164,7 +145,6 @@ pub struct TierConfig {
impl Clone for TierConfig {
fn clone(&self) -> TierConfig {
let mut s3 = None;
let mut wasabi = None;
let mut r = None;
let mut compatible_backend = None;
let mut aliyun = None;
@@ -181,13 +161,6 @@ impl Clone for TierConfig {
s3 = Some(s3_clone);
}
}
TierType::Wasabi => {
if let Some(wasabi_) = self.wasabi.as_ref() {
let mut wasabi_clone = wasabi_.clone();
wasabi_clone.secret_key = "REDACTED".to_string();
wasabi = Some(wasabi_clone);
}
}
TierType::RustFS => {
if let Some(r_) = self.rustfs.as_ref() {
let mut r_clone = r_.clone();
@@ -251,7 +224,6 @@ impl Clone for TierConfig {
tier_type: self.tier_type.clone(),
name: self.name.clone(),
s3,
wasabi,
rustfs: r,
minio: compatible_backend,
aliyun,
@@ -272,7 +244,6 @@ impl TierConfig {
tier_type: self.tier_type.clone(),
name: self.name.clone(),
s3: self.s3.clone(),
wasabi: self.wasabi.clone(),
aliyun: self.aliyun.clone(),
tencent: self.tencent.clone(),
huaweicloud: self.huaweicloud.clone(),
@@ -287,7 +258,6 @@ impl TierConfig {
fn endpoint(&self) -> String {
match self.tier_type {
TierType::S3 => self.s3.as_ref().map(|s| s.endpoint.clone()).unwrap_or_default(),
TierType::Wasabi => self.wasabi.as_ref().map(|w| w.endpoint.clone()).unwrap_or_default(),
TierType::RustFS => self.rustfs.as_ref().map(|r| r.endpoint.clone()).unwrap_or_default(),
TierType::MinIO => self.minio.as_ref().map(|m| m.endpoint.clone()).unwrap_or_default(),
TierType::Aliyun => self.aliyun.as_ref().map(|a| a.endpoint.clone()).unwrap_or_default(),
@@ -306,7 +276,6 @@ impl TierConfig {
fn bucket(&self) -> String {
match self.tier_type {
TierType::S3 => self.s3.as_ref().map(|s| s.bucket.clone()).unwrap_or_default(),
TierType::Wasabi => self.wasabi.as_ref().map(|w| w.bucket.clone()).unwrap_or_default(),
TierType::RustFS => self.rustfs.as_ref().map(|r| r.bucket.clone()).unwrap_or_default(),
TierType::MinIO => self.minio.as_ref().map(|m| m.bucket.clone()).unwrap_or_default(),
TierType::Aliyun => self.aliyun.as_ref().map(|a| a.bucket.clone()).unwrap_or_default(),
@@ -325,7 +294,6 @@ impl TierConfig {
fn prefix(&self) -> String {
match self.tier_type {
TierType::S3 => self.s3.as_ref().map(|s| s.prefix.clone()).unwrap_or_default(),
TierType::Wasabi => self.wasabi.as_ref().map(|w| w.prefix.clone()).unwrap_or_default(),
TierType::RustFS => self.rustfs.as_ref().map(|r| r.prefix.clone()).unwrap_or_default(),
TierType::MinIO => self.minio.as_ref().map(|m| m.prefix.clone()).unwrap_or_default(),
TierType::Aliyun => self.aliyun.as_ref().map(|a| a.prefix.clone()).unwrap_or_default(),
@@ -344,7 +312,6 @@ impl TierConfig {
fn region(&self) -> String {
match self.tier_type {
TierType::S3 => self.s3.as_ref().map(|s| s.region.clone()).unwrap_or_default(),
TierType::Wasabi => self.wasabi.as_ref().map(|w| w.region.clone()).unwrap_or_default(),
TierType::RustFS => self.rustfs.as_ref().map(|r| r.region.clone()).unwrap_or_default(),
TierType::MinIO => self.minio.as_ref().map(|m| m.region.clone()).unwrap_or_default(),
TierType::Aliyun => self.aliyun.as_ref().map(|a| a.region.clone()).unwrap_or_default(),
@@ -389,73 +356,6 @@ pub struct TierS3 {
pub aws_role_duration_seconds: i32,
}
#[derive(Serialize, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)]
pub struct TierWasabi {
pub name: String,
#[serde(default)]
pub endpoint: String,
#[serde(rename = "accessKey")]
pub access_key: String,
#[serde(rename = "secretKey")]
pub secret_key: String,
pub bucket: String,
#[serde(default)]
pub prefix: String,
pub region: String,
}
impl std::fmt::Debug for TierWasabi {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TierWasabi")
.field("name", &self.name)
.field("endpoint", &self.endpoint)
.field("access_key", &self.access_key)
.field("secret_key", &"REDACTED")
.field("bucket", &self.bucket)
.field("prefix", &self.prefix)
.field("region", &self.region)
.finish()
}
}
impl TierWasabi {
pub(crate) fn alternative_endpoint(&self) -> Option<&'static str> {
WASABI_ALTERNATIVE_ENDPOINTS
.iter()
.find_map(|(region, endpoint)| (*region == self.region).then_some(*endpoint))
}
pub(crate) fn canonical_endpoint(&self) -> io::Result<String> {
let region = self.region.as_bytes();
let is_alphanumeric = |byte: &u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
if !(1..=63).contains(&region.len())
|| !region.first().is_some_and(is_alphanumeric)
|| !region.last().is_some_and(is_alphanumeric)
|| !region
.iter()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
{
return Err(io::Error::other("invalid Wasabi region"));
}
let endpoint = if self.region == "us-east-1" {
WASABI_US_EAST_ENDPOINT.to_string()
} else {
format!("https://s3.{}.wasabisys.com", self.region)
};
if !self.endpoint.is_empty() && self.endpoint != endpoint && self.alternative_endpoint() != Some(self.endpoint.as_str()) {
return Err(io::Error::other(format!("Wasabi endpoint must be {endpoint}")));
}
Ok(endpoint)
}
pub(crate) fn normalize_endpoint(&mut self) -> io::Result<()> {
self.endpoint = self.canonical_endpoint()?;
Ok(())
}
}
impl TierS3 {
#[allow(dead_code)]
fn create<F>(
@@ -715,200 +615,3 @@ pub struct TierR2 {
pub prefix: String,
pub region: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn wasabi_config() -> TierWasabi {
TierWasabi {
name: "COLD-WASABI".to_string(),
access_key: "access".to_string(),
secret_key: "secret".to_string(),
bucket: "archive".to_string(),
prefix: "objects".to_string(),
region: "ap-northeast-1".to_string(),
..Default::default()
}
}
#[test]
fn wasabi_type_and_endpoint_are_canonical() {
let mut wasabi = wasabi_config();
let endpoint = wasabi.canonical_endpoint().expect("valid region should produce an endpoint");
assert_eq!(endpoint, "https://s3.ap-northeast-1.wasabisys.com");
wasabi
.normalize_endpoint()
.expect("valid region should normalize the endpoint");
assert_eq!(wasabi.endpoint, endpoint);
assert_eq!(
wasabi.canonical_endpoint().expect("canonical endpoint should be accepted"),
"https://s3.ap-northeast-1.wasabisys.com"
);
assert_eq!(TierType::new("Wasabi").to_string(), "Wasabi");
assert_eq!(TierType::Wasabi.as_lowercase(), "wasabi");
for (region, alternative, canonical) in [
("us-east-1", "https://s3.us-east-1.wasabisys.com", "https://s3.wasabisys.com"),
("eu-central-1", "https://s3.nl-1.wasabisys.com", "https://s3.eu-central-1.wasabisys.com"),
("eu-central-2", "https://s3.de-1.wasabisys.com", "https://s3.eu-central-2.wasabisys.com"),
("eu-west-1", "https://s3.uk-1.wasabisys.com", "https://s3.eu-west-1.wasabisys.com"),
("eu-west-2", "https://s3.fr-1.wasabisys.com", "https://s3.eu-west-2.wasabisys.com"),
("eu-west-3", "https://s3.uk-2.wasabisys.com", "https://s3.eu-west-3.wasabisys.com"),
("eu-south-1", "https://s3.it-1.wasabisys.com", "https://s3.eu-south-1.wasabisys.com"),
] {
let mut config = wasabi_config();
config.region = region.to_string();
config.endpoint = alternative.to_string();
config
.normalize_endpoint()
.expect("a documented Wasabi alternative endpoint should normalize");
assert_eq!(config.endpoint, canonical, "region {region}");
}
}
#[test]
fn wasabi_endpoint_rejects_unsafe_region_and_custom_endpoint() {
for region in [
"",
"US-EAST-1",
"-us-east-1",
"us-east-1-",
"us.east-1",
"us/east-1",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
] {
let mut wasabi = wasabi_config();
wasabi.region = region.to_string();
let err = wasabi.canonical_endpoint().expect_err("unsafe region must be rejected");
assert_eq!(err.to_string(), "invalid Wasabi region", "region {region}");
}
let mut wasabi = wasabi_config();
wasabi.endpoint = "https://s3.example.invalid".to_string();
let err = wasabi.canonical_endpoint().expect_err("custom endpoint must be rejected");
assert!(err.to_string().contains("https://s3.ap-northeast-1.wasabisys.com"));
}
#[test]
fn wasabi_config_rejects_unknown_fields_and_redacts_secret() {
let omitted_optional = serde_json::from_value::<TierWasabi>(serde_json::json!({
"name": "COLD-WASABI",
"accessKey": "access",
"secretKey": "secret",
"bucket": "archive",
"region": "us-east-1"
}))
.expect("endpoint and prefix should remain optional in Admin JSON");
assert!(omitted_optional.endpoint.is_empty());
assert!(omitted_optional.prefix.is_empty());
assert_eq!(
omitted_optional
.canonical_endpoint()
.expect("an omitted endpoint should be derived from the region"),
WASABI_US_EAST_ENDPOINT
);
let err = serde_json::from_value::<TierWasabi>(serde_json::json!({
"name": "COLD-WASABI",
"accessKey": "access",
"secretKey": "secret",
"bucket": "archive",
"region": "us-east-1",
"unexpected": true
}))
.expect_err("unknown Wasabi fields must be rejected");
assert!(err.to_string().contains("unknown field `unexpected`"), "{err}");
let err = serde_json::from_value::<TierWasabi>(serde_json::json!({
"name": "COLD-WASABI",
"accessKey": "access",
"bucket": "archive",
"region": "us-east-1"
}))
.expect_err("missing Wasabi credentials must be rejected during decoding");
assert!(err.to_string().contains("missing field `secretKey`"), "{err}");
let config = TierConfig {
tier_type: TierType::Wasabi,
wasabi: Some(wasabi_config()),
..Default::default()
};
let redacted = config.clone();
assert_eq!(
redacted
.wasabi
.as_ref()
.expect("redacted Wasabi payload should remain")
.secret_key,
"REDACTED"
);
assert_eq!(
config
.clone_with_credentials()
.wasabi
.as_ref()
.expect("credential-bearing Wasabi payload should remain")
.secret_key,
"secret"
);
let mut debug_config = wasabi_config();
debug_config.secret_key = "wasabi-debug-secret-value".to_string();
let debug = format!("{debug_config:?}");
assert!(debug.contains("REDACTED"));
assert!(!debug.contains("wasabi-debug-secret-value"));
}
#[test]
fn wasabi_admin_json_shape_roundtrips_and_redacts() {
let mut wasabi = wasabi_config();
wasabi
.normalize_endpoint()
.expect("valid Wasabi configuration should normalize");
let config = TierConfig {
tier_type: TierType::Wasabi,
wasabi: Some(wasabi),
..Default::default()
};
let expected = serde_json::json!({
"type": "wasabi",
"wasabi": {
"name": "COLD-WASABI",
"endpoint": "https://s3.ap-northeast-1.wasabisys.com",
"accessKey": "access",
"secretKey": "secret",
"bucket": "archive",
"prefix": "objects",
"region": "ap-northeast-1"
}
});
let encoded = serde_json::to_value(config.clone_with_credentials()).expect("Wasabi Admin JSON should encode");
assert_eq!(encoded, expected);
let decoded: TierConfig = serde_json::from_value(encoded).expect("Wasabi Admin JSON should decode");
assert!(matches!(decoded.tier_type, TierType::Wasabi));
let redacted = config.clone();
assert_eq!(
config
.wasabi
.as_ref()
.expect("source Wasabi payload should remain")
.secret_key,
"secret"
);
assert_eq!(
redacted
.wasabi
.as_ref()
.expect("redacted Wasabi payload should remain")
.secret_key,
"REDACTED"
);
assert_eq!(
serde_json::to_value(redacted).expect("redacted Wasabi JSON should encode")["wasabi"]["secretKey"],
"REDACTED"
);
}
}
@@ -1,785 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::Arc;
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::config::com;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result as EcstoreResult};
use crate::object_api::ObjectOptions;
use crate::services::tier::tier::TierDestinationId;
use crate::storage_api_contracts::{list::ListOperations as _, object::HTTPPreconditions};
use crate::store::ECStore;
pub(crate) const TIER_MUTATION_INTENT_SCHEMA: &str = "rustfs-tier-mutation-intent-v1";
pub(crate) const MAX_TIER_MUTATION_INTENT_SIZE: usize = rustfs_protos::TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE;
pub(crate) const TIER_MUTATION_INTENT_RECORD_PREFIX: &str = "tier/mutation-intents/records";
pub(crate) type TierMutationDigest = [u8; 32];
pub(crate) type Result<T> = std::result::Result<T, TierMutationIntentError>;
#[derive(Debug, thiserror::Error)]
pub(crate) enum TierMutationIntentError {
#[error("tier mutation intent is corrupt: {0}")]
Corrupt(&'static str),
#[error("tier mutation intent schema is unsupported: {0}")]
UnsupportedSchema(String),
#[error("tier mutation intent checksum mismatch")]
ChecksumMismatch,
#[error("invalid tier mutation intent state change from {from:?} to {to:?}")]
InvalidStateChange {
from: TierMutationIntentState,
to: TierMutationIntentState,
},
#[error("tier mutation intent json error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum TierMutationIntentState {
Prepared,
Committed,
Aborted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum TierMutationIntentKind {
Add,
Edit,
Remove,
Clear,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TierMutationIntentTarget {
pub tier_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_backend_identity: Option<TierDestinationId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_backend_identity: Option<TierDestinationId>,
}
impl TierMutationIntentTarget {
pub(crate) fn validate(&self) -> Result<()> {
if self.tier_name.is_empty() {
return Err(TierMutationIntentError::Corrupt("target tier name is empty"));
}
if self.old_backend_identity.is_none() && self.new_backend_identity.is_none() {
return Err(TierMutationIntentError::Corrupt("target has no backend identity"));
}
if self.old_backend_identity.as_ref().is_some_and(identity_is_empty) {
return Err(TierMutationIntentError::Corrupt("target old backend identity is empty"));
}
if self.new_backend_identity.as_ref().is_some_and(identity_is_empty) {
return Err(TierMutationIntentError::Corrupt("target new backend identity is empty"));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TierMutationIntent {
pub mutation_id: Uuid,
pub revision: u64,
pub kind: TierMutationIntentKind,
pub state: TierMutationIntentState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_config_etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub committed_config_etag: Option<String>,
pub candidate_digest: TierMutationDigest,
pub affected_targets: Vec<TierMutationIntentTarget>,
pub expires_at_unix_nanos: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TierMutationIntentRecordScan {
pub intents: Vec<TierMutationIntent>,
pub scanned: usize,
pub failed: usize,
pub next_marker: Option<String>,
pub truncated: bool,
}
impl TierMutationIntent {
pub(crate) fn validate(&self) -> Result<()> {
if self.mutation_id.is_nil() {
return Err(TierMutationIntentError::Corrupt("mutation_id is nil"));
}
if self.revision == 0 {
return Err(TierMutationIntentError::Corrupt("revision is zero"));
}
if self.old_config_etag.as_deref().is_some_and(str::is_empty) {
return Err(TierMutationIntentError::Corrupt("old config etag is empty"));
}
if self.old_config_etag.is_none() && self.kind != TierMutationIntentKind::Add {
return Err(TierMutationIntentError::Corrupt("old config etag is required for mutation kind"));
}
if self.committed_config_etag.as_deref().is_some_and(str::is_empty) {
return Err(TierMutationIntentError::Corrupt("committed config etag is empty"));
}
if self.state != TierMutationIntentState::Committed && self.committed_config_etag.is_some() {
return Err(TierMutationIntentError::Corrupt("non-committed intent carries committed config etag"));
}
if self.state == TierMutationIntentState::Committed && self.committed_config_etag.is_none() {
return Err(TierMutationIntentError::Corrupt("committed intent is missing committed config etag"));
}
if digest_is_empty(&self.candidate_digest) {
return Err(TierMutationIntentError::Corrupt("candidate digest is empty"));
}
if self.affected_targets.is_empty() {
return Err(TierMutationIntentError::Corrupt("affected targets are empty"));
}
if self.expires_at_unix_nanos <= 0 {
return Err(TierMutationIntentError::Corrupt("expiry timestamp is not positive"));
}
let mut previous: Option<&str> = None;
for target in &self.affected_targets {
target.validate()?;
self.validate_target_shape(target)?;
if previous.is_some_and(|previous| previous >= target.tier_name.as_str()) {
return Err(TierMutationIntentError::Corrupt("affected targets are not canonical"));
}
previous = Some(&target.tier_name);
}
Ok(())
}
fn validate_target_shape(&self, target: &TierMutationIntentTarget) -> Result<()> {
match self.kind {
TierMutationIntentKind::Add if target.old_backend_identity.is_none() && target.new_backend_identity.is_some() => {
Ok(())
}
TierMutationIntentKind::Edit if target.old_backend_identity.is_some() && target.new_backend_identity.is_some() => {
Ok(())
}
TierMutationIntentKind::Remove | TierMutationIntentKind::Clear
if target.old_backend_identity.is_some() && target.new_backend_identity.is_none() =>
{
Ok(())
}
_ => Err(TierMutationIntentError::Corrupt(
"target backend identity shape does not match mutation kind",
)),
}
}
pub(crate) fn advance(&mut self, next: TierMutationIntentState, committed_config_etag: Option<String>) -> Result<()> {
match (self.state, next) {
(TierMutationIntentState::Prepared, TierMutationIntentState::Committed) => {
let committed_config_etag =
committed_config_etag.ok_or(TierMutationIntentError::Corrupt("commit requires config etag"))?;
if committed_config_etag.is_empty() {
return Err(TierMutationIntentError::Corrupt("commit config etag is empty"));
}
self.state = next;
self.committed_config_etag = Some(committed_config_etag);
}
(TierMutationIntentState::Prepared, TierMutationIntentState::Aborted) => {
if committed_config_etag.is_some() {
return Err(TierMutationIntentError::Corrupt("abort must not carry committed config etag"));
}
self.state = next;
self.committed_config_etag = None;
}
_ => {
return Err(TierMutationIntentError::InvalidStateChange {
from: self.state,
to: next,
});
}
}
self.revision = self
.revision
.checked_add(1)
.ok_or(TierMutationIntentError::Corrupt("revision overflow"))?;
self.validate()
}
pub(crate) fn advance_idempotent(
&mut self,
next: TierMutationIntentState,
committed_config_etag: Option<String>,
) -> Result<bool> {
match (self.state, next) {
(TierMutationIntentState::Prepared, TierMutationIntentState::Committed | TierMutationIntentState::Aborted) => {
self.advance(next, committed_config_etag)?;
Ok(true)
}
(TierMutationIntentState::Committed, TierMutationIntentState::Committed) => {
let committed_config_etag =
committed_config_etag.ok_or(TierMutationIntentError::Corrupt("commit requires config etag"))?;
if committed_config_etag.is_empty() {
return Err(TierMutationIntentError::Corrupt("commit config etag is empty"));
}
if self.committed_config_etag.as_deref() != Some(committed_config_etag.as_str()) {
return Err(TierMutationIntentError::Corrupt("committed config etag does not match"));
}
self.validate()?;
Ok(false)
}
(TierMutationIntentState::Aborted, TierMutationIntentState::Aborted) => {
if committed_config_etag.is_some() {
return Err(TierMutationIntentError::Corrupt("abort must not carry committed config etag"));
}
self.validate()?;
Ok(false)
}
_ => Err(TierMutationIntentError::InvalidStateChange {
from: self.state,
to: next,
}),
}
}
pub(crate) fn encode(&self) -> Result<Vec<u8>> {
self.validate()?;
let intent_bytes = serde_json::to_vec(self)?;
let content_sha256 = hex_sha256(&intent_bytes, ToOwned::to_owned);
let persisted = PersistedTierMutationIntent {
schema: TIER_MUTATION_INTENT_SCHEMA.to_string(),
content_sha256,
intent: self.clone(),
};
let encoded = serde_json::to_vec(&persisted)?;
if encoded.len() > MAX_TIER_MUTATION_INTENT_SIZE {
return Err(TierMutationIntentError::Corrupt("encoded intent exceeds maximum size"));
}
Ok(encoded)
}
pub(crate) fn decode(expected_mutation_id: Uuid, data: &[u8]) -> Result<Self> {
if data.len() > MAX_TIER_MUTATION_INTENT_SIZE {
return Err(TierMutationIntentError::Corrupt("encoded intent exceeds maximum size"));
}
let persisted: PersistedTierMutationIntent = serde_json::from_slice(data)?;
if persisted.schema != TIER_MUTATION_INTENT_SCHEMA {
return Err(TierMutationIntentError::UnsupportedSchema(persisted.schema));
}
if !is_sha256_checksum(&persisted.content_sha256) {
return Err(TierMutationIntentError::Corrupt("content checksum is not a sha256 checksum"));
}
let intent_bytes = serde_json::to_vec(&persisted.intent)?;
let actual_checksum = hex_sha256(&intent_bytes, ToOwned::to_owned);
if persisted.content_sha256 != actual_checksum {
return Err(TierMutationIntentError::ChecksumMismatch);
}
if persisted.intent.mutation_id != expected_mutation_id {
return Err(TierMutationIntentError::Corrupt("mutation_id does not match intent key"));
}
persisted.intent.validate()?;
Ok(persisted.intent)
}
}
pub(crate) fn tier_mutation_intent_record_object_name(mutation_id: Uuid) -> Result<String> {
if mutation_id.is_nil() {
return Err(TierMutationIntentError::Corrupt("mutation_id is nil"));
}
let mutation_key = mutation_id.simple().to_string();
Ok(format!(
"{}/{}/{}/{}.json",
TIER_MUTATION_INTENT_RECORD_PREFIX,
&mutation_key[..2],
&mutation_key[2..4],
mutation_key
))
}
pub(crate) fn tier_mutation_intent_id_from_record_object_name(object: &str) -> Result<Uuid> {
let prefix = format!("{TIER_MUTATION_INTENT_RECORD_PREFIX}/");
let suffix = object
.strip_prefix(&prefix)
.ok_or(TierMutationIntentError::Corrupt("intent record path has wrong prefix"))?;
let mut parts = suffix.split('/');
let shard_a = parts
.next()
.ok_or(TierMutationIntentError::Corrupt("intent record path is incomplete"))?;
let shard_b = parts
.next()
.ok_or(TierMutationIntentError::Corrupt("intent record path is incomplete"))?;
let file_name = parts
.next()
.ok_or(TierMutationIntentError::Corrupt("intent record path is incomplete"))?;
if parts.next().is_some() {
return Err(TierMutationIntentError::Corrupt("intent record path is not canonical"));
}
let mutation_key = file_name
.strip_suffix(".json")
.ok_or(TierMutationIntentError::Corrupt("intent record path has wrong suffix"))?;
if mutation_key.len() != 32
|| !mutation_key
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
{
return Err(TierMutationIntentError::Corrupt("intent record path has invalid mutation id"));
}
if shard_a != &mutation_key[..2] || shard_b != &mutation_key[2..4] {
return Err(TierMutationIntentError::Corrupt("intent record path shard does not match mutation id"));
}
Uuid::parse_str(mutation_key).map_err(|_| TierMutationIntentError::Corrupt("intent record path has invalid uuid"))
}
pub(crate) async fn save_tier_mutation_intent_record(api: Arc<ECStore>, intent: &TierMutationIntent) -> EcstoreResult<()> {
let object = tier_mutation_intent_record_object_name(intent.mutation_id).map_err(tier_mutation_intent_store_error)?;
let data = intent.encode().map_err(tier_mutation_intent_store_error)?;
com::save_config(api, &object, data).await
}
pub(crate) async fn save_tier_mutation_intent_record_if_absent(
api: Arc<ECStore>,
intent: &TierMutationIntent,
) -> EcstoreResult<()> {
let object = tier_mutation_intent_record_object_name(intent.mutation_id).map_err(tier_mutation_intent_store_error)?;
let data = intent.encode().map_err(tier_mutation_intent_store_error)?;
com::save_config_with_opts(
api,
&object,
data,
&ObjectOptions {
max_parity: true,
http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()),
..Default::default()
}),
..Default::default()
},
)
.await
}
pub(crate) async fn load_tier_mutation_intent_record(api: Arc<ECStore>, mutation_id: Uuid) -> EcstoreResult<TierMutationIntent> {
let (intent, _) = load_tier_mutation_intent_record_with_etag(api, mutation_id).await?;
Ok(intent)
}
pub(crate) async fn load_tier_mutation_intent_record_with_etag(
api: Arc<ECStore>,
mutation_id: Uuid,
) -> EcstoreResult<(TierMutationIntent, String)> {
let object = tier_mutation_intent_record_object_name(mutation_id).map_err(tier_mutation_intent_store_error)?;
let (data, object_info) = com::read_config_with_metadata(api, &object, &ObjectOptions::default()).await?;
let etag = object_info
.etag
.filter(|etag| !etag.trim().is_empty())
.ok_or_else(|| Error::other("tier mutation intent record is missing an ETag"))?;
let intent = TierMutationIntent::decode(mutation_id, &data).map_err(tier_mutation_intent_store_error)?;
Ok((intent, etag))
}
pub(crate) async fn save_tier_mutation_intent_record_if_current(
api: Arc<ECStore>,
intent: &TierMutationIntent,
current_etag: &str,
) -> EcstoreResult<()> {
if current_etag.trim().is_empty() {
return Err(Error::other("tier mutation intent current ETag is empty"));
}
let object = tier_mutation_intent_record_object_name(intent.mutation_id).map_err(tier_mutation_intent_store_error)?;
let data = intent.encode().map_err(tier_mutation_intent_store_error)?;
com::save_config_with_opts(
api,
&object,
data,
&ObjectOptions {
max_parity: true,
http_preconditions: Some(HTTPPreconditions {
if_match: Some(current_etag.to_string()),
..Default::default()
}),
..Default::default()
},
)
.await
}
pub(crate) async fn delete_tier_mutation_intent_record(api: Arc<ECStore>, mutation_id: Uuid) -> EcstoreResult<()> {
let object = tier_mutation_intent_record_object_name(mutation_id).map_err(tier_mutation_intent_store_error)?;
match com::delete_config(api, &object).await {
Ok(()) | Err(Error::ConfigNotFound) => Ok(()),
Err(err) => Err(err),
}
}
pub(crate) async fn advance_tier_mutation_intent_record_idempotent(
api: Arc<ECStore>,
mutation_id: Uuid,
next: TierMutationIntentState,
committed_config_etag: Option<String>,
) -> EcstoreResult<(TierMutationIntent, bool)> {
let (mut intent, current_etag) = load_tier_mutation_intent_record_with_etag(api.clone(), mutation_id).await?;
let advanced = intent
.advance_idempotent(next, committed_config_etag)
.map_err(tier_mutation_intent_store_error)?;
if advanced {
save_tier_mutation_intent_record_if_current(api, &intent, &current_etag).await?;
}
Ok((intent, advanced))
}
pub(crate) async fn list_tier_mutation_intent_records(
api: Arc<ECStore>,
limit: usize,
marker: Option<String>,
) -> EcstoreResult<TierMutationIntentRecordScan> {
if limit == 0 {
return Err(Error::other("tier mutation intent scan limit must be greater than zero"));
}
let list = api
.clone()
.list_objects_v2(
RUSTFS_META_BUCKET,
TIER_MUTATION_INTENT_RECORD_PREFIX,
marker,
None,
i32::try_from(limit).map_or(i32::MAX, |value| value),
false,
None,
false,
)
.await?;
let mut scan = TierMutationIntentRecordScan {
intents: Vec::with_capacity(list.objects.len()),
scanned: 0,
failed: 0,
next_marker: list.next_continuation_token,
truncated: list.is_truncated,
};
for object in list.objects {
scan.scanned += 1;
let mutation_id = match tier_mutation_intent_id_from_record_object_name(&object.name) {
Ok(mutation_id) => mutation_id,
Err(_) => {
scan.failed += 1;
continue;
}
};
match load_tier_mutation_intent_record(api.clone(), mutation_id).await {
Ok(intent) => scan.intents.push(intent),
Err(Error::ConfigNotFound) => {}
Err(_) => scan.failed += 1,
}
}
Ok(scan)
}
fn tier_mutation_intent_store_error(err: TierMutationIntentError) -> Error {
Error::other(err)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PersistedTierMutationIntent {
schema: String,
content_sha256: String,
intent: TierMutationIntent,
}
fn identity_is_empty(identity: &TierDestinationId) -> bool {
identity.iter().all(|byte| *byte == 0)
}
fn digest_is_empty(digest: &TierMutationDigest) -> bool {
digest.iter().all(|byte| *byte == 0)
}
#[cfg(test)]
mod tests {
use super::*;
const OLD_IDENTITY: TierDestinationId = [1; 32];
const NEW_IDENTITY: TierDestinationId = [2; 32];
const CANDIDATE_DIGEST: TierMutationDigest = [3; 32];
fn prepared_intent() -> TierMutationIntent {
TierMutationIntent {
mutation_id: Uuid::new_v4(),
revision: 1,
kind: TierMutationIntentKind::Edit,
state: TierMutationIntentState::Prepared,
old_config_etag: Some("old-etag".to_string()),
committed_config_etag: None,
candidate_digest: CANDIDATE_DIGEST,
affected_targets: vec![TierMutationIntentTarget {
tier_name: "COLD-A".to_string(),
old_backend_identity: Some(OLD_IDENTITY),
new_backend_identity: Some(NEW_IDENTITY),
}],
expires_at_unix_nanos: 1_780_000_000_000_000_000,
}
}
#[test]
fn intent_round_trip_preserves_committed_state() {
let mut intent = prepared_intent();
intent
.advance(TierMutationIntentState::Committed, Some("new-etag".to_string()))
.expect("intent should commit");
let encoded = intent.encode().expect("intent should encode");
let decoded = TierMutationIntent::decode(intent.mutation_id, &encoded).expect("intent should decode");
assert_eq!(decoded, intent);
assert_eq!(decoded.committed_config_etag.as_deref(), Some("new-etag"));
}
#[test]
fn intent_decode_rejects_unknown_fields_and_checksum_mismatch() {
let intent = prepared_intent();
let encoded = intent.encode().expect("intent should encode");
let mut persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded intent should be json");
persisted["intent"]["unexpected"] = serde_json::Value::Bool(true);
let unknown = serde_json::to_vec(&persisted).expect("mutated intent should encode");
assert!(matches!(
TierMutationIntent::decode(intent.mutation_id, &unknown),
Err(TierMutationIntentError::Json(_))
));
let mut persisted: serde_json::Value = serde_json::from_slice(&encoded).expect("encoded intent should be json");
persisted["intent"]["revision"] = serde_json::Value::from(2_u64);
let mismatched = serde_json::to_vec(&persisted).expect("mutated intent should encode");
assert!(matches!(
TierMutationIntent::decode(intent.mutation_id, &mismatched),
Err(TierMutationIntentError::ChecksumMismatch)
));
}
#[test]
fn intent_validation_requires_canonical_targets() {
let mut duplicate = prepared_intent();
duplicate.affected_targets.push(TierMutationIntentTarget {
tier_name: "COLD-A".to_string(),
old_backend_identity: Some(OLD_IDENTITY),
new_backend_identity: Some(NEW_IDENTITY),
});
assert!(matches!(
duplicate.validate(),
Err(TierMutationIntentError::Corrupt("affected targets are not canonical"))
));
let mut unsorted = prepared_intent();
unsorted.affected_targets.insert(
0,
TierMutationIntentTarget {
tier_name: "COLD-Z".to_string(),
old_backend_identity: Some(OLD_IDENTITY),
new_backend_identity: Some(NEW_IDENTITY),
},
);
assert!(matches!(
unsorted.validate(),
Err(TierMutationIntentError::Corrupt("affected targets are not canonical"))
));
}
#[test]
fn intent_state_machine_rejects_late_abort_after_commit() {
let mut intent = prepared_intent();
intent
.advance(TierMutationIntentState::Committed, Some("new-etag".to_string()))
.expect("intent should commit");
assert!(matches!(
intent.advance(TierMutationIntentState::Aborted, None),
Err(TierMutationIntentError::InvalidStateChange {
from: TierMutationIntentState::Committed,
to: TierMutationIntentState::Aborted,
})
));
}
#[test]
fn intent_idempotent_commit_retry_keeps_revision_and_rejects_conflicts() {
let mut intent = prepared_intent();
assert!(
intent
.advance_idempotent(TierMutationIntentState::Committed, Some("new-etag".to_string()))
.expect("initial commit should advance")
);
assert_eq!(intent.revision, 2);
assert!(
!intent
.advance_idempotent(TierMutationIntentState::Committed, Some("new-etag".to_string()))
.expect("same commit retry should be idempotent")
);
assert_eq!(intent.revision, 2);
assert!(matches!(
intent.advance_idempotent(TierMutationIntentState::Committed, Some("other-etag".to_string())),
Err(TierMutationIntentError::Corrupt("committed config etag does not match"))
));
assert!(matches!(
intent.advance_idempotent(TierMutationIntentState::Aborted, None),
Err(TierMutationIntentError::InvalidStateChange {
from: TierMutationIntentState::Committed,
to: TierMutationIntentState::Aborted,
})
));
}
#[test]
fn intent_idempotent_abort_retry_keeps_revision_and_rejects_commit() {
let mut intent = prepared_intent();
assert!(
intent
.advance_idempotent(TierMutationIntentState::Aborted, None)
.expect("initial abort should advance")
);
assert_eq!(intent.revision, 2);
assert!(
!intent
.advance_idempotent(TierMutationIntentState::Aborted, None)
.expect("same abort retry should be idempotent")
);
assert_eq!(intent.revision, 2);
assert!(matches!(
intent.advance_idempotent(TierMutationIntentState::Aborted, Some("unexpected-etag".to_string())),
Err(TierMutationIntentError::Corrupt("abort must not carry committed config etag"))
));
assert!(matches!(
intent.advance_idempotent(TierMutationIntentState::Committed, Some("new-etag".to_string())),
Err(TierMutationIntentError::InvalidStateChange {
from: TierMutationIntentState::Aborted,
to: TierMutationIntentState::Committed,
})
));
}
#[test]
fn intent_validation_rejects_placeholder_identity() {
let mut intent = prepared_intent();
intent.affected_targets[0].old_backend_identity = Some([0; 32]);
assert!(matches!(
intent.validate(),
Err(TierMutationIntentError::Corrupt("target old backend identity is empty"))
));
}
#[test]
fn intent_allows_initial_config_create_without_old_etag() {
let mut intent = prepared_intent();
intent.kind = TierMutationIntentKind::Add;
intent.old_config_etag = None;
intent.affected_targets[0].old_backend_identity = None;
let encoded = intent.encode().expect("initial create intent should encode");
let decoded = TierMutationIntent::decode(intent.mutation_id, &encoded).expect("initial create intent should decode");
assert_eq!(decoded.old_config_etag, None);
assert_eq!(decoded.kind, TierMutationIntentKind::Add);
}
#[test]
fn intent_record_object_name_is_canonical_and_reversible() {
let mutation_id = Uuid::parse_str("12345678-1234-5678-9abc-def012345678").expect("test uuid should parse");
let object = tier_mutation_intent_record_object_name(mutation_id).expect("record object should build");
let parsed = tier_mutation_intent_id_from_record_object_name(&object).expect("record object should parse");
assert_eq!(object, "tier/mutation-intents/records/12/34/12345678123456789abcdef012345678.json");
assert_eq!(parsed, mutation_id);
}
#[test]
fn intent_record_object_name_rejects_nil_and_malformed_paths() {
assert!(matches!(
tier_mutation_intent_record_object_name(Uuid::nil()),
Err(TierMutationIntentError::Corrupt("mutation_id is nil"))
));
assert!(matches!(
tier_mutation_intent_id_from_record_object_name("tier/mutation-intents/records/12/34/not-a-uuid.json"),
Err(TierMutationIntentError::Corrupt("intent record path has invalid mutation id"))
));
assert!(matches!(
tier_mutation_intent_id_from_record_object_name(
"tier/mutation-intents/records/12/34/12345678123456789abcdef012345678"
),
Err(TierMutationIntentError::Corrupt("intent record path has wrong suffix"))
));
assert!(matches!(
tier_mutation_intent_id_from_record_object_name(
"tier/mutation-intents/records/12/35/12345678123456789abcdef012345678.json"
),
Err(TierMutationIntentError::Corrupt("intent record path shard does not match mutation id"))
));
assert!(matches!(
tier_mutation_intent_id_from_record_object_name(
"tier/mutation-intents/records/12/34/12345678123456789abcdef012345678/extra.json"
),
Err(TierMutationIntentError::Corrupt("intent record path is not canonical"))
));
assert!(matches!(
tier_mutation_intent_id_from_record_object_name(
"tier/mutation-intents/records/12/34/12345678123456789ABCDEF012345678.json"
),
Err(TierMutationIntentError::Corrupt("intent record path has invalid mutation id"))
));
}
#[test]
fn intent_decode_rejects_record_key_mismatch() {
let intent = prepared_intent();
let encoded = intent.encode().expect("intent should encode");
let wrong_id = Uuid::new_v4();
assert!(matches!(
TierMutationIntent::decode(wrong_id, &encoded),
Err(TierMutationIntentError::Corrupt("mutation_id does not match intent key"))
));
}
#[test]
fn intent_validation_rejects_target_shape_mismatch() {
let mut add = prepared_intent();
add.kind = TierMutationIntentKind::Add;
assert!(matches!(
add.validate(),
Err(TierMutationIntentError::Corrupt(
"target backend identity shape does not match mutation kind"
))
));
let mut remove = prepared_intent();
remove.kind = TierMutationIntentKind::Remove;
assert!(matches!(
remove.validate(),
Err(TierMutationIntentError::Corrupt(
"target backend identity shape does not match mutation kind"
))
));
let mut clear_without_etag = prepared_intent();
clear_without_etag.kind = TierMutationIntentKind::Clear;
clear_without_etag.old_config_etag = None;
clear_without_etag.affected_targets[0].new_backend_identity = None;
assert!(matches!(
clear_without_etag.validate(),
Err(TierMutationIntentError::Corrupt("old config etag is required for mutation kind"))
));
}
}
@@ -1,278 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::sync::Arc;
use rustfs_protos::{TIER_MUTATION_RPC_PROTOCOL_VERSION, TierMutationRpcPhase};
use uuid::Uuid;
use super::tier::TierConfigMgr;
use super::tier_mutation_intent::{
MAX_TIER_MUTATION_INTENT_SIZE, TierMutationIntent, TierMutationIntentState, advance_tier_mutation_intent_record_idempotent,
load_tier_mutation_intent_record, save_tier_mutation_intent_record_if_absent,
};
use crate::client::admin_handler_utils::AdminError;
use crate::error::{Error, StorageError};
use crate::store::ECStore;
pub const MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE: usize = rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TierMutationPeerState {
Prepared,
Committed,
Aborted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TierMutationPeerOutcome {
pub state: TierMutationPeerState,
pub applied: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum TierMutationPeerError {
#[error("unsupported tier mutation peer protocol version: {0}")]
UnsupportedProtocolVersion(u32),
#[error("tier mutation peer mutation_id is nil")]
NilMutationId,
#[error("tier mutation peer payload is too large: {len}/{max}")]
PayloadTooLarge { len: usize, max: usize },
#[error("tier mutation peer payload is invalid: {0}")]
InvalidPayload(String),
#[error("tier mutation peer intent conflicts with existing record")]
ConflictingIntent,
#[error("tier mutation peer runtime error: {0}")]
Runtime(#[source] AdminError),
#[error("tier mutation peer store error: {0}")]
Store(#[source] StorageError),
}
impl From<Error> for TierMutationPeerError {
fn from(error: Error) -> Self {
Self::Store(error)
}
}
pub type TierMutationPeerResult<T> = std::result::Result<T, TierMutationPeerError>;
pub async fn handle_tier_mutation_peer_request(
api: Arc<ECStore>,
protocol_version: u32,
phase: TierMutationRpcPhase,
mutation_id: Uuid,
canonical_payload: &[u8],
) -> TierMutationPeerResult<TierMutationPeerOutcome> {
validate_peer_request_envelope(protocol_version, mutation_id, canonical_payload)?;
match phase {
TierMutationRpcPhase::Prepare => handle_prepare(api, mutation_id, canonical_payload).await,
TierMutationRpcPhase::Commit => handle_commit(api, mutation_id, canonical_payload).await,
TierMutationRpcPhase::Abort => handle_abort(api, mutation_id, canonical_payload).await,
_ => Err(TierMutationPeerError::InvalidPayload(
"tier mutation rpc phase is unsupported".to_string(),
)),
}
}
async fn handle_prepare(
api: Arc<ECStore>,
mutation_id: Uuid,
canonical_payload: &[u8],
) -> TierMutationPeerResult<TierMutationPeerOutcome> {
let intent = TierMutationIntent::decode(mutation_id, canonical_payload)
.map_err(|err| TierMutationPeerError::InvalidPayload(err.to_string()))?;
if intent.state != TierMutationIntentState::Prepared {
return Err(TierMutationPeerError::InvalidPayload(
"prepare intent must be in prepared state".to_string(),
));
}
let tier_config_mgr = api.tier_config_mgr();
match save_tier_mutation_intent_record_if_absent(api.clone(), &intent).await {
Ok(()) => {
TierConfigMgr::apply_prepared_mutation_intent_block(&tier_config_mgr, &intent)
.await
.map_err(TierMutationPeerError::Runtime)?;
Ok(TierMutationPeerOutcome {
state: TierMutationPeerState::Prepared,
applied: true,
})
}
Err(Error::PreconditionFailed) => {
let existing = load_tier_mutation_intent_record(api, mutation_id).await?;
if !same_mutation_identity(&existing, &intent) {
return Err(TierMutationPeerError::ConflictingIntent);
}
match existing.state {
TierMutationIntentState::Prepared => {
TierConfigMgr::apply_prepared_mutation_intent_block(&tier_config_mgr, &existing)
.await
.map_err(TierMutationPeerError::Runtime)?;
}
TierMutationIntentState::Committed | TierMutationIntentState::Aborted => {
TierConfigMgr::clear_prepared_mutation_intent_block(&tier_config_mgr, mutation_id).await;
}
}
Ok(TierMutationPeerOutcome {
state: peer_state_from_intent(existing.state),
applied: false,
})
}
Err(err) => Err(err.into()),
}
}
async fn handle_commit(
api: Arc<ECStore>,
mutation_id: Uuid,
canonical_payload: &[u8],
) -> TierMutationPeerResult<TierMutationPeerOutcome> {
let committed_config_etag = parse_commit_etag(canonical_payload)?;
let tier_config_mgr = api.tier_config_mgr();
let (intent, applied) = advance_tier_mutation_intent_record_idempotent(
api,
mutation_id,
TierMutationIntentState::Committed,
Some(committed_config_etag),
)
.await?;
if intent.state == TierMutationIntentState::Committed {
TierConfigMgr::clear_prepared_mutation_intent_block(&tier_config_mgr, mutation_id).await;
}
Ok(TierMutationPeerOutcome {
state: peer_state_from_intent(intent.state),
applied,
})
}
async fn handle_abort(
api: Arc<ECStore>,
mutation_id: Uuid,
canonical_payload: &[u8],
) -> TierMutationPeerResult<TierMutationPeerOutcome> {
if !canonical_payload.is_empty() {
return Err(TierMutationPeerError::InvalidPayload("abort payload must be empty".to_string()));
}
let tier_config_mgr = api.tier_config_mgr();
let (intent, applied) =
advance_tier_mutation_intent_record_idempotent(api, mutation_id, TierMutationIntentState::Aborted, None).await?;
if intent.state == TierMutationIntentState::Aborted {
TierConfigMgr::clear_prepared_mutation_intent_block(&tier_config_mgr, mutation_id).await;
}
Ok(TierMutationPeerOutcome {
state: peer_state_from_intent(intent.state),
applied,
})
}
fn validate_peer_request_envelope(
protocol_version: u32,
mutation_id: Uuid,
canonical_payload: &[u8],
) -> TierMutationPeerResult<()> {
if protocol_version != TIER_MUTATION_RPC_PROTOCOL_VERSION {
return Err(TierMutationPeerError::UnsupportedProtocolVersion(protocol_version));
}
if mutation_id.is_nil() {
return Err(TierMutationPeerError::NilMutationId);
}
if canonical_payload.len() > MAX_TIER_MUTATION_INTENT_SIZE {
return Err(TierMutationPeerError::PayloadTooLarge {
len: canonical_payload.len(),
max: MAX_TIER_MUTATION_INTENT_SIZE,
});
}
Ok(())
}
fn parse_commit_etag(canonical_payload: &[u8]) -> TierMutationPeerResult<String> {
if canonical_payload.len() > MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE {
return Err(TierMutationPeerError::PayloadTooLarge {
len: canonical_payload.len(),
max: MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE,
});
}
let etag = std::str::from_utf8(canonical_payload)
.map_err(|err| TierMutationPeerError::InvalidPayload(err.to_string()))?
.trim();
if etag.is_empty() {
return Err(TierMutationPeerError::InvalidPayload(
"commit payload must carry a committed config etag".to_string(),
));
}
Ok(etag.to_string())
}
fn peer_state_from_intent(state: TierMutationIntentState) -> TierMutationPeerState {
match state {
TierMutationIntentState::Prepared => TierMutationPeerState::Prepared,
TierMutationIntentState::Committed => TierMutationPeerState::Committed,
TierMutationIntentState::Aborted => TierMutationPeerState::Aborted,
}
}
fn same_mutation_identity(existing: &TierMutationIntent, expected: &TierMutationIntent) -> bool {
existing.mutation_id == expected.mutation_id
&& existing.kind == expected.kind
&& existing.old_config_etag == expected.old_config_etag
&& existing.candidate_digest == expected.candidate_digest
&& existing.affected_targets == expected.affected_targets
&& existing.expires_at_unix_nanos == expected.expires_at_unix_nanos
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peer_request_envelope_fails_closed_on_old_version_nil_id_and_large_payload() {
let mutation_id = Uuid::new_v4();
assert!(matches!(
validate_peer_request_envelope(TIER_MUTATION_RPC_PROTOCOL_VERSION + 1, mutation_id, b"payload"),
Err(TierMutationPeerError::UnsupportedProtocolVersion(_))
));
assert!(matches!(
validate_peer_request_envelope(TIER_MUTATION_RPC_PROTOCOL_VERSION, Uuid::nil(), b"payload"),
Err(TierMutationPeerError::NilMutationId)
));
let oversized = vec![0; MAX_TIER_MUTATION_INTENT_SIZE + 1];
assert!(matches!(
validate_peer_request_envelope(TIER_MUTATION_RPC_PROTOCOL_VERSION, mutation_id, &oversized),
Err(TierMutationPeerError::PayloadTooLarge { .. })
));
}
#[test]
fn commit_payload_requires_small_non_empty_utf8_etag() {
assert_eq!(
parse_commit_etag(b" committed-etag ").expect("etag payload should parse"),
"committed-etag"
);
assert!(matches!(
parse_commit_etag(b" "),
Err(TierMutationPeerError::InvalidPayload(message)) if message.contains("committed config etag")
));
assert!(matches!(
parse_commit_etag(&[0xff]),
Err(TierMutationPeerError::InvalidPayload(message)) if message.contains("utf-8")
));
let oversized = vec![b'a'; MAX_TIER_MUTATION_PEER_COMMIT_ETAG_SIZE + 1];
assert!(matches!(
parse_commit_etag(&oversized),
Err(TierMutationPeerError::PayloadTooLarge { .. })
));
}
}
+13 -322
View File
@@ -25,7 +25,7 @@ use crate::client::{
};
use crate::error::is_err_bucket_not_found;
use crate::services::tier::{
tier::{ERR_TIER_INVALID_CONFIG, ERR_TIER_TYPE_UNSUPPORTED},
tier::ERR_TIER_TYPE_UNSUPPORTED,
tier_config::{TierConfig, TierType},
tier_handlers::{ERR_TIER_BUCKET_NOT_FOUND, ERR_TIER_NOT_FOUND, ERR_TIER_PERM_ERR},
warm_backend_aliyun::WarmBackendAliyun,
@@ -37,7 +37,6 @@ use crate::services::tier::{
warm_backend_rustfs::WarmBackendRustFS,
warm_backend_s3::WarmBackendS3,
warm_backend_tencent::WarmBackendTencent,
warm_backend_wasabi::WarmBackendWasabi,
};
use bytes::Bytes;
use http::StatusCode;
@@ -66,14 +65,6 @@ pub struct WarmBackendGetOpts {
#[async_trait::async_trait]
pub trait WarmBackend {
async fn validate(&self) -> Result<(), std::io::Error> {
Ok(())
}
fn validate_remote_version_id(&self, _remote_version_id: &str) -> Result<(), std::io::Error> {
Ok(())
}
/// Return `Ok` only after the backend has consumed the complete declared
/// body and its storage service has acknowledged the PUT. The built-in S3
/// family uses the transition client's declared-length request plus
@@ -92,15 +83,6 @@ pub trait WarmBackend {
) -> Result<String, std::io::Error>;
async fn get(&self, object: &str, rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error>;
async fn remove(&self, object: &str, rv: &str) -> Result<(), std::io::Error>;
async fn remove_exact(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
if rv.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"an exact tier delete requires a remote version ID",
));
}
self.remove(object, rv).await
}
async fn in_use(&self) -> Result<bool, std::io::Error>;
}
@@ -183,23 +165,16 @@ pub fn build_transition_put_options(storage_class: String, mut metadata: HashMap
pub async fn check_warm_backend(w: Option<&WarmBackendImpl>) -> Result<(), AdminError> {
let w = w.ok_or_else(|| ERR_TIER_NOT_FOUND.clone())?;
w.validate().await.map_err(|_| ERR_TIER_INVALID_CONFIG.clone())?;
let remote_version_id = w
.put(PROBE_OBJECT, ReaderImpl::Body(Bytes::from("RustFS".as_bytes().to_vec())), 5)
.await
.map_err(|_| ERR_TIER_PERM_ERR.clone())?;
if w.validate_remote_version_id(&remote_version_id).is_err() {
w.remove_exact(PROBE_OBJECT, &remote_version_id)
.await
.map_err(|_| ERR_TIER_PERM_ERR.clone())?;
return Err(ERR_TIER_INVALID_CONFIG.clone());
.await;
if let Err(err) = remote_version_id {
return Err(ERR_TIER_PERM_ERR.clone());
}
let read_result = w.get(PROBE_OBJECT, &remote_version_id, WarmBackendGetOpts::default()).await;
let remove_result = w.remove(PROBE_OBJECT, &remote_version_id).await;
let r = w.get(PROBE_OBJECT, "", WarmBackendGetOpts::default()).await;
//xhttp.DrainBody(r);
if read_result.is_err() || remove_result.is_err() {
if let Err(err) = r {
//if is_err_bucket_not_found(&err) {
// return Err(ERR_TIER_BUCKET_NOT_FOUND);
//}
@@ -210,6 +185,11 @@ pub async fn check_warm_backend(w: Option<&WarmBackendImpl>) -> Result<(), Admin
return Err(ERR_TIER_PERM_ERR.clone());
//}
}
if let Ok(version_id) = remote_version_id {
if let Err(err) = w.remove(PROBE_OBJECT, &version_id).await {
return Err(ERR_TIER_PERM_ERR.clone());
};
}
Ok(())
}
@@ -236,27 +216,6 @@ pub async fn new_warm_backend(tier: &TierConfig, probe: bool) -> Result<WarmBack
});
}
}
TierType::Wasabi => {
if let Some(wasabi_config) = tier.wasabi.as_ref() {
match WarmBackendWasabi::new(wasabi_config, &tier.name).await {
Ok(backend) => d = Some(Box::new(backend)),
Err(err) => {
warn!("{}", err);
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: format!("Unable to setup remote tier, check tier configuration: {err}"),
status_code: StatusCode::BAD_REQUEST,
});
}
}
} else {
return Err(AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: "Wasabi tier configuration not found".to_string(),
status_code: StatusCode::BAD_REQUEST,
});
}
}
TierType::RustFS => {
if let Some(rustfs_config) = tier.rustfs.as_ref() {
let dd = WarmBackendRustFS::new(rustfs_config, &tier.name).await;
@@ -422,284 +381,16 @@ pub async fn new_warm_backend(tier: &TierConfig, probe: bool) -> Result<WarmBack
}
}
let d = d.ok_or_else(|| AdminError {
d.ok_or_else(|| AdminError {
code: "XRustFSAdminTierInvalidConfig".to_string(),
message: "Tier backend not initialized".to_string(),
status_code: StatusCode::BAD_REQUEST,
})?;
if probe {
d.validate().await.map_err(|_| ERR_TIER_INVALID_CONFIG.clone())?;
}
Ok(d)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::tier::tier_config::TierWasabi;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
const PROBE_VERSION: &str = "remote-v2";
struct RejectingValidationBackend {
validations: Arc<AtomicUsize>,
puts: Arc<AtomicUsize>,
removes: Arc<AtomicUsize>,
}
struct RejectingProbeVersionBackend {
gets: Arc<AtomicUsize>,
removed_versions: Arc<tokio::sync::Mutex<Vec<String>>>,
}
struct RecordingProbeBackend {
get_versions: Arc<tokio::sync::Mutex<Vec<String>>>,
removed_versions: Arc<tokio::sync::Mutex<Vec<String>>>,
fail_get: bool,
}
#[async_trait::async_trait]
impl WarmBackend for RejectingValidationBackend {
async fn validate(&self) -> Result<(), std::io::Error> {
self.validations.fetch_add(1, Ordering::SeqCst);
Err(std::io::Error::other("invalid backend configuration"))
}
async fn put(&self, _object: &str, _r: ReaderImpl, _length: i64) -> Result<String, std::io::Error> {
self.puts.fetch_add(1, Ordering::SeqCst);
Ok(String::new())
}
async fn put_with_meta(
&self,
object: &str,
r: ReaderImpl,
length: i64,
_meta: HashMap<String, String>,
) -> Result<String, std::io::Error> {
self.put(object, r, length).await
}
async fn get(&self, _object: &str, _rv: &str, _opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
Err(std::io::Error::other("get must not run after validation failure"))
}
async fn remove(&self, _object: &str, _rv: &str) -> Result<(), std::io::Error> {
self.removes.fetch_add(1, Ordering::SeqCst);
Err(std::io::Error::other("remove must not run after validation failure"))
}
async fn in_use(&self) -> Result<bool, std::io::Error> {
Err(std::io::Error::other("in_use must not run after validation failure"))
}
}
#[async_trait::async_trait]
impl WarmBackend for RejectingProbeVersionBackend {
fn validate_remote_version_id(&self, remote_version_id: &str) -> Result<(), std::io::Error> {
if remote_version_id.is_empty() {
Ok(())
} else {
Err(std::io::Error::other("probe returned a version ID"))
}
}
async fn put(&self, _object: &str, _r: ReaderImpl, _length: i64) -> Result<String, std::io::Error> {
Ok(uuid::Uuid::nil().to_string())
}
async fn put_with_meta(
&self,
object: &str,
r: ReaderImpl,
length: i64,
_meta: HashMap<String, String>,
) -> Result<String, std::io::Error> {
self.put(object, r, length).await
}
async fn get(&self, _object: &str, _rv: &str, _opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
self.gets.fetch_add(1, Ordering::SeqCst);
Err(std::io::Error::other("GET must not run for a rejected probe version"))
}
async fn remove(&self, _object: &str, _rv: &str) -> Result<(), std::io::Error> {
Err(std::io::Error::other("generic remove must not run for a rejected fresh PUT response"))
}
async fn remove_exact(&self, _object: &str, rv: &str) -> Result<(), std::io::Error> {
self.removed_versions.lock().await.push(rv.to_string());
Ok(())
}
async fn in_use(&self) -> Result<bool, std::io::Error> {
Ok(false)
}
}
#[async_trait::async_trait]
impl WarmBackend for RecordingProbeBackend {
async fn put(&self, _object: &str, _r: ReaderImpl, _length: i64) -> Result<String, std::io::Error> {
Ok(PROBE_VERSION.to_string())
}
async fn put_with_meta(
&self,
object: &str,
r: ReaderImpl,
length: i64,
_meta: HashMap<String, String>,
) -> Result<String, std::io::Error> {
self.put(object, r, length).await
}
async fn get(&self, _object: &str, rv: &str, _opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
self.get_versions.lock().await.push(rv.to_string());
if self.fail_get {
Err(std::io::Error::other("probe GET failed"))
} else {
Ok(ReadCloser::new(std::io::Cursor::new(Vec::new())))
}
}
async fn remove(&self, _object: &str, rv: &str) -> Result<(), std::io::Error> {
self.removed_versions.lock().await.push(rv.to_string());
Ok(())
}
async fn in_use(&self) -> Result<bool, std::io::Error> {
Ok(false)
}
}
#[tokio::test]
async fn check_warm_backend_validates_before_probe_io() {
let validations = Arc::new(AtomicUsize::new(0));
let puts = Arc::new(AtomicUsize::new(0));
let removes = Arc::new(AtomicUsize::new(0));
let backend: WarmBackendImpl = Box::new(RejectingValidationBackend {
validations: validations.clone(),
puts: puts.clone(),
removes: removes.clone(),
});
let err = check_warm_backend(Some(&backend))
.await
.expect_err("invalid backend configuration should fail before probe I/O");
assert_eq!(err.code, ERR_TIER_INVALID_CONFIG.code);
assert_eq!(validations.load(Ordering::SeqCst), 1);
assert_eq!(puts.load(Ordering::SeqCst), 0);
assert_eq!(removes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn default_exact_remove_rejects_an_empty_version() {
let removes = Arc::new(AtomicUsize::new(0));
let backend = RejectingValidationBackend {
validations: Arc::new(AtomicUsize::new(0)),
puts: Arc::new(AtomicUsize::new(0)),
removes: removes.clone(),
};
let err = backend
.remove_exact("remote-object", "")
.await
.expect_err("an empty exact constraint must fail closed");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(removes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn check_warm_backend_removes_exact_probe_when_versioning_drifts() {
let gets = Arc::new(AtomicUsize::new(0));
let removed_versions = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let backend: WarmBackendImpl = Box::new(RejectingProbeVersionBackend {
gets: gets.clone(),
removed_versions: removed_versions.clone(),
});
let err = check_warm_backend(Some(&backend))
.await
.expect_err("a probe version ID must fail an unversioned backend check");
assert_eq!(err.code, ERR_TIER_INVALID_CONFIG.code);
assert_eq!(gets.load(Ordering::SeqCst), 0);
assert_eq!(removed_versions.lock().await.as_slice(), [uuid::Uuid::nil().to_string()]);
}
#[tokio::test]
async fn check_warm_backend_forwards_probe_version_to_get_and_remove() {
let get_versions = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let removed_versions = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let backend: WarmBackendImpl = Box::new(RecordingProbeBackend {
get_versions: get_versions.clone(),
removed_versions: removed_versions.clone(),
fail_get: false,
});
check_warm_backend(Some(&backend))
.await
.expect("a successful probe should validate, read, and remove its object");
assert_eq!(get_versions.lock().await.as_slice(), [PROBE_VERSION]);
assert_eq!(removed_versions.lock().await.as_slice(), [PROBE_VERSION]);
}
#[tokio::test]
async fn check_warm_backend_removes_probe_after_get_failure() {
let get_versions = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let removed_versions = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let backend: WarmBackendImpl = Box::new(RecordingProbeBackend {
get_versions: get_versions.clone(),
removed_versions: removed_versions.clone(),
fail_get: true,
});
let err = check_warm_backend(Some(&backend))
.await
.expect_err("a failed probe GET should return a permission error after cleanup");
assert_eq!(err.code, ERR_TIER_PERM_ERR.code);
assert_eq!(get_versions.lock().await.as_slice(), [PROBE_VERSION]);
assert_eq!(removed_versions.lock().await.as_slice(), [PROBE_VERSION]);
}
#[tokio::test]
async fn new_wasabi_backend_honors_probe_flag() {
let tier = TierConfig {
name: "WASABI".to_string(),
tier_type: TierType::Wasabi,
wasabi: Some(TierWasabi {
name: "WASABI".to_string(),
access_key: "invalid\naccess-key".to_string(),
secret_key: "secret-key".to_string(),
bucket: "tier-bucket".to_string(),
prefix: "archive".to_string(),
region: "us-east-1".to_string(),
..Default::default()
}),
..Default::default()
};
let backend = new_warm_backend(&tier, false)
.await
.expect("valid Wasabi config should initialize without probing the remote service");
assert!(backend.validate_remote_version_id("").is_ok());
assert!(backend.validate_remote_version_id("unexpected-version").is_err());
let err = match new_warm_backend(&tier, true).await {
Ok(_) => panic!("probing a Wasabi backend must validate its credentials"),
Err(err) => err,
};
assert_eq!(err.code, ERR_TIER_INVALID_CONFIG.code);
}
#[test]
fn build_transition_put_options_preserves_content_headers() {
@@ -25,9 +25,9 @@ use url::Url;
use crate::client::{
api_get_options::GetObjectOptions,
api_put_object::PutObjectOptions,
api_remove::{RemoveObjectOptions, RemoveObjectResult},
api_remove::RemoveObjectOptions,
credentials::{Credentials, SignatureType, Static, Value},
transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore},
transition_api::{Options, TransitionClient, TransitionCore},
transition_api::{ReadCloser, ReaderImpl},
};
use crate::error::ErrorResponse;
@@ -36,7 +36,6 @@ use crate::services::tier::{
tier_config::TierS3,
warm_backend::{WarmBackend, WarmBackendGetOpts, build_transition_put_options},
};
use http::HeaderMap;
use rustfs_utils::egress::validate_outbound_url;
use rustfs_utils::path::SLASH_SEPARATOR;
@@ -49,15 +48,7 @@ pub struct WarmBackendS3 {
}
impl WarmBackendS3 {
pub async fn new(conf: &TierS3, _tier: &str) -> Result<Self, std::io::Error> {
Self::new_with_bucket_lookup(conf, BucketLookupType::BucketLookupAuto, "s3").await
}
pub(crate) async fn new_with_bucket_lookup(
conf: &TierS3,
bucket_lookup: BucketLookupType,
tier_type: &str,
) -> Result<Self, std::io::Error> {
pub async fn new(conf: &TierS3, tier: &str) -> Result<Self, std::io::Error> {
let u = match Url::parse(&conf.endpoint) {
Ok(u) => u,
Err(err) => {
@@ -103,13 +94,12 @@ impl WarmBackendS3 {
creds,
secure: u.scheme() == "https",
region: conf.region.clone(),
bucket_lookup,
..Default::default()
};
let host = u
.host()
.ok_or_else(|| std::io::Error::other("Invalid endpoint URL: missing host"))?;
let client = TransitionClient::new(&host.to_string(), opts, tier_type).await?;
let client = TransitionClient::new(&host.to_string(), opts, "s3").await?;
let client = Arc::new(client);
let core = TransitionCore(Arc::clone(&client));
@@ -129,36 +119,6 @@ impl WarmBackendS3 {
}
return dest_obj;
}
pub(crate) async fn remove_with_result(&self, object: &str, rv: &str) -> Result<RemoveObjectResult, std::io::Error> {
let mut opts = RemoveObjectOptions::default();
if !rv.is_empty() {
opts.version_id = rv.to_string();
}
self.client
.remove_object_inner(&self.bucket, &self.get_dest(object), opts)
.await
}
pub(crate) async fn get_with_headers(
&self,
object: &str,
rv: &str,
opts: WarmBackendGetOpts,
) -> Result<(HeaderMap, ReadCloser), std::io::Error> {
let mut gopts = GetObjectOptions::default();
if !rv.is_empty() {
gopts.version_id = rv.to_string();
}
if opts.start_offset >= 0 && opts.length > 0 {
gopts
.set_range(opts.start_offset, opts.start_offset + opts.length - 1)
.map_err(std::io::Error::other)?;
}
let (_, headers, reader) = self.core.get_object(&self.bucket, &self.get_dest(object), &gopts).await?;
Ok((headers, reader))
}
}
#[cfg(test)]
@@ -208,11 +168,32 @@ impl WarmBackend for WarmBackendS3 {
}
async fn get(&self, object: &str, rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
self.get_with_headers(object, rv, opts).await.map(|(_, reader)| reader)
let mut gopts = GetObjectOptions::default();
if rv != "" {
gopts.version_id = rv.to_string();
}
if opts.start_offset >= 0 && opts.length > 0 {
if let Err(err) = gopts.set_range(opts.start_offset, opts.start_offset + opts.length - 1) {
return Err(std::io::Error::other(err));
}
}
let c = TransitionCore(Arc::clone(&self.client));
let (_, _, r) = c.get_object(&self.bucket, &self.get_dest(object), &gopts).await?;
Ok(r)
}
async fn remove(&self, object: &str, rv: &str) -> Result<(), std::io::Error> {
self.remove_with_result(object, rv).await.map(|_| ())
let mut ropts = RemoveObjectOptions::default();
if rv != "" {
ropts.version_id = rv.to_string();
}
let client = self.client.clone();
match client.remove_object(&self.bucket, &self.get_dest(object), ropts).await {
None => Ok(()),
Some(err) => Err(std::io::Error::other(err)),
}
}
async fn in_use(&self) -> Result<bool, std::io::Error> {
File diff suppressed because it is too large Load Diff
-36
View File
@@ -210,40 +210,6 @@ pub(super) fn require_restore_operation_id(metadata: &HashMap<String, String>, e
}
}
pub(super) fn restore_commit_operation_id_from_metadata(metadata: &HashMap<String, String>) -> Result<Option<Uuid>> {
if !metadata.contains_key(X_AMZ_RESTORE.as_str()) {
return Ok(None);
}
restore_operation_id_from_metadata(metadata)
}
impl SetDisks {
pub(super) async fn require_current_restore_operation_id(
&self,
bucket: &str,
object: &str,
opts: &ObjectOptions,
expected: Option<Uuid>,
mode: &str,
) -> Result<()> {
let Some(expected) = expected else {
return Ok(());
};
let read_opts = ObjectOptions {
version_id: opts.version_id.clone(),
versioned: opts.versioned,
version_suspended: opts.version_suspended,
no_lock: true,
..Default::default()
};
let (current, _, _) = self.get_object_fileinfo(bucket, object, &read_opts, true, false).await?;
restore_operation_id_from_metadata(&current.metadata)?
.filter(|actual| *actual == expected)
.ok_or_else(|| Error::other(format!("restore operation id changed before {mode}: expected {expected}")))?;
Ok(())
}
}
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
@@ -687,8 +653,6 @@ mod core;
mod ctx;
mod metadata;
mod ops;
#[cfg(feature = "test-util")]
pub(crate) use ops::object::TransitionCleanupStoreBarrier as SetDiskTransitionCleanupStoreBarrier;
pub(crate) use ops::object::body_cache_plaintext_len;
mod read;
mod replication;
+3 -176
View File
@@ -1038,20 +1038,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
}
}
let expected_restore_operation_id = restore_commit_operation_id_from_metadata(&opts.user_defined)?;
let (mut fi, files_metas) = self.check_upload_id_exists(bucket, object, upload_id, true).await?;
if expected_restore_operation_id.is_some() {
rustfs_utils::http::metadata_compat::remove_str(&mut fi.metadata, SUFFIX_RESTORE_OPERATION_ID);
}
if opts.versioned {
fi.version_id = Some(
fi.version_id
.filter(|version_id| !version_id.is_nil())
.unwrap_or_else(Uuid::new_v4),
);
} else {
fi.version_id = None;
}
let upload_id_path = Self::get_upload_id_dir(bucket, object, upload_id);
let write_quorum = fi.write_quorum(self.default_write_quorum());
@@ -1389,7 +1376,6 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
meta.parts.clone_from(&fi.parts);
meta.metadata = fi.metadata.clone();
meta.versioned = opts.versioned || opts.version_suspended;
meta.version_id = fi.version_id;
meta.checksum = fi.checksum.clone();
}
}
@@ -1435,15 +1421,6 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
});
}
self.require_current_restore_operation_id(
bucket,
object,
opts,
expected_restore_operation_id,
"complete_multipart_upload_commit",
)
.await?;
let complete_tail_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(Instant::now);
// Crash-consistency injection: hard power loss after the upload is fully
@@ -1639,7 +1616,7 @@ mod tests {
#[serial(metadata_cache_invalidation_probe)]
async fn complete_multipart_generation_retires_cached_snapshot() {
use crate::storage_api_contracts::multipart::MultipartOperations as _;
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
use crate::storage_api_contracts::object::ObjectIO as _;
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-metadata-generation-bucket";
@@ -2368,15 +2345,14 @@ mod tests {
/// Stage a single-part multipart upload without completing it. A single
/// part is the last part, so the 5 MiB minimum-part-size gate does not
/// apply. Returns the upload id and the `CompletePart` list.
async fn stage_upload_with_create_opts(
async fn stage_upload(
set_disks: &Arc<SetDisks>,
bucket: &str,
object: &str,
content: &[u8],
create_opts: &ObjectOptions,
) -> (String, Vec<CompletePart>) {
let upload = set_disks
.new_multipart_upload(bucket, object, create_opts)
.new_multipart_upload(bucket, object, &ObjectOptions::default())
.await
.expect("multipart upload should be created");
let mut reader = PutObjReader::new(
@@ -2404,43 +2380,6 @@ mod tests {
)
}
async fn stage_upload(
set_disks: &Arc<SetDisks>,
bucket: &str,
object: &str,
content: &[u8],
) -> (String, Vec<CompletePart>) {
stage_upload_with_create_opts(set_disks, bucket, object, content, &ObjectOptions::default()).await
}
async fn rewrite_staged_upload_version_id(
set_disks: &Arc<SetDisks>,
bucket: &str,
object: &str,
upload_id: &str,
version_id: Option<Uuid>,
) {
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, upload_id);
let disks = set_disks.disks.read().await.clone();
let (fi, mut files_metas) = set_disks
.check_upload_id_exists(bucket, object, upload_id, true)
.await
.expect("staged upload metadata should be readable");
for meta in files_metas.iter_mut() {
meta.version_id = version_id;
}
SetDisks::write_unique_file_info(
&disks,
bucket,
RUSTFS_META_MULTIPART_BUCKET,
&upload_id_path,
&files_metas,
fi.write_quorum(set_disks.default_write_quorum()),
)
.await
.expect("staged upload metadata should be rewritten");
}
async fn complete(
set_disks: &Arc<SetDisks>,
bucket: &str,
@@ -2454,118 +2393,6 @@ mod tests {
.await
}
#[tokio::test]
#[serial]
async fn complete_multipart_upload_assigns_completion_version_id() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-versioned-complete-bucket";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let (upload_id, parts) = stage_upload(&set_disks, bucket, object, b"versioned multipart body").await;
let complete_opts = ObjectOptions {
versioned: true,
..Default::default()
};
let completed = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, parts, &complete_opts)
.await
.expect("versioned multipart completion should succeed");
let version_id = completed.version_id.expect("versioned completion must return a version id");
assert!(!version_id.is_nil(), "versioned completion must not use a null version id");
let lookup_opts = ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
};
let current = set_disks
.get_object_info(bucket, object, &lookup_opts)
.await
.expect("completed version should be addressable by exact version id");
assert_eq!(current.version_id, Some(version_id));
}
#[tokio::test]
#[serial]
async fn complete_multipart_upload_replaces_staged_nil_version_id() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-nil-versioned-complete-bucket";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let (upload_id, parts) = stage_upload(&set_disks, bucket, object, b"versioned multipart body").await;
rewrite_staged_upload_version_id(&set_disks, bucket, object, &upload_id, Some(Uuid::nil())).await;
let complete_opts = ObjectOptions {
versioned: true,
..Default::default()
};
let completed = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, parts, &complete_opts)
.await
.expect("versioned multipart completion should succeed");
let version_id = completed.version_id.expect("versioned completion must return a version id");
assert!(!version_id.is_nil(), "versioned completion must replace a staged null version id");
let lookup_opts = ObjectOptions {
versioned: true,
version_id: Some(version_id.to_string()),
..Default::default()
};
let current = set_disks
.get_object_info(bucket, object, &lookup_opts)
.await
.expect("completed version should be addressable by exact version id");
assert_eq!(current.version_id, Some(version_id));
}
#[tokio::test]
#[serial]
async fn complete_multipart_upload_suspended_clears_staged_version_id() {
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
let bucket = "multipart-suspended-complete-bucket";
let object = "object";
make_bucket_on_all(&disk_stores, bucket).await;
let create_opts = ObjectOptions {
versioned: true,
..Default::default()
};
let (upload_id, parts) =
stage_upload_with_create_opts(&set_disks, bucket, object, b"suspended multipart body", &create_opts).await;
let (staged_fi, _) = set_disks
.check_upload_id_exists(bucket, object, &upload_id, true)
.await
.expect("staged upload metadata should be readable");
assert!(
staged_fi.version_id.is_some_and(|version_id| !version_id.is_nil()),
"enabled multipart create should stage a concrete version id"
);
let complete_opts = ObjectOptions {
version_suspended: true,
..Default::default()
};
let completed = set_disks
.clone()
.complete_multipart_upload(bucket, object, &upload_id, parts, &complete_opts)
.await
.expect("suspended multipart completion should succeed");
assert!(
completed.version_id.is_some_and(|version_id| version_id.is_nil()),
"suspended completion must publish a null-version object internally"
);
}
/// Read the whole committed object back; returns `(body, etag)`. The full
/// body read also proves the served length matches (never a torn/short body).
async fn read_object(set_disks: &Arc<SetDisks>, bucket: &str, object: &str) -> (Vec<u8>, Option<String>) {
File diff suppressed because it is too large Load Diff
@@ -127,7 +127,4 @@ pub(crate) mod topology {
CapabilityStatus, DiskCapabilities, TopologyCapabilities, TopologyDisk, TopologyLabels, TopologyPool, TopologySet,
TopologySnapshot,
};
#[cfg(test)]
pub(crate) use rustfs_storage_api::CapabilityState;
}
+47 -165
View File
@@ -40,16 +40,16 @@ fn validate_table_bucket_delete_allowed(
Ok(())
}
async fn table_catalog_metadata_exists(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<bool> {
async fn table_catalog_metadata_exists(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> bool {
let local_disks = runtime_sources::local_disks_in(ctx).await;
for disk in local_disks.iter() {
let catalog_path = disk.path().join(bucket).join(BUCKET_TABLE_RESERVED_PREFIX);
if has_xlmeta_files(&catalog_path).await? {
return Ok(true);
if has_xlmeta_files(&catalog_path).await {
return true;
}
}
Ok(false)
false
}
async fn validate_table_bucket_delete_guard(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<()> {
@@ -57,7 +57,7 @@ async fn validate_table_bucket_delete_guard(ctx: &crate::runtime::instance::Inst
.await
.is_ok_and(|metadata| metadata.table_bucket_enabled());
if table_bucket_enabled {
validate_table_bucket_delete_allowed(bucket, true, table_catalog_metadata_exists(ctx, bucket).await?)?;
validate_table_bucket_delete_allowed(bucket, true, table_catalog_metadata_exists(ctx, bucket).await)?;
}
Ok(())
@@ -257,57 +257,49 @@ impl ECStore {
None
};
let sr_mark_delete = opts.srdelete_op == SRBucketDeleteOp::MarkDelete;
let sr_purge = opts.srdelete_op == SRBucketDeleteOp::Purge;
let sr_delete = sr_mark_delete || sr_purge;
let mut delete_opts = opts.clone();
let bucket_exists = match self.peer_sys.get_bucket_info(bucket, &BucketOptions::default()).await {
Ok(_) => true,
Err(err) => {
let storage_err: StorageError = err.into();
if is_err_strict_volume_not_found(&storage_err) && sr_delete {
false
} else if is_err_strict_volume_not_found(&storage_err) {
return Err(StorageError::BucketNotFound(bucket.to_string()));
} else {
return Err(to_object_err(storage_err, vec![bucket]));
}
}
};
if bucket_exists {
validate_table_bucket_delete_guard(&self.ctx, bucket).await?;
// Check bucket is empty before deletion (per S3 API spec)
// If bucket is not empty (contains actual objects with xl.meta files) and force
// is not set, return BucketNotEmpty error.
// Note: Empty directories (left after object deletion) should NOT count as objects.
if !opts.force {
let local_disks = runtime_sources::local_disks_in(&self.ctx).await;
for disk in local_disks.iter() {
let bucket_path = disk.path().join(bucket);
if has_xlmeta_files(&bucket_path).await? {
return Err(StorageError::BucketNotEmpty(bucket.to_string()));
}
}
delete_opts.force_if_empty = true;
// Check bucket exists before deletion (per S3 API spec)
// If bucket doesn't exist, return NoSuchBucket error
if let Err(err) = self.peer_sys.get_bucket_info(bucket, &BucketOptions::default()).await {
// Convert DiskError to StorageError for comparison
let storage_err: StorageError = err.into();
if is_err_bucket_not_found(&storage_err) {
return Err(StorageError::BucketNotFound(bucket.to_string()));
}
return Err(to_object_err(storage_err, vec![bucket]));
}
if sr_delete && !bucket_exists {
delete_opts.force_if_empty = true;
validate_table_bucket_delete_guard(&self.ctx, bucket).await?;
let sr_mark_delete = opts.srdelete_op == SRBucketDeleteOp::MarkDelete;
let sr_purge = opts.srdelete_op == SRBucketDeleteOp::Purge;
// Check bucket is empty before deletion (per S3 API spec)
// If bucket is not empty (contains actual objects with xl.meta files) and force
// is not set, return BucketNotEmpty error.
// Note: Empty directories (left after object deletion) should NOT count as objects.
if !opts.force && !sr_mark_delete {
let local_disks = runtime_sources::local_disks_in(&self.ctx).await;
for disk in local_disks.iter() {
// Check if bucket directory contains any xl.meta files (actual objects)
// We recursively scan for xl.meta files to determine if bucket has objects
// Use the disk's root path to construct bucket path
let bucket_path = disk.path().join(bucket);
if has_xlmeta_files(&bucket_path).await {
return Err(StorageError::BucketNotEmpty(bucket.to_string()));
}
}
}
if sr_mark_delete {
self.mark_bucket_deleted(bucket).await?;
self.cleanup_deleted_bucket_metadata(bucket, false).await?;
return Ok(());
}
if let Err(err) = self.peer_sys.delete_bucket(bucket, &delete_opts).await {
let storage_err = to_object_err(err.into(), vec![bucket]);
if !sr_delete || !is_err_strict_volume_not_found(&storage_err) {
return Err(storage_err);
}
}
self.peer_sys
.delete_bucket(bucket, opts)
.await
.map_err(|e| to_object_err(e.into(), vec![bucket]))?;
self.cleanup_deleted_bucket_metadata(bucket, sr_purge).await?;
Ok(())
@@ -327,7 +319,7 @@ mod tests {
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::runtime::instance::InstanceContext;
use crate::storage_api_contracts::{
bucket::{BucketOperations as _, BucketOptions, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp},
bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions, SRBucketDeleteOp},
object::{ObjectIO as _, ObjectOperations as _},
};
use crate::store::{ECStore, init_local_disks_with_instance_ctx};
@@ -456,10 +448,7 @@ mod tests {
async fn any_disk_has_object_metadata(disk_paths: &[PathBuf], bucket: &str) -> bool {
for disk_path in disk_paths {
if super::has_xlmeta_files(&disk_path.join(bucket))
.await
.expect("object metadata scan should succeed")
{
if super::has_xlmeta_files(&disk_path.join(bucket)).await {
return true;
}
}
@@ -519,19 +508,12 @@ mod tests {
// serialize them so their assertions cannot observe each other's operations.
#[tokio::test]
#[serial]
async fn bucket_delete_mark_delete_removes_empty_bucket_and_keeps_deleted_marker() {
async fn bucket_delete_mark_delete_marks_metadata_deleted_without_physical_object_delete() {
let (disk_paths, ecstore) = setup_bucket_delete_test_env().await;
let bucket = format!("bucket-mark-delete-{}", Uuid::new_v4().simple());
let object = "object.txt";
ecstore
.make_bucket(&bucket, &MakeBucketOptions::default())
.await
.expect("bucket should be created");
for disk_path in &disk_paths {
tokio::fs::create_dir_all(disk_path.join(&bucket).join("empty-directory"))
.await
.expect("empty directory remnant should be created");
}
create_bucket_with_object(&ecstore, &bucket, object).await;
assert!(metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_ok());
let generation_before_delete = ecstore.scanner_namespace_mutation_generation();
@@ -544,7 +526,7 @@ mod tests {
},
)
.await
.expect("MarkDelete should remove an empty bucket");
.expect("MarkDelete should not reject non-empty bucket data");
assert_eq!(
ecstore.scanner_namespace_mutation_generation(),
generation_before_delete.saturating_add(1),
@@ -552,8 +534,8 @@ mod tests {
);
assert!(
!any_disk_path_exists(&disk_paths, &bucket).await,
"MarkDelete should remove the bucket volume"
any_disk_has_object_metadata(&disk_paths, &bucket).await,
"MarkDelete must not physically remove object xl.meta data"
);
assert!(
any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await,
@@ -563,79 +545,6 @@ mod tests {
metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_err(),
"deleted bucket metadata must be removed from the local cache"
);
let buckets = ecstore
.list_bucket(&BucketOptions::default())
.await
.expect("bucket listing should succeed after MarkDelete");
assert!(!buckets.iter().any(|info| info.name == bucket));
ecstore
.delete_all(RUSTFS_META_BUCKET, &bucket_deleted_marker_prefix(&bucket))
.await
.expect("deleted-bucket marker should be removed to simulate a partial failure");
assert!(!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await);
ecstore
.delete_bucket(
&bucket,
&DeleteBucketOptions {
srdelete_op: SRBucketDeleteOp::MarkDelete,
..Default::default()
},
)
.await
.expect("retried MarkDelete should recreate a missing tombstone");
assert!(any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await);
}
#[tokio::test]
#[serial]
async fn bucket_delete_mark_delete_rejects_non_empty_bucket_without_force() {
let (disk_paths, ecstore) = setup_bucket_delete_test_env().await;
let bucket = format!("bucket-mark-delete-non-empty-{}", Uuid::new_v4().simple());
create_bucket_with_object(&ecstore, &bucket, "object.txt").await;
let err = ecstore
.delete_bucket(
&bucket,
&DeleteBucketOptions {
srdelete_op: SRBucketDeleteOp::MarkDelete,
..Default::default()
},
)
.await
.expect_err("MarkDelete should reject a non-empty bucket without force");
assert!(matches!(err, StorageError::BucketNotEmpty(name) if name == bucket));
assert!(any_disk_has_object_metadata(&disk_paths, &bucket).await);
assert!(!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await);
}
#[tokio::test]
#[serial]
async fn bucket_delete_mark_delete_rejects_hidden_object_paths_without_force() {
let (disk_paths, ecstore) = setup_bucket_delete_test_env().await;
let bucket = format!("bucket-mark-delete-hidden-{}", Uuid::new_v4().simple());
create_bucket_with_object(&ecstore, &bucket, ".well-known/acme-challenge").await;
let mut reader = PutObjReader::from_vec(b"delete bucket semantics".to_vec());
ecstore
.put_object(&bucket, ".rustfs.sys/object", &mut reader, &ObjectOptions::default())
.await
.expect("second hidden object should be written");
let err = ecstore
.delete_bucket(
&bucket,
&DeleteBucketOptions {
srdelete_op: SRBucketDeleteOp::MarkDelete,
..Default::default()
},
)
.await
.expect_err("MarkDelete should reject a hidden object path without force");
assert!(matches!(err, StorageError::BucketNotEmpty(name) if name == bucket));
assert!(any_disk_has_object_metadata(&disk_paths, &bucket).await);
}
#[tokio::test]
@@ -648,12 +557,7 @@ mod tests {
create_bucket_with_object(&ecstore, &bucket, object).await;
write_bucket_metadata_marker(&disk_paths, &metadata_prefix).await;
ecstore
.mark_bucket_deleted(&bucket)
.await
.expect("deleted-bucket marker should be created");
assert!(any_disk_path_exists(&disk_paths, &metadata_prefix).await);
assert!(any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await);
let generation_before_delete = ecstore.scanner_namespace_mutation_generation();
ecstore
@@ -678,32 +582,10 @@ mod tests {
!any_disk_path_exists(&disk_paths, &metadata_prefix).await,
"Purge should remove bucket metadata prefix"
);
assert!(
!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await,
"Purge should remove the deleted-bucket marker"
);
assert!(
metadata_sys::get_in(&ecstore.ctx, &bucket).await.is_err(),
"purged bucket metadata must be removed from the local cache"
);
write_bucket_metadata_marker(&disk_paths, &metadata_prefix).await;
ecstore
.mark_bucket_deleted(&bucket)
.await
.expect("stale deleted-bucket marker should be recreated");
ecstore
.delete_bucket(
&bucket,
&DeleteBucketOptions {
force: true,
srdelete_op: SRBucketDeleteOp::Purge,
..Default::default()
},
)
.await
.expect("retried Purge should remove stale metadata without a bucket volume");
assert!(!any_disk_path_exists(&disk_paths, &metadata_prefix).await);
assert!(!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await);
}
#[tokio::test]
File diff suppressed because it is too large Load Diff
+10 -31
View File
@@ -81,11 +81,6 @@ type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
type WalkOptions = StorageWalkOptions<fn(&rustfs_filemeta::FileInfo) -> bool>;
const LIST_MERGED_INPUT_BUFFER: usize = 1;
fn list_merged_entry_channel() -> (Sender<MetaCacheEntry>, Receiver<MetaCacheEntry>) {
mpsc::channel(LIST_MERGED_INPUT_BUFFER)
}
fn normalize_max_keys(max_keys: i32) -> i32 {
max_keys.min(MAX_OBJECT_LIST)
@@ -4173,7 +4168,7 @@ impl ECStore {
for sets in self.pools.iter() {
for set in sets.disk_set.iter() {
let (send, recv) = list_merged_entry_channel();
let (send, recv) = mpsc::channel(100);
inputs.push(recv);
let opts = opts.clone();
@@ -5428,7 +5423,7 @@ impl Sets {
let mut inputs = Vec::new();
for set in &self.disk_set {
let (send, recv) = list_merged_entry_channel();
let (send, recv) = mpsc::channel(100);
inputs.push(recv);
let opts = opts.clone();
let rx_clone = rx.clone();
@@ -6692,14 +6687,14 @@ mod test {
VerifiedIndexCandidateStats, VersionMarker, current_list_objects_mutation_sequence,
encode_persistent_list_metadata_object, enforce_latest_listing_write_quorum, expand_ask_disks_for_object_quorum,
fallback_entries_for_object, gather_results, latest_listing_allow_agreed_objects, latest_listing_object_quorum,
latest_listing_raw_min_disks, latest_listing_required_object_quorum, list_marker_key, list_merged_entry_channel,
list_metadata_resolution_params, list_objects_from_metadata_snapshot_candidates,
list_objects_from_verified_index_candidates, list_objects_from_verified_index_candidates_with_optional_stats,
list_objects_from_verified_index_candidates_with_stats, list_objects_index_mode_from_env,
list_objects_index_provider_from_env, list_objects_index_provider_state_from_env, list_objects_key_only_provider_health,
list_objects_metadata_fast_guardrails_from_env, list_objects_paginate, list_objects_quorum_from_env,
list_quorum_from_env, load_namespace_mutation_journal_state, load_persistent_key_only_index, max_keys_plus_one,
merge_entry_channels, namespace_mutation_journal_chaos_bucket_from_env, namespace_mutation_journal_chaos_config_from_env,
latest_listing_raw_min_disks, latest_listing_required_object_quorum, list_marker_key, list_metadata_resolution_params,
list_objects_from_metadata_snapshot_candidates, list_objects_from_verified_index_candidates,
list_objects_from_verified_index_candidates_with_optional_stats, list_objects_from_verified_index_candidates_with_stats,
list_objects_index_mode_from_env, list_objects_index_provider_from_env, list_objects_index_provider_state_from_env,
list_objects_key_only_provider_health, list_objects_metadata_fast_guardrails_from_env, list_objects_paginate,
list_objects_quorum_from_env, list_quorum_from_env, load_namespace_mutation_journal_state,
load_persistent_key_only_index, max_keys_plus_one, merge_entry_channels,
namespace_mutation_journal_chaos_bucket_from_env, namespace_mutation_journal_chaos_config_from_env,
namespace_mutation_journal_chaos_enabled_from_env, namespace_mutation_journal_chaos_sequence_from_env,
namespace_mutation_journal_chaos_status_from_env, normalize_list_quorum, observe_list_objects_mutations_with_store,
parse_namespace_mutation_journal_state, parse_persistent_key_only_index, parse_persistent_list_metadata_object,
@@ -6843,22 +6838,6 @@ mod test {
}
}
#[tokio::test]
async fn list_merged_entry_channel_backpressures_after_one_head() {
let (tx, mut rx) = list_merged_entry_channel();
tx.try_send(test_meta_entry("object-a"))
.expect("first merge head should fit in the input buffer");
match tx.try_send(test_meta_entry("object-b")) {
Err(tokio::sync::mpsc::error::TrySendError::Full(entry)) => assert_eq!(entry.name, "object-b"),
other => panic!("second merge head should wait for the k-way merge refill, got {other:?}"),
}
assert_eq!(rx.recv().await.expect("first merge head should remain available").name, "object-a");
tx.try_send(test_meta_entry("object-b"))
.expect("input buffer should accept the next head after refill");
}
fn test_live_object_info(name: &str, etag: &str) -> ObjectInfo {
let mut fi = FileInfo {
name: name.to_owned(),
+15 -9
View File
@@ -40,8 +40,8 @@ use crate::disk::endpoint::{Endpoint, EndpointType};
use crate::disk::{DiskAPI, DiskInfo, DiskInfoOptions};
use crate::error::{Error, Result};
use crate::error::{
StorageError, is_err_bucket_exists, is_err_invalid_upload_id, is_err_object_not_found, is_err_read_quorum,
is_err_strict_volume_not_found, is_err_version_not_found, to_object_err,
StorageError, is_err_bucket_exists, is_err_bucket_not_found, is_err_invalid_upload_id, is_err_object_not_found,
is_err_read_quorum, is_err_version_not_found, to_object_err,
};
use crate::runtime::global::DISK_RESERVE_FRACTION;
use crate::runtime::instance::InstanceContext;
@@ -91,7 +91,7 @@ type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
/// Check if a directory contains any xl.meta files (indicating actual S3 objects)
/// This is used to determine if a bucket is empty for deletion purposes.
pub(crate) async fn has_xlmeta_files(path: &std::path::Path) -> std::io::Result<bool> {
async fn has_xlmeta_files(path: &std::path::Path) -> bool {
use crate::disk::STORAGE_FORMAT_FILE;
use tokio::fs;
@@ -100,27 +100,33 @@ pub(crate) async fn has_xlmeta_files(path: &std::path::Path) -> std::io::Result<
while let Some(current_path) = stack.pop() {
let mut entries = match fs::read_dir(&current_path).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
Err(_) => continue,
};
while let Some(entry) = entries.next_entry().await? {
while let Ok(Some(entry)) = entries.next_entry().await {
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
// Skip hidden files/directories (like .rustfs.sys)
if file_name_str.starts_with('.') {
continue;
}
// Check if this is an xl.meta file
if file_name_str == STORAGE_FORMAT_FILE {
return Ok(true);
return true;
}
// If it's a directory, add to stack for further exploration
if entry.file_type().await?.is_dir() {
if let Ok(file_type) = entry.file_type().await
&& file_type.is_dir()
{
stack.push(entry.path());
}
}
}
Ok(false)
false
}
async fn enqueue_transition_after_write(result: Result<ObjectInfo>, src: LcEventSrc) -> Result<ObjectInfo> {
-15
View File
@@ -86,19 +86,4 @@ impl CapturingRecorder {
.map(|(_, samples)| samples.0.lock().expect("samples should be lockable").len())
.sum()
}
pub(crate) fn histogram_values(&self, name: &str, labels: &[(&str, &str)]) -> Vec<f64> {
self.histograms
.lock()
.expect("histogram map should be lockable")
.iter()
.filter(|(key, _)| {
key.name() == name
&& labels
.iter()
.all(|(lk, lv)| key.labels().any(|label| label.key() == *lk && label.value() == *lv))
})
.flat_map(|(_, samples)| samples.0.lock().expect("samples should be lockable").clone())
.collect()
}
}
-3
View File
@@ -532,9 +532,6 @@ impl FileMeta {
if ver.header.version_id != vid {
continue;
}
if fi.tier_free_version() && !ver.header.free_version() {
return Err(Error::FileVersionNotFound);
}
match ver.header.version_type {
VersionType::Invalid | VersionType::Legacy => return Err(Error::other("invalid file meta version")),
+1 -1
View File
@@ -51,7 +51,7 @@ rustfs-utils = { workspace = true, features = ["path"] }
rustfs-io-metrics.workspace = true
tokio-util = { workspace = true, features = ["io", "compat"] }
pollster.workspace = true
reqwest = { workspace = true }
reqwest = { workspace = true, default-features = false, features = ["rustls", "http2", "system-proxy"] }
moka = { workspace = true, features = ["future"] }
openidconnect = { workspace = true, default-features = false, features = ["accept-rfc3339-timestamps"] }
http = { workspace = true }
-7
View File
@@ -90,10 +90,6 @@ pub enum Error {
#[error("invalid access_key")]
InvalidAccessKey,
#[error("access key is already in use")]
AccessKeyAlreadyExists,
#[error("action not allowed")]
IAMActionNotAllowed,
@@ -163,7 +159,6 @@ impl Clone for Error {
Error::NoAccessKey => Error::NoAccessKey,
Error::InvalidToken => Error::InvalidToken,
Error::InvalidAccessKey => Error::InvalidAccessKey,
Error::AccessKeyAlreadyExists => Error::AccessKeyAlreadyExists,
Error::IAMActionNotAllowed => Error::IAMActionNotAllowed,
Error::InvalidExpiration => Error::InvalidExpiration,
Error::NoSecretKeyWithAccessKey => Error::NoSecretKeyWithAccessKey,
@@ -302,7 +297,6 @@ mod tests {
Error::NoSuchUser("testuser".to_string()),
Error::NoSuchAccount("testaccount".to_string()),
Error::InvalidArgument,
Error::AccessKeyAlreadyExists,
Error::IAMActionNotAllowed,
Error::PolicyTooLarge,
Error::ConfigNotFound,
@@ -421,7 +415,6 @@ mod tests {
(Error::NoSuchUser("testuser".to_string()), "user 'testuser' does not exist"),
(Error::NoSuchAccount("testaccount".to_string()), "account 'testaccount' does not exist"),
(Error::InvalidArgument, "invalid arguments specified"),
(Error::AccessKeyAlreadyExists, "access key is already in use"),
(Error::IAMActionNotAllowed, "action not allowed"),
(Error::ConfigNotFound, "config not found"),
];
+7 -3
View File
@@ -783,10 +783,14 @@ where
}
let cache = self.cache.snapshot();
if cache.users.contains_key(&cred.access_key) || cache.sts_accounts.contains_key(&cred.access_key) {
return Err(Error::AccessKeyAlreadyExists);
let users = Arc::clone(&cache.users);
if let Some(x) = users.get(&cred.access_key)
&& x.credentials.is_service_account()
{
return Err(Error::IAMActionNotAllowed);
}
drop(cache);
drop(users);
let u = UserIdentity::new(cred);
@@ -1332,7 +1336,7 @@ where
if let Some(x) = users.get(access_key) {
warn!(error = ?x, "IAM user already exists");
if x.credentials.is_temp() {
return Err(Error::AccessKeyAlreadyExists);
return Err(Error::IAMActionNotAllowed);
}
}
drop(cache);
+2 -157
View File
@@ -456,7 +456,7 @@ impl<T: Store> IamSys<T> {
}
if parent_user == opts.access_key {
return Err(IamError::AccessKeyAlreadyExists);
return Err(IamError::IAMActionNotAllowed);
}
if opts.access_key == SITE_REPLICATOR_SERVICE_ACCOUNT && !opts.allow_site_replicator_account {
@@ -1429,7 +1429,6 @@ mod tests {
/// When true, parent user has no groups and no mapped policies (empty `policy_db_get`).
empty_policies: bool,
saved_sts_users: Arc<Mutex<HashMap<String, UserIdentity>>>,
saved_service_account_count: Arc<Mutex<usize>>,
}
impl StsTestMockStore {
@@ -1437,16 +1436,8 @@ mod tests {
Self {
empty_policies,
saved_sts_users: Arc::new(Mutex::new(HashMap::new())),
saved_service_account_count: Arc::new(Mutex::new(0)),
}
}
fn saved_service_account_count(&self) -> usize {
*self
.saved_service_account_count
.lock()
.expect("saved_service_account_count mutex poisoned")
}
}
#[async_trait::async_trait]
@@ -1470,16 +1461,10 @@ mod tests {
async fn save_user_identity(
&self,
name: &str,
user_type: UserType,
_user_type: UserType,
item: UserIdentity,
_ttl: Option<usize>,
) -> Result<()> {
if user_type == UserType::Svc {
*self
.saved_service_account_count
.lock()
.expect("saved_service_account_count mutex poisoned") += 1;
}
self.saved_sts_users
.lock()
.expect("saved_sts_users mutex poisoned")
@@ -1701,146 +1686,6 @@ mod tests {
}
}
async fn test_iam_sys() -> IamSys<StsTestMockStore> {
let store = StsTestMockStore::new(false);
let cache = IamCache::new(store).await.expect("IAM cache should initialize");
IamSys::new(cache)
}
fn service_account_opts(access_key: &str, secret_key: &str) -> NewServiceAccountOpts {
NewServiceAccountOpts {
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
..Default::default()
}
}
#[tokio::test]
async fn new_service_account_rejects_parent_access_key() {
ensure_test_global_credentials();
let iam_sys = test_iam_sys().await;
let access_key = "PARENTACCESSKEY123";
let err = iam_sys
.new_service_account(access_key, None, service_account_opts(access_key, "parentAccessKeySecret123"))
.await
.expect_err("a service account must not reuse its parent access key");
assert_eq!(err, Error::AccessKeyAlreadyExists);
}
#[tokio::test]
async fn duplicate_service_account_returns_access_key_already_exists() {
ensure_test_global_credentials();
let iam_sys = test_iam_sys().await;
let access_key = "DUPLICATESERVICEKEY1";
iam_sys
.new_service_account("svc-parent-user", None, service_account_opts(access_key, "firstServiceSecret123"))
.await
.expect("initial service account should be created");
let err = iam_sys
.new_service_account("svc-parent-user", None, service_account_opts(access_key, "secondServiceSecret123"))
.await
.expect_err("duplicate service account should fail");
assert_eq!(err, Error::AccessKeyAlreadyExists);
}
#[tokio::test]
async fn service_account_creation_rejects_regular_user_access_key() {
ensure_test_global_credentials();
let iam_sys = test_iam_sys().await;
let access_key = "REGULARUSERACCESSKEY1";
let user = AddOrUpdateUserReq {
secret_key: "regularUserSecret123".to_string(),
policy: None,
status: rustfs_madmin::AccountStatus::Enabled,
};
iam_sys
.store
.add_user(access_key, &user)
.await
.expect("regular user should be created");
let err = iam_sys
.new_service_account("svc-parent-user", None, service_account_opts(access_key, "serviceAccountSecret123"))
.await
.expect_err("service-account creation must reject a regular-user access key");
assert_eq!(err, Error::AccessKeyAlreadyExists);
assert_eq!(iam_sys.store.api.saved_service_account_count(), 0);
let existing = iam_sys.get_user(access_key).await.expect("regular user should remain cached");
assert!(!existing.credentials.is_service_account());
assert_eq!(existing.credentials.secret_key, user.secret_key);
}
#[tokio::test]
async fn service_account_creation_rejects_sts_access_key() {
ensure_test_global_credentials();
let iam_sys = test_iam_sys().await;
let access_key = "TEMPORARYSERVICEKEY12";
let temp_cred = Credentials {
access_key: access_key.to_string(),
secret_key: "temporarySecretKey123".to_string(),
session_token: "temporary-session-token".to_string(),
expiration: Some(OffsetDateTime::now_utc() + time::Duration::hours(1)),
status: ACCOUNT_ON.to_string(),
parent_user: "sts-parent-user".to_string(),
..Default::default()
};
iam_sys
.set_temp_user(access_key, &temp_cred, None)
.await
.expect("temporary credentials should be created");
let err = iam_sys
.new_service_account("svc-parent-user", None, service_account_opts(access_key, "serviceAccountSecret123"))
.await
.expect_err("service-account creation must reject a temporary access key");
assert_eq!(err, Error::AccessKeyAlreadyExists);
assert_eq!(iam_sys.store.api.saved_service_account_count(), 0);
let existing = iam_sys
.get_user(access_key)
.await
.expect("temporary account should remain cached");
assert!(existing.credentials.is_temp());
assert_eq!(existing.credentials.secret_key, temp_cred.secret_key);
}
#[tokio::test]
async fn user_creation_rejects_service_account_access_key() {
ensure_test_global_credentials();
let iam_sys = test_iam_sys().await;
let access_key = "SERVICEACCOUNTKEY123";
iam_sys
.new_service_account("svc-parent-user", None, service_account_opts(access_key, "serviceAccountSecret123"))
.await
.expect("service account should be created");
let user = AddOrUpdateUserReq {
secret_key: "regularUserSecret123".to_string(),
policy: None,
status: rustfs_madmin::AccountStatus::Enabled,
};
let err = iam_sys
.create_user(access_key, &user)
.await
.expect_err("user creation must reject a service-account access key");
assert_eq!(err, Error::AccessKeyAlreadyExists);
}
#[tokio::test]
async fn test_new_service_account_without_expiration_omits_exp_claim() {
ensure_test_global_credentials();
@@ -514,12 +514,6 @@ pub fn cluster_peer_is_offline(addr: &str) -> bool {
peers.get(normalize_peer_key(addr)).map(|peer| !peer.online).unwrap_or(false)
}
/// Return the last observed online/offline state for a peer, if this process has observed one.
pub fn cluster_peer_observed_online_status(addr: &str) -> Option<bool> {
let peers = CLUSTER_PEER_HEALTH.read().unwrap_or_else(|poisoned| poisoned.into_inner());
peers.get(normalize_peer_key(addr)).map(|peer| peer.online)
}
/// Decide whether to fast-fail (bypass) an offline peer instead of attempting to reach it
/// (grpc-optimization P3 offline bypass). Returns `true` to bypass.
///
@@ -788,18 +782,6 @@ mod tests {
assert!(!cluster_peer_is_offline(bare), "reachable via one form must mark the shared entry online");
}
#[test]
fn cluster_peer_observed_online_status_reports_known_and_unknown_peers() {
let addr = "http://cluster-peer-snapshot-status-test:9000";
assert_eq!(cluster_peer_observed_online_status(addr), None);
record_peer_reachable(addr);
assert_eq!(cluster_peer_observed_online_status(addr), Some(true));
record_peer_unreachable(addr, 1);
assert_eq!(cluster_peer_observed_online_status(addr), Some(false));
}
#[test]
fn cluster_servers_offline_total_name_is_stable() {
assert_eq!(CLUSTER_SERVERS_OFFLINE_TOTAL, "rustfs_cluster_servers_offline_total");
+4 -6
View File
@@ -210,12 +210,10 @@ pub use io_metrics::{
record_io_scheduler_decision, record_load_level_change, record_queue_operation, record_starvation_event,
};
pub use list_objects_metrics::{
LIST_OBJECTS_GATHER_OUTCOME_INPUT_CLOSED, LIST_OBJECTS_GATHER_OUTCOME_LIMIT_REACHED,
LIST_OBJECTS_LOCAL_READ_DIR_OUTCOME_ERROR, LIST_OBJECTS_LOCAL_READ_DIR_OUTCOME_OK, LIST_OBJECTS_SOURCE_WALKER,
ListObjectsGatherObservation, ListObjectsIndexPageObservation, ListObjectsLocalReadDirObservation, init_list_objects_metrics,
record_list_objects_gather, record_list_objects_index_attempt, record_list_objects_index_fallback,
record_list_objects_index_live_verify_failure, record_list_objects_index_served, record_list_objects_local_read_dir,
record_list_objects_merge,
LIST_OBJECTS_GATHER_OUTCOME_INPUT_CLOSED, LIST_OBJECTS_GATHER_OUTCOME_LIMIT_REACHED, LIST_OBJECTS_SOURCE_WALKER,
ListObjectsGatherObservation, ListObjectsIndexPageObservation, init_list_objects_metrics, record_list_objects_gather,
record_list_objects_index_attempt, record_list_objects_index_fallback, record_list_objects_index_live_verify_failure,
record_list_objects_index_served, record_list_objects_merge,
};
// Backpressure metrics exports
@@ -42,13 +42,6 @@ const LIST_OBJECTS_INDEX_LIVE_VERIFY_FAILURE_TOTAL: &str = "rustfs_s3_list_objec
const LIST_OBJECTS_INDEX_RETURNED_OBJECTS: &str = "rustfs_s3_list_objects_index_returned_objects";
const LIST_OBJECTS_INDEX_RETURNED_PREFIXES: &str = "rustfs_s3_list_objects_index_returned_prefixes";
const LIST_OBJECTS_INDEX_VERIFICATION_IO_AMPLIFICATION: &str = "rustfs_s3_list_objects_index_verification_io_amplification";
const LIST_OBJECTS_LOCAL_READ_DIR_TOTAL: &str = "rustfs_s3_list_objects_local_read_dir_total";
const LIST_OBJECTS_LOCAL_READ_DIR_DURATION_MS: &str = "rustfs_s3_list_objects_local_read_dir_duration_ms";
const LIST_OBJECTS_LOCAL_READ_DIR_ENTRIES: &str = "rustfs_s3_list_objects_local_read_dir_entries";
const LIST_OBJECTS_LOCAL_READ_DIR_LIMIT: &str = "rustfs_s3_list_objects_local_read_dir_limit";
pub const LIST_OBJECTS_LOCAL_READ_DIR_OUTCOME_OK: &str = "ok";
pub const LIST_OBJECTS_LOCAL_READ_DIR_OUTCOME_ERROR: &str = "error";
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ListObjectsGatherObservation {
@@ -76,17 +69,6 @@ pub struct ListObjectsIndexPageObservation {
pub is_truncated: bool,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ListObjectsLocalReadDirObservation {
pub outcome: &'static str,
pub requested_count: i32,
pub returned_entries: usize,
pub duration_ms: f64,
pub is_root: bool,
pub has_filter_prefix: bool,
pub has_forward: bool,
}
#[inline(always)]
fn bool_label(value: bool) -> &'static str {
if value { "true" } else { "false" }
@@ -102,16 +84,6 @@ fn limit_as_f64(value: i32) -> f64 {
value.max(0) as f64
}
#[inline(always)]
fn count_mode_label(count: i32) -> &'static str {
if count < 0 { "whole" } else { "bounded" }
}
#[inline(always)]
fn read_dir_count_as_f64(value: i32) -> f64 {
f64::from(value)
}
pub fn init_list_objects_metrics() {
static METRICS_DESC_INIT: OnceLock<()> = OnceLock::new();
METRICS_DESC_INIT.get_or_init(|| {
@@ -183,22 +155,6 @@ pub fn init_list_objects_metrics() {
LIST_OBJECTS_INDEX_VERIFICATION_IO_AMPLIFICATION,
"Ratio of live verification attempts to returned objects for opt-in ListObjects index serving."
);
describe_counter!(
LIST_OBJECTS_LOCAL_READ_DIR_TOTAL,
"Total number of local read_dir calls made while serving live-walker ListObjects pages."
);
describe_histogram!(
LIST_OBJECTS_LOCAL_READ_DIR_DURATION_MS,
"Duration in milliseconds of local read_dir calls made while serving live-walker ListObjects pages."
);
describe_histogram!(
LIST_OBJECTS_LOCAL_READ_DIR_ENTRIES,
"Number of immediate directory entries returned by local read_dir while serving live-walker ListObjects pages."
);
describe_histogram!(
LIST_OBJECTS_LOCAL_READ_DIR_LIMIT,
"Requested local read_dir count used while serving live-walker ListObjects pages; negative counts mean whole-directory enumeration."
);
});
}
@@ -368,38 +324,6 @@ pub fn record_list_objects_index_served(observation: ListObjectsIndexPageObserva
.record(verification_io_amplification);
}
pub fn record_list_objects_local_read_dir(observation: ListObjectsLocalReadDirObservation) {
if !get_stage_metrics_enabled() {
return;
}
let count_mode = count_mode_label(observation.requested_count);
counter!(
LIST_OBJECTS_LOCAL_READ_DIR_TOTAL,
"outcome" => observation.outcome,
"count_mode" => count_mode,
"is_root" => bool_label(observation.is_root),
"has_filter_prefix" => bool_label(observation.has_filter_prefix),
"has_forward" => bool_label(observation.has_forward),
)
.increment(1);
histogram!(
LIST_OBJECTS_LOCAL_READ_DIR_DURATION_MS,
"outcome" => observation.outcome,
"count_mode" => count_mode,
)
.record(observation.duration_ms);
histogram!(
LIST_OBJECTS_LOCAL_READ_DIR_ENTRIES,
"outcome" => observation.outcome,
"count_mode" => count_mode,
)
.record(count_as_f64(observation.returned_entries));
histogram!(LIST_OBJECTS_LOCAL_READ_DIR_LIMIT, "count_mode" => count_mode)
.record(read_dir_count_as_f64(observation.requested_count));
}
#[cfg(test)]
mod tests {
use super::*;
@@ -449,27 +373,4 @@ mod tests {
});
record_list_objects_index_live_verify_failure("index_key_only", "read_error");
}
#[test]
fn record_local_read_dir_observation_accepts_whole_directory_counts() {
init_list_objects_metrics();
record_list_objects_local_read_dir(ListObjectsLocalReadDirObservation {
outcome: LIST_OBJECTS_LOCAL_READ_DIR_OUTCOME_OK,
requested_count: -1,
returned_entries: 4096,
duration_ms: 12.5,
is_root: true,
has_filter_prefix: false,
has_forward: false,
});
record_list_objects_local_read_dir(ListObjectsLocalReadDirObservation {
outcome: LIST_OBJECTS_LOCAL_READ_DIR_OUTCOME_ERROR,
requested_count: -1,
returned_entries: 0,
duration_ms: 5000.0,
is_root: true,
has_filter_prefix: false,
has_forward: true,
});
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ authors.workspace = true
[dependencies]
tokio = { workspace = true, features = ["rt", "sync"] }
reqwest = { workspace = true, features = ["json"] }
reqwest = { workspace = true, default-features = false, features = ["rustls", "http2", "system-proxy", "json"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
thiserror = { workspace = true }
+1 -1
View File
@@ -60,7 +60,7 @@ rustfs-utils = { workspace = true }
rustfs-security-governance = { workspace = true }
# HTTP client for Vault
reqwest = { workspace = true }
reqwest = { workspace = true, default-features = false, features = ["rustls", "http2", "system-proxy"] }
vaultrs = { workspace = true }
[dev-dependencies]
@@ -96,23 +96,6 @@ pub(super) fn rules() -> Vec<Rule> {
"定位具体盘(结合 samples 的 fields),检查硬件。",
)
},
Rule {
evidence_fields: strings(["bucket", "path", "state", "timeout_ms", "drive"]),
anchors: strings(["Metacache listing quorum failed", "Metacache reader peek timed out"]),
min_count: 2,
..base(
"metacache-listing-timeout",
P2Degraded,
"disk",
"metacache listing 多盘等待超时",
any([
all([contains("Metacache listing quorum failed"), field("state", "quorum_failed")]),
all([contains("Metacache reader peek timed out"), field("state", "peek_timed_out")]),
]),
"对象 listing/scanner 期间 metacache reader 等待超时并可能导致 listing quorum failure;常见于宽前缀、高延迟盘或慢远端 reader。",
"先确认是否存在宽前缀/慢盘/高延迟远端 reader;再参考 docs/operations/drive-timeout-tuning.md 调整 RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS、RUSTFS_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS 或 RUSTFS_DRIVE_TIMEOUT_PROFILE=high_latency。",
)
},
Rule {
anchors: strings(["Unformatted disk"]),
..base(
+1 -26
View File
@@ -73,7 +73,7 @@ fn msg(message: &'static str) -> Sample {
#[test]
fn seed_rule_set_is_valid_and_complete() {
let set = seed_rule_set();
assert_eq!(set.rules().len(), 69);
assert_eq!(set.rules().len(), 68);
// Every message-matching rule carries at least one anchor for the CI
// guard; the only anchor-less rules are the two structural matchers
// (panic kind, scanner target+level).
@@ -113,14 +113,6 @@ fn every_rule_has_a_positive_sample() {
msg("reporting peer disks offline after consecutive storage_info failures"),
),
("drive-faulty-error", msg("remote drive is faulty")),
(
"metacache-listing-timeout",
Sample {
message: "Metacache listing quorum failed",
fields: &[("state", "quorum_failed"), ("bucket", "photos"), ("path", "wide/")],
..Default::default()
},
),
("unformatted-disk", msg("Unformatted disk found")),
("disk-access-denied", msg("disk access denied: /data/disk1")),
("inconsistent-drive", msg("inconsistent drive found")),
@@ -316,23 +308,6 @@ fn smoke_samples_hit_exact_rule_sets() {
&["disk-marked-faulty"],
);
exact(&msg("erasure write quorum (required=8, achieved=5)"), &["ec-write-quorum"]);
exact(
&Sample {
message: "Metacache listing quorum failed",
fields: &[("state", "quorum_failed"), ("bucket", "photos"), ("path", "wide/")],
..Default::default()
},
&["metacache-listing-timeout"],
);
exact(
&Sample {
message: "S3Error InvalidAccessKeyId: Access Key Id you provided does not exist",
fields: &[("code", "InvalidAccessKeyId")],
..Default::default()
},
&["unknown-access-key"],
);
exact(&msg("HTTP transport failed: BrokenPipe while streaming response body"), &[]);
// Internode auth failures legitimately hit both the internode rule and
// the generic client signature rule.
exact(
-8
View File
@@ -258,14 +258,6 @@ mod tests {
assert!(GRAFANA_DASHBOARD.contains("rustfs_log_cleaner_active_file_size_bytes"));
}
#[test]
fn test_grafana_dashboard_deduplicates_cluster_bucket_usage() {
assert!(GRAFANA_DASHBOARD.contains("max by (job, bucket) (rustfs_cluster_usage_buckets_objects_count"));
assert!(GRAFANA_DASHBOARD.contains("max by (job, bucket) (rustfs_cluster_usage_buckets_total_bytes"));
assert!(!GRAFANA_DASHBOARD.contains("sum by (bucket) (rustfs_bucket_api_objects_total"));
assert!(!GRAFANA_DASHBOARD.contains("sum by (bucket) (rustfs_bucket_api_usage_bytes"));
}
#[test]
fn test_readme_mentions_deployed_dashboard_path() {
assert!(README.contains(".docker/observability/grafana/dashboards/rustfs.json"));
+2 -21
View File
@@ -97,7 +97,7 @@ pub static USAGE_VERSIONS_DISTRIBUTION_MD: LazyLock<MetricDescriptor> = LazyLock
pub static USAGE_BUCKET_TOTAL_BYTES_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_gauge_md(
MetricName::UsageBucketTotalBytes,
"Cluster-wide logical bucket size in bytes",
"Total bucket size in bytes",
&[BUCKET_LABEL],
subsystems::CLUSTER_USAGE_BUCKETS,
)
@@ -106,7 +106,7 @@ pub static USAGE_BUCKET_TOTAL_BYTES_MD: LazyLock<MetricDescriptor> = LazyLock::n
pub static USAGE_BUCKET_OBJECTS_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_gauge_md(
MetricName::UsageBucketObjectsCount,
"Cluster-wide logical object count in bucket",
"Total objects count in bucket",
&[BUCKET_LABEL],
subsystems::CLUSTER_USAGE_BUCKETS,
)
@@ -156,22 +156,3 @@ pub static USAGE_BUCKET_OBJECT_VERSION_COUNT_DISTRIBUTION_MD: LazyLock<MetricDes
subsystems::CLUSTER_USAGE_BUCKETS,
)
});
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bucket_usage_descriptors_identify_cluster_wide_logical_values() {
assert_eq!(
USAGE_BUCKET_TOTAL_BYTES_MD.get_full_metric_name(),
"rustfs_cluster_usage_buckets_total_bytes"
);
assert_eq!(
USAGE_BUCKET_OBJECTS_TOTAL_MD.get_full_metric_name(),
"rustfs_cluster_usage_buckets_objects_count"
);
assert!(USAGE_BUCKET_TOTAL_BYTES_MD.help.contains("Cluster-wide logical"));
assert!(USAGE_BUCKET_OBJECTS_TOTAL_MD.help.contains("Cluster-wide logical"));
}
}
+4 -6
View File
@@ -19,21 +19,21 @@ use std::sync::LazyLock;
const BUCKET_LABEL: &str = "bucket";
/// Cluster-wide logical bytes used by the bucket.
/// Total bytes used by the bucket
pub static BUCKET_USAGE_BYTES_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_gauge_md(
MetricName::Custom("usage_bytes".to_string()),
"Cluster-wide logical bytes used by the bucket",
"Total bytes used by the bucket",
&[BUCKET_LABEL],
MetricSubsystem::new("/bucket/usage"),
)
});
/// Cluster-wide logical object count in the bucket.
/// Total number of objects in the bucket
pub static BUCKET_OBJECTS_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
new_gauge_md(
MetricName::Custom("objects_total".to_string()),
"Cluster-wide logical object count in the bucket",
"Total number of objects in the bucket",
&[BUCKET_LABEL],
MetricSubsystem::new("/bucket/usage"),
)
@@ -58,7 +58,5 @@ mod tests {
assert_eq!(BUCKET_USAGE_BYTES_MD.subsystem.path(), "/bucket/usage");
assert_eq!(BUCKET_OBJECTS_TOTAL_MD.subsystem.path(), "/bucket/usage");
assert_eq!(BUCKET_QUOTA_BYTES_MD.subsystem.path(), "/bucket/usage");
assert!(BUCKET_USAGE_BYTES_MD.help.contains("Cluster-wide logical"));
assert!(BUCKET_OBJECTS_TOTAL_MD.help.contains("Cluster-wide logical"));
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ ipnetwork = { workspace = true, features = ["serde"] }
base64-simd = { workspace = true }
jsonwebtoken = { workspace = true, features = ["aws_lc_rs"] }
regex = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
reqwest = { workspace = true, default-features = false, features = ["rustls", "http2", "system-proxy", "json"] }
chrono = { workspace = true, features = ["serde"] }
tracing.workspace = true
moka = { workspace = true, features = ["future"] }
@@ -83,8 +83,6 @@ pub struct GetBucketInfoResponse {
pub struct DeleteBucketRequest {
#[prost(string, tag = "1")]
pub bucket: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub options: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct DeleteBucketResponse {
@@ -1225,46 +1223,6 @@ pub struct LoadTransitionTierConfigResponse {
#[prost(string, optional, tag = "2")]
pub error_info: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct TierMutationPrepareRequest {
#[prost(uint32, tag = "1")]
pub version: u32,
#[prost(string, tag = "2")]
pub mutation_id: ::prost::alloc::string::String,
#[prost(bytes = "bytes", tag = "3")]
pub canonical_payload: ::prost::bytes::Bytes,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct TierMutationCommitRequest {
#[prost(uint32, tag = "1")]
pub version: u32,
#[prost(string, tag = "2")]
pub mutation_id: ::prost::alloc::string::String,
#[prost(bytes = "bytes", tag = "3")]
pub canonical_payload: ::prost::bytes::Bytes,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct TierMutationAbortRequest {
#[prost(uint32, tag = "1")]
pub version: u32,
#[prost(string, tag = "2")]
pub mutation_id: ::prost::alloc::string::String,
#[prost(bytes = "bytes", tag = "3")]
pub canonical_payload: ::prost::bytes::Bytes,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct TierMutationControlResponse {
#[prost(bool, tag = "1")]
pub success: bool,
#[prost(enumeration = "TierMutationPeerState", tag = "2")]
pub state: i32,
#[prost(bool, tag = "3")]
pub applied: bool,
#[prost(string, optional, tag = "4")]
pub error_info: ::core::option::Option<::prost::alloc::string::String>,
#[prost(bytes = "bytes", tag = "5")]
pub response_proof: ::prost::bytes::Bytes,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct GetLiveEventsRequest {
#[prost(uint64, tag = "1")]
@@ -1285,38 +1243,6 @@ pub struct GetLiveEventsResponse {
#[prost(string, optional, tag = "5")]
pub error_info: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum TierMutationPeerState {
Unspecified = 0,
Prepared = 1,
Committed = 2,
Aborted = 3,
}
impl TierMutationPeerState {
/// String value of the enum field names used in the ProtoBuf definition.
///
/// The values are not transformed in any way and thus are considered stable
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
pub fn as_str_name(&self) -> &'static str {
match self {
Self::Unspecified => "TIER_MUTATION_PEER_STATE_UNSPECIFIED",
Self::Prepared => "TIER_MUTATION_PEER_STATE_PREPARED",
Self::Committed => "TIER_MUTATION_PEER_STATE_COMMITTED",
Self::Aborted => "TIER_MUTATION_PEER_STATE_ABORTED",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
match value {
"TIER_MUTATION_PEER_STATE_UNSPECIFIED" => Some(Self::Unspecified),
"TIER_MUTATION_PEER_STATE_PREPARED" => Some(Self::Prepared),
"TIER_MUTATION_PEER_STATE_COMMITTED" => Some(Self::Committed),
"TIER_MUTATION_PEER_STATE_ABORTED" => Some(Self::Aborted),
_ => None,
}
}
}
/// Generated client implementations.
pub mod node_service_client {
#![allow(unused_variables, dead_code, missing_docs, clippy::wildcard_imports, clippy::let_unit_value)]
@@ -5664,334 +5590,3 @@ pub mod heal_control_service_server {
const NAME: &'static str = SERVICE_NAME;
}
}
/// Generated client implementations.
pub mod tier_mutation_control_service_client {
#![allow(unused_variables, dead_code, missing_docs, clippy::wildcard_imports, clippy::let_unit_value)]
use tonic::codegen::http::Uri;
use tonic::codegen::*;
#[derive(Debug, Clone)]
pub struct TierMutationControlServiceClient<T> {
inner: tonic::client::Grpc<T>,
}
impl TierMutationControlServiceClient<tonic::transport::Channel> {
/// Attempt to create a new client by connecting to a given endpoint.
pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
where
D: TryInto<tonic::transport::Endpoint>,
D::Error: Into<StdError>,
{
let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
Ok(Self::new(conn))
}
}
impl<T> TierMutationControlServiceClient<T>
where
T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
{
pub fn new(inner: T) -> Self {
let inner = tonic::client::Grpc::new(inner);
Self { inner }
}
pub fn with_origin(inner: T, origin: Uri) -> Self {
let inner = tonic::client::Grpc::with_origin(inner, origin);
Self { inner }
}
pub fn with_interceptor<F>(inner: T, interceptor: F) -> TierMutationControlServiceClient<InterceptedService<T, F>>
where
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::Body>,
Response = http::Response<<T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody>,
>,
<T as tonic::codegen::Service<http::Request<tonic::body::Body>>>::Error:
Into<StdError> + std::marker::Send + std::marker::Sync,
{
TierMutationControlServiceClient::new(InterceptedService::new(inner, interceptor))
}
/// Compress requests with the given encoding.
///
/// This requires the server to support it otherwise it might respond with an
/// error.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.send_compressed(encoding);
self
}
/// Enable decompressing responses.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.accept_compressed(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_decoding_message_size(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
pub async fn prepare_tier_mutation(
&mut self,
request: impl tonic::IntoRequest<super::TierMutationPrepareRequest>,
) -> std::result::Result<tonic::Response<super::TierMutationControlResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/node_service.TierMutationControlService/PrepareTierMutation");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("node_service.TierMutationControlService", "PrepareTierMutation"));
self.inner.unary(req, path, codec).await
}
pub async fn commit_tier_mutation(
&mut self,
request: impl tonic::IntoRequest<super::TierMutationCommitRequest>,
) -> std::result::Result<tonic::Response<super::TierMutationControlResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/node_service.TierMutationControlService/CommitTierMutation");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("node_service.TierMutationControlService", "CommitTierMutation"));
self.inner.unary(req, path, codec).await
}
pub async fn abort_tier_mutation(
&mut self,
request: impl tonic::IntoRequest<super::TierMutationAbortRequest>,
) -> std::result::Result<tonic::Response<super::TierMutationControlResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| tonic::Status::unknown(format!("Service was not ready: {}", e.into())))?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/node_service.TierMutationControlService/AbortTierMutation");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("node_service.TierMutationControlService", "AbortTierMutation"));
self.inner.unary(req, path, codec).await
}
}
}
/// Generated server implementations.
pub mod tier_mutation_control_service_server {
#![allow(unused_variables, dead_code, missing_docs, clippy::wildcard_imports, clippy::let_unit_value)]
use tonic::codegen::*;
/// Generated trait containing gRPC methods that should be implemented for use with TierMutationControlServiceServer.
#[async_trait]
pub trait TierMutationControlService: std::marker::Send + std::marker::Sync + 'static {
async fn prepare_tier_mutation(
&self,
request: tonic::Request<super::TierMutationPrepareRequest>,
) -> std::result::Result<tonic::Response<super::TierMutationControlResponse>, tonic::Status>;
async fn commit_tier_mutation(
&self,
request: tonic::Request<super::TierMutationCommitRequest>,
) -> std::result::Result<tonic::Response<super::TierMutationControlResponse>, tonic::Status>;
async fn abort_tier_mutation(
&self,
request: tonic::Request<super::TierMutationAbortRequest>,
) -> std::result::Result<tonic::Response<super::TierMutationControlResponse>, tonic::Status>;
}
#[derive(Debug)]
pub struct TierMutationControlServiceServer<T> {
inner: Arc<T>,
accept_compression_encodings: EnabledCompressionEncodings,
send_compression_encodings: EnabledCompressionEncodings,
max_decoding_message_size: Option<usize>,
max_encoding_message_size: Option<usize>,
}
impl<T> TierMutationControlServiceServer<T> {
pub fn new(inner: T) -> Self {
Self::from_arc(Arc::new(inner))
}
pub fn from_arc(inner: Arc<T>) -> Self {
Self {
inner,
accept_compression_encodings: Default::default(),
send_compression_encodings: Default::default(),
max_decoding_message_size: None,
max_encoding_message_size: None,
}
}
pub fn with_interceptor<F>(inner: T, interceptor: F) -> InterceptedService<Self, F>
where
F: tonic::service::Interceptor,
{
InterceptedService::new(Self::new(inner), interceptor)
}
/// Enable decompressing requests with the given encoding.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.accept_compression_encodings.enable(encoding);
self
}
/// Compress responses with the given encoding, if the client supports it.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.send_compression_encodings.enable(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.max_decoding_message_size = Some(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.max_encoding_message_size = Some(limit);
self
}
}
impl<T, B> tonic::codegen::Service<http::Request<B>> for TierMutationControlServiceServer<T>
where
T: TierMutationControlService,
B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
match req.uri().path() {
"/node_service.TierMutationControlService/PrepareTierMutation" => {
#[allow(non_camel_case_types)]
struct PrepareTierMutationSvc<T: TierMutationControlService>(pub Arc<T>);
impl<T: TierMutationControlService> tonic::server::UnaryService<super::TierMutationPrepareRequest> for PrepareTierMutationSvc<T> {
type Response = super::TierMutationControlResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, request: tonic::Request<super::TierMutationPrepareRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut =
async move { <T as TierMutationControlService>::prepare_tier_mutation(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = PrepareTierMutationSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/node_service.TierMutationControlService/CommitTierMutation" => {
#[allow(non_camel_case_types)]
struct CommitTierMutationSvc<T: TierMutationControlService>(pub Arc<T>);
impl<T: TierMutationControlService> tonic::server::UnaryService<super::TierMutationCommitRequest> for CommitTierMutationSvc<T> {
type Response = super::TierMutationControlResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, request: tonic::Request<super::TierMutationCommitRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut =
async move { <T as TierMutationControlService>::commit_tier_mutation(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = CommitTierMutationSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/node_service.TierMutationControlService/AbortTierMutation" => {
#[allow(non_camel_case_types)]
struct AbortTierMutationSvc<T: TierMutationControlService>(pub Arc<T>);
impl<T: TierMutationControlService> tonic::server::UnaryService<super::TierMutationAbortRequest> for AbortTierMutationSvc<T> {
type Response = super::TierMutationControlResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(&mut self, request: tonic::Request<super::TierMutationAbortRequest>) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut =
async move { <T as TierMutationControlService>::abort_tier_mutation(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = AbortTierMutationSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(accept_compression_encodings, send_compression_encodings)
.apply_max_message_size_config(max_decoding_message_size, max_encoding_message_size);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
_ => Box::pin(async move {
let mut response = http::Response::new(tonic::body::Body::default());
let headers = response.headers_mut();
headers.insert(tonic::Status::GRPC_STATUS, (tonic::Code::Unimplemented as i32).into());
headers.insert(http::header::CONTENT_TYPE, tonic::metadata::GRPC_CONTENT_TYPE);
Ok(response)
}),
}
}
}
impl<T> Clone for TierMutationControlServiceServer<T> {
fn clone(&self) -> Self {
let inner = self.inner.clone();
Self {
inner,
accept_compression_encodings: self.accept_compression_encodings,
send_compression_encodings: self.send_compression_encodings,
max_decoding_message_size: self.max_decoding_message_size,
max_encoding_message_size: self.max_encoding_message_size,
}
}
}
/// Generated gRPC service name
pub const SERVICE_NAME: &str = "node_service.TierMutationControlService";
impl<T> tonic::server::NamedService for TierMutationControlServiceServer<T> {
const NAME: &'static str = SERVICE_NAME;
}
}
-271
View File
@@ -35,7 +35,6 @@ use tonic::{
transport::{Certificate, Channel, ClientTlsConfig, Endpoint},
};
use tracing::{debug, info, warn};
use uuid::Uuid;
// Type alias for the complex client type
pub type NodeServiceClientType = NodeServiceClient<
@@ -166,9 +165,6 @@ pub fn internode_rpc_max_message_size() -> usize {
pub const HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE: usize = heal_control::RESULT_MAX_SIZE + 1024;
pub const HEAL_CONTROL_PROTOCOL_VERSION: u32 = 2;
pub const HEAL_CONTROL_CAPABILITY_PROBE_PREFIX: &[u8] = b"rustfs-heal-control-capability-v2\0";
pub const TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE: usize = 64 * 1024;
pub const TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE: usize = 1024;
pub const TIER_MUTATION_RPC_MAX_MESSAGE_SIZE: usize = TIER_MUTATION_RPC_MAX_PREPARE_PAYLOAD_SIZE + 4096;
pub fn heal_control_coordinator_epoch(topology_fingerprint: &str) -> Result<u64, &'static str> {
let prefix = topology_fingerprint
@@ -255,100 +251,6 @@ pub fn canonical_heal_control_response_body(
Ok(body)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum TierMutationRpcPhase {
Prepare,
Commit,
Abort,
}
impl TierMutationRpcPhase {
fn as_wire_str(self) -> &'static str {
match self {
Self::Prepare => "prepare",
Self::Commit => "commit",
Self::Abort => "abort",
}
}
}
pub const TIER_MUTATION_RPC_PROTOCOL_VERSION: u32 = 1;
pub fn canonical_tier_mutation_rpc_body(
version: u32,
phase: TierMutationRpcPhase,
mutation_id: Uuid,
canonical_payload: &[u8],
) -> Result<Vec<u8>, std::num::TryFromIntError> {
const DOMAIN: &[u8] = b"rustfs-tier-mutation-rpc-v1\0";
let phase = phase.as_wire_str().as_bytes();
let mutation_id = mutation_id.as_bytes();
let mut body = Vec::with_capacity(DOMAIN.len() + 4 + 8 + phase.len() + mutation_id.len() + 8 + canonical_payload.len());
body.extend_from_slice(DOMAIN);
body.extend_from_slice(&version.to_be_bytes());
body.extend_from_slice(&u64::try_from(phase.len())?.to_be_bytes());
body.extend_from_slice(phase);
body.extend_from_slice(mutation_id);
body.extend_from_slice(&u64::try_from(canonical_payload.len())?.to_be_bytes());
body.extend_from_slice(canonical_payload);
Ok(body)
}
pub struct TierMutationRpcResponseProofInput<'a> {
pub version: u32,
pub phase: TierMutationRpcPhase,
pub mutation_id: Uuid,
pub canonical_payload: &'a [u8],
pub success: bool,
pub state: i32,
pub applied: bool,
pub error_info: Option<&'a str>,
}
pub fn canonical_tier_mutation_rpc_response_body(
input: TierMutationRpcResponseProofInput<'_>,
) -> Result<Vec<u8>, std::num::TryFromIntError> {
const DOMAIN: &[u8] = b"rustfs-tier-mutation-rpc-response-v1\0";
let phase = input.phase.as_wire_str().as_bytes();
let mutation_id = input.mutation_id.as_bytes();
let error_info = input.error_info.map(str::as_bytes);
let error_info_len = error_info.map_or(0, <[u8]>::len);
let mut body = Vec::with_capacity(
DOMAIN.len()
+ 4
+ 8
+ phase.len()
+ mutation_id.len()
+ 8
+ input.canonical_payload.len()
+ 1
+ 4
+ 1
+ 1
+ 8
+ error_info_len,
);
body.extend_from_slice(DOMAIN);
body.extend_from_slice(&input.version.to_be_bytes());
body.extend_from_slice(&u64::try_from(phase.len())?.to_be_bytes());
body.extend_from_slice(phase);
body.extend_from_slice(mutation_id);
body.extend_from_slice(&u64::try_from(input.canonical_payload.len())?.to_be_bytes());
body.extend_from_slice(input.canonical_payload);
body.push(u8::from(input.success));
body.extend_from_slice(&input.state.to_be_bytes());
body.push(u8::from(input.applied));
body.push(u8::from(error_info.is_some()));
body.extend_from_slice(&u64::try_from(error_info_len)?.to_be_bytes());
if let Some(error_info) = error_info {
body.extend_from_slice(error_info);
}
Ok(body)
}
#[cfg(test)]
mod heal_control_tests {
use super::{
@@ -461,179 +363,6 @@ mod heal_control_tests {
}
}
#[cfg(test)]
mod tier_mutation_rpc_tests {
use super::{
TIER_MUTATION_RPC_PROTOCOL_VERSION, TierMutationRpcPhase, TierMutationRpcResponseProofInput,
canonical_tier_mutation_rpc_body, canonical_tier_mutation_rpc_response_body,
};
use crate::proto_gen::node_service::TierMutationPeerState;
use uuid::uuid;
#[test]
fn canonical_tier_mutation_body_binds_phase_id_and_payload() {
let mutation_id = uuid!("12345678-1234-5678-9abc-def012345678");
let payload = b"canonical-intent-record";
let baseline = canonical_tier_mutation_rpc_body(
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
payload,
)
.expect("small mutation body should encode");
let mut golden = b"rustfs-tier-mutation-rpc-v1\0".to_vec();
golden.extend_from_slice(&1_u32.to_be_bytes());
golden.extend_from_slice(&7_u64.to_be_bytes());
golden.extend_from_slice(b"prepare");
golden.extend_from_slice(mutation_id.as_bytes());
golden.extend_from_slice(&u64::try_from(payload.len()).expect("payload length should fit").to_be_bytes());
golden.extend_from_slice(payload);
assert_eq!(baseline, golden);
assert_ne!(
baseline,
canonical_tier_mutation_rpc_body(2, TierMutationRpcPhase::Prepare, mutation_id, payload)
.expect("small mutation body should encode")
);
assert_ne!(
baseline,
canonical_tier_mutation_rpc_body(
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Commit,
mutation_id,
payload,
)
.expect("small mutation body should encode")
);
assert_ne!(
baseline,
canonical_tier_mutation_rpc_body(
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
uuid!("22345678-1234-5678-9abc-def012345678"),
payload,
)
.expect("small mutation body should encode")
);
assert_ne!(
baseline,
canonical_tier_mutation_rpc_body(
TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
b"canonical-intent-record-tampered",
)
.expect("small mutation body should encode")
);
}
#[test]
fn canonical_tier_mutation_response_binds_request_state_and_error() {
let mutation_id = uuid!("12345678-1234-5678-9abc-def012345678");
let payload = b"canonical-intent-record";
let baseline = canonical_tier_mutation_rpc_response_body(TierMutationRpcResponseProofInput {
version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: TierMutationRpcPhase::Prepare,
mutation_id,
canonical_payload: payload,
success: true,
state: TierMutationPeerState::Prepared as i32,
applied: true,
error_info: None,
})
.expect("small mutation response should encode");
let cases = [
TierMutationRpcResponseProofInput {
version: 2,
phase: TierMutationRpcPhase::Prepare,
mutation_id,
canonical_payload: payload,
success: true,
state: TierMutationPeerState::Prepared as i32,
applied: true,
error_info: None,
},
TierMutationRpcResponseProofInput {
version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: TierMutationRpcPhase::Commit,
mutation_id,
canonical_payload: payload,
success: true,
state: TierMutationPeerState::Prepared as i32,
applied: true,
error_info: None,
},
TierMutationRpcResponseProofInput {
version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: TierMutationRpcPhase::Prepare,
mutation_id: uuid!("22345678-1234-5678-9abc-def012345678"),
canonical_payload: payload,
success: true,
state: TierMutationPeerState::Prepared as i32,
applied: true,
error_info: None,
},
TierMutationRpcResponseProofInput {
version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: TierMutationRpcPhase::Prepare,
mutation_id,
canonical_payload: b"tampered-intent-record",
success: true,
state: TierMutationPeerState::Prepared as i32,
applied: true,
error_info: None,
},
TierMutationRpcResponseProofInput {
version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: TierMutationRpcPhase::Prepare,
mutation_id,
canonical_payload: payload,
success: false,
state: TierMutationPeerState::Prepared as i32,
applied: true,
error_info: None,
},
TierMutationRpcResponseProofInput {
version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: TierMutationRpcPhase::Prepare,
mutation_id,
canonical_payload: payload,
success: true,
state: TierMutationPeerState::Committed as i32,
applied: true,
error_info: None,
},
TierMutationRpcResponseProofInput {
version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: TierMutationRpcPhase::Prepare,
mutation_id,
canonical_payload: payload,
success: true,
state: TierMutationPeerState::Prepared as i32,
applied: false,
error_info: None,
},
TierMutationRpcResponseProofInput {
version: TIER_MUTATION_RPC_PROTOCOL_VERSION,
phase: TierMutationRpcPhase::Prepare,
mutation_id,
canonical_payload: payload,
success: true,
state: TierMutationPeerState::Prepared as i32,
applied: true,
error_info: Some("error"),
},
];
for case in cases {
assert_ne!(
baseline,
canonical_tier_mutation_rpc_response_body(case).expect("small mutation response should encode")
);
}
}
}
/// Whether internode metadata RPCs should send only the msgpack `_bin` payloads and leave the JSON
/// compatibility strings empty (grpc-optimization P2-1). Shared by the client (`remote_disk`) and
/// server (`node_service`) send paths. Defaults to `false` (dual-write); see
-40
View File
@@ -74,7 +74,6 @@ message GetBucketInfoResponse {
message DeleteBucketRequest {
string bucket = 1;
string options = 2;
}
message DeleteBucketResponse {
@@ -866,39 +865,6 @@ message LoadTransitionTierConfigResponse {
optional string error_info = 2;
}
message TierMutationPrepareRequest {
uint32 version = 1;
string mutation_id = 2;
bytes canonical_payload = 3;
}
message TierMutationCommitRequest {
uint32 version = 1;
string mutation_id = 2;
bytes canonical_payload = 3;
}
message TierMutationAbortRequest {
uint32 version = 1;
string mutation_id = 2;
bytes canonical_payload = 3;
}
enum TierMutationPeerState {
TIER_MUTATION_PEER_STATE_UNSPECIFIED = 0;
TIER_MUTATION_PEER_STATE_PREPARED = 1;
TIER_MUTATION_PEER_STATE_COMMITTED = 2;
TIER_MUTATION_PEER_STATE_ABORTED = 3;
}
message TierMutationControlResponse {
bool success = 1;
TierMutationPeerState state = 2;
bool applied = 3;
optional string error_info = 4;
bytes response_proof = 5;
}
message GetLiveEventsRequest {
uint64 after_sequence = 1;
uint32 limit = 2;
@@ -1017,9 +983,3 @@ service NodeService {
service HealControlService {
rpc HealControl(HealControlRequest) returns (HealControlResponse) {};
}
service TierMutationControlService {
rpc PrepareTierMutation(TierMutationPrepareRequest) returns (TierMutationControlResponse) {};
rpc CommitTierMutation(TierMutationCommitRequest) returns (TierMutationControlResponse) {};
rpc AbortTierMutation(TierMutationAbortRequest) returns (TierMutationControlResponse) {};
}
+1 -1
View File
@@ -43,7 +43,7 @@ crc-fast = { workspace = true }
pin-project-lite.workspace = true
serde = { workspace = true, features = ["derive"] }
bytes = { workspace = true, features = ["serde"] }
reqwest = { workspace = true, features = ["stream"] }
reqwest = { workspace = true, default-features = false, features = ["rustls", "http2", "system-proxy", "stream"] }
rustls-pki-types.workspace = true
tokio-util = { workspace = true, features = ["io", "compat"] }
faster-hex.workspace = true
-29
View File
@@ -1496,7 +1496,6 @@ mod tests {
use axum::{Router, body::Body, extract::State, http::StatusCode, response::IntoResponse, routing::get};
use futures::stream::{self, StreamExt as _};
use http_body_util::BodyExt as _;
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
use std::io::{self, IoSlice};
use std::sync::{
Arc,
@@ -1742,7 +1741,6 @@ mod tests {
let addr = listener.local_addr().expect("listener local address should be available");
let app = Router::new()
.route("/stream", get(get_stream).head(reject_head).put(accept_put))
.route(WALK_DIR_PATH, get(get_stream))
.route("/reject-put", get(get_stream).put(reject_put))
.route("/stall", get(get_stalling_stream))
.route("/delayed-first", get(get_delayed_first_chunk))
@@ -1803,33 +1801,6 @@ mod tests {
handle.abort();
}
#[tokio::test]
async fn http_reader_records_walk_dir_recv_bytes() {
let state = TestState::default();
let Some((base_url, handle)) = start_test_server(state.clone()).await else {
return;
};
let metrics = global_internode_metrics();
let before = metrics.snapshot().recv_bytes_total;
let url = base_url.replace("/stream", WALK_DIR_PATH);
let mut reader = HttpReader::new(url, Method::GET, HeaderMap::new(), None)
.await
.expect("walk_dir reader should open");
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.expect("walk_dir body should read to EOF");
let after = metrics.snapshot().recv_bytes_total;
assert_eq!(buf, b"hello");
assert_eq!(state.get_count.load(Ordering::SeqCst), 1);
assert!(
after >= before.saturating_add(5),
"walk_dir HttpReader should record streamed bytes as internode recv bytes: before={before}, after={after}"
);
handle.abort();
}
#[tokio::test]
async fn http_reader_stall_timeout_triggers_after_progress_stops() {
let state = TestState::default();
+11 -11
View File
@@ -105,17 +105,17 @@ impl SessionCtxFactory {
{\"id\":10,\"name\":\"Jack\",\"age\":38,\"department\":\"Finance\",\"salary\":7500}\n"
.to_vec()
} else {
b"id,name,age,department,salary\n\
1,Alice,25,HR,05000\n\
2,Bob,30,IT,6000\n\
3,Charlie,35,Finance,7000\n\
4,Diana,22,Marketing,4500\n\
5,Eve,28,IT,5500\n\
6,Frank,40,Finance,8000\n\
7,Grace,26,HR,5200\n\
8,Henry,32,IT,6200\n\
9,Ivy,24,Marketing,4800\n\
10,Jack,38,Finance,7500"
b"id,name,age,department,salary
1,Alice,25,HR,5000
2,Bob,30,IT,6000
3,Charlie,35,Finance,7000
4,Diana,22,Marketing,4500
5,Eve,28,IT,5500
6,Frank,40,Finance,8000
7,Grace,26,HR,5200
8,Henry,32,IT,6200
9,Ivy,24,Marketing,4800
10,Jack,38,Finance,7500"
.to_vec()
};
@@ -180,7 +180,6 @@ impl SimpleQueryDispatcher {
let mut need_rename_volume_name = false;
let mut need_ignore_volume_name = false;
let mut file_format = CsvFormat::default()
.with_schema_infer_max_rec(0)
.with_comment(
csv.comments
.clone()
+14 -14
View File
@@ -224,20 +224,20 @@ mod tests {
.to_vec();
let expected = [
"+----+---------+-----+------------+--------+",
"| id | name | age | department | salary |",
"+----+---------+-----+------------+--------+",
"| 1 | Alice | 25 | HR | 05000 |",
"| 2 | Bob | 30 | IT | 6000 |",
"| 3 | Charlie | 35 | Finance | 7000 |",
"| 4 | Diana | 22 | Marketing | 4500 |",
"| 5 | Eve | 28 | IT | 5500 |",
"| 6 | Frank | 40 | Finance | 8000 |",
"| 7 | Grace | 26 | HR | 5200 |",
"| 8 | Henry | 32 | IT | 6200 |",
"| 9 | Ivy | 24 | Marketing | 4800 |",
"| 10 | Jack | 38 | Finance | 7500 |",
"+----+---------+-----+------------+--------+",
"+----------------+---------+-----+------------+--------+",
"| id | name | age | department | salary |",
"+----------------+---------+-----+------------+--------+",
"| 1 | Alice | 25 | HR | 5000 |",
"| 2 | Bob | 30 | IT | 6000 |",
"| 3 | Charlie | 35 | Finance | 7000 |",
"| 4 | Diana | 22 | Marketing | 4500 |",
"| 5 | Eve | 28 | IT | 5500 |",
"| 6 | Frank | 40 | Finance | 8000 |",
"| 7 | Grace | 26 | HR | 5200 |",
"| 8 | Henry | 32 | IT | 6200 |",
"| 9 | Ivy | 24 | Marketing | 4800 |",
"| 10 | Jack | 38 | Finance | 7500 |",
"+----------------+---------+-----+------------+--------+",
];
assert_batches_eq!(expected, &results);
@@ -15,7 +15,6 @@
#[cfg(test)]
mod integration_tests {
use crate::{create_fresh_db, get_global_db, instance::make_rustfsms};
use datafusion::arrow::array::{Array, StringArray};
use rustfs_s3select_api::{
QueryError,
query::{Context, Query},
@@ -171,123 +170,14 @@ mod integration_tests {
}
#[tokio::test]
async fn test_csv_values_remain_strings() {
let sql = "SELECT salary FROM S3Object LIMIT 1";
async fn test_select_with_where_clause() {
let sql = "SELECT name, age FROM S3Object WHERE age > 30";
let input = create_test_input(sql);
let db = get_global_db(input.clone(), true).await.expect("create CSV test database");
let db = get_global_db(input.clone(), true).await.unwrap();
let query = Query::new(Context { input: Arc::new(input) }, sql.to_string());
let batches = db
.execute(&query)
.await
.expect("execute CSV query")
.result()
.chunk_result()
.await
.expect("collect CSV query output");
let salaries = batches
.first()
.expect("CSV query should return one batch")
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("CSV column should use UTF-8 string values");
assert_eq!(salaries.value(0), "05000");
}
#[tokio::test]
async fn test_csv_header_modes_keep_positional_string_columns() {
for (header, expected) in [
(FileHeaderInfo::IGNORE, ["05000", "6000"]),
(FileHeaderInfo::NONE, ["salary", "05000"]),
] {
let sql = "SELECT _5 FROM S3Object LIMIT 2";
let mut input = create_test_input(sql);
input
.request
.input_serialization
.csv
.as_mut()
.expect("CSV input should be configured")
.file_header_info = Some(FileHeaderInfo::from_static(header));
let db = get_global_db(input.clone(), true).await.expect("create CSV test database");
let query = Query::new(Context { input: Arc::new(input) }, sql.to_string());
let batches = db
.execute(&query)
.await
.expect("execute positional CSV query")
.result()
.chunk_result()
.await
.expect("collect positional CSV query output");
let values = batches
.iter()
.flat_map(|batch| {
let column = batch
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("positional CSV column should use UTF-8 strings");
(0..column.len()).map(|row| column.value(row).to_string()).collect::<Vec<_>>()
})
.collect::<Vec<_>>();
assert_eq!(
values,
expected.map(|value| value.to_string()),
"unexpected values for FileHeaderInfo={header}"
);
}
}
#[tokio::test]
async fn test_csv_numeric_comparison_with_and_without_cast() {
for sql in [
"SELECT name, age FROM S3Object WHERE age > 30",
"SELECT name, age FROM S3Object WHERE CAST(age AS INT) > 30",
] {
let input = create_test_input(sql);
let db = get_global_db(input.clone(), true).await.expect("create CSV test database");
let query = Query::new(Context { input: Arc::new(input) }, sql.to_string());
let batches = db
.execute(&query)
.await
.expect("execute CSV numeric comparison")
.result()
.chunk_result()
.await
.expect("collect CSV numeric comparison output");
let mut rows = Vec::new();
for batch in &batches {
let names = batch
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("CSV name column should use UTF-8 strings");
let ages = batch
.column(1)
.as_any()
.downcast_ref::<StringArray>()
.expect("CSV age column should use UTF-8 strings");
for row in 0..batch.num_rows() {
rows.push((names.value(row).to_string(), ages.value(row).to_string()));
}
}
assert_eq!(
rows,
[
("Charlie".to_string(), "35".to_string()),
("Frank".to_string(), "40".to_string()),
("Henry".to_string(), "32".to_string()),
("Jack".to_string(), "38".to_string()),
],
"unexpected rows for query: {sql}"
);
}
let result = db.execute(&query).await;
assert!(result.is_ok());
}
#[tokio::test]
@@ -41,11 +41,10 @@ use storage_api::lifecycle::{
BUCKET_LIFECYCLE_CONFIG, BucketOperations, BucketOptions, BucketVersioningSys, CompletePart, DiskOption, ECStore,
EcstoreError, Endpoint, EndpointServerPools, Endpoints, IlmAction, LcEvent, LcEventSrc, ListOperations as _,
MakeBucketOptions, MockWarmBackend, MultipartOperations as _, ObjectIO as _, ObjectOperations as _, PoolEndpoints,
STORAGE_FORMAT_FILE, TRANSITION_PENDING, TransitionCleanupStoreBarrier, TransitionOptions, assert_transition_meta_consistent,
enqueue_transition_for_existing_objects, expire_transitioned_object, free_version_count, get_bucket_metadata,
get_global_tier_config_mgr, init_background_expiry, init_bucket_metadata_sys, init_local_disks, is_err_object_not_found,
is_err_version_not_found, new_disk, path2_bucket_object_with_base_path, recover_tier_delete_journal_entries,
register_mock_tier_util, update_bucket_metadata, wait_for_free_version_absence,
STORAGE_FORMAT_FILE, TransitionOptions, assert_transition_meta_consistent, enqueue_transition_for_existing_objects,
expire_transitioned_object, free_version_count, get_bucket_metadata, get_global_tier_config_mgr, init_background_expiry,
init_bucket_metadata_sys, init_local_disks, is_err_object_not_found, is_err_version_not_found, new_disk,
path2_bucket_object_with_base_path, register_mock_tier_util, update_bucket_metadata, wait_for_free_version_absence,
};
static GLOBAL_ENV: OnceLock<(Vec<PathBuf>, Arc<ECStore>)> = OnceLock::new();
@@ -604,7 +603,6 @@ mod serial_tests {
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&tier_name).await;
backend.set_put_remote_version(Some(String::new())).await;
let bucket_name = format!("test-expire-get-race-{}", &Uuid::new_v4().simple().to_string()[..8]);
let object_name = "test/race-object.bin";
@@ -641,10 +639,6 @@ mod serial_tests {
.get_object_info(bucket_name.as_str(), object_name, &ObjectOptions::default())
.await
.expect("Failed to load transitioned object info");
assert!(
oi.transitioned_object.version_id.is_empty(),
"the regression must exercise an unversioned remote tier"
);
// Concurrent GET loop: hammer GET while the expiry runs. Every outcome
// must be a full correct body or a clean not-found -- never a tier-fetch
@@ -738,327 +732,6 @@ mod serial_tests {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial]
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
async fn rejected_transition_candidate_is_recovered_from_persisted_delete_journal() {
let (_disk_paths, ecstore) = setup_isolated_test_env(false).await;
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&tier_name).await;
backend.set_put_read_limit(Some(4096)).await;
backend.set_put_remote_version(Some(String::new())).await;
backend.set_remove_failure(true);
let bucket_name = format!("test-transition-cleanup-journal-{}", &Uuid::new_v4().simple().to_string()[..8]);
let object_name = "test/rejected-candidate.bin";
let payload = b"rejected remote candidate must not replace the local source".repeat(1024);
create_test_bucket(&ecstore, bucket_name.as_str()).await;
upload_test_object(&ecstore, bucket_name.as_str(), object_name, &payload).await;
let original = ecstore
.get_object_info(bucket_name.as_str(), object_name, &ObjectOptions::default())
.await
.expect("source object metadata should resolve before transition");
let opts = ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().expect("uploaded source object should have an ETag"),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
};
ecstore
.transition_object(bucket_name.as_str(), object_name, &opts)
.await
.expect_err("a remotely accepted partial upload must not commit transition metadata");
let put_versions = backend.put_versions().await;
assert_eq!(put_versions.len(), 1, "transition should create exactly one remote candidate");
assert!(put_versions[0].1.is_empty());
assert_eq!(
backend.object_count().await,
1,
"failed cleanup must retain the remote candidate for recovery"
);
assert!(
backend.remove_versions().await.is_empty(),
"no cleanup path may delete the candidate while remove failures are enabled"
);
let retained = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("tier delete journal recovery should scan the persisted candidate");
assert_eq!(retained.scanned, 1);
assert_eq!(retained.deleted, 0);
assert_eq!(retained.failed, 1);
assert_eq!(backend.object_count().await, 1, "failed recovery must retain the remote candidate");
backend.set_remove_failure(false);
let recovered = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("tier delete journal recovery should delete the retained candidate");
assert_eq!(recovered.scanned, 1);
assert_eq!(recovered.deleted, 1);
assert_eq!(recovered.failed, 0);
let removed_versions = backend.remove_versions().await;
assert!(!removed_versions.is_empty(), "recovery must issue at least one successful delete");
assert!(
removed_versions.iter().all(|removed| removed == &put_versions[0]),
"every idempotent cleanup must delete the exact PUT object and version"
);
assert_eq!(backend.object_count().await, 0, "recovery should remove the rejected remote candidate");
let empty = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("a removed tier delete journal entry should no longer be listed");
assert_eq!(empty.scanned, 0, "successful recovery must remove the persisted journal entry");
assert_eq!(
read_object_fully(&ecstore, bucket_name.as_str(), object_name).await,
payload,
"rejected transition cleanup must leave the source object readable"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial]
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
async fn cancelled_before_cleanup_store_resolution_persists_journal() {
let (_disk_paths, ecstore) = setup_isolated_test_env(false).await;
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&tier_name).await;
backend.set_put_remote_version(Some(Uuid::new_v4().to_string())).await;
backend.set_reject_non_empty_remote_versions(true);
backend.set_remove_failure(true);
let cleanup_store_barrier = TransitionCleanupStoreBarrier::install();
let bucket_name = format!("test-transition-cancel-cleanup-{}", &Uuid::new_v4().simple().to_string()[..8]);
let object_name = "test/rejected-candidate.bin";
let payload = b"cancelled rejected cleanup must retain durable recovery evidence".repeat(1024);
create_test_bucket(&ecstore, bucket_name.as_str()).await;
upload_test_object(&ecstore, bucket_name.as_str(), object_name, &payload).await;
let original = ecstore
.get_object_info(bucket_name.as_str(), object_name, &ObjectOptions::default())
.await
.expect("source object metadata should resolve before transition");
let opts = ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().expect("uploaded source object should have an ETag"),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
};
let transition_store = ecstore.clone();
let transition_bucket = bucket_name.clone();
let transition = tokio::spawn(async move {
transition_store
.transition_object(transition_bucket.as_str(), object_name, &opts)
.await
});
cleanup_store_barrier.wait_until_paused().await;
transition.abort();
assert!(
transition
.await
.expect_err("aborted transition task should be cancelled")
.is_cancelled()
);
let retained = tokio::time::timeout(Duration::from_secs(30), async {
loop {
let recovery = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("the cancelled transition journal should be readable");
if recovery.scanned > 0 {
break recovery;
}
tokio::task::yield_now().await;
}
})
.await
.expect("Drop should persist the rejected candidate through the saved instance context");
assert_eq!((retained.scanned, retained.deleted, retained.failed), (1, 0, 1));
tokio::time::timeout(Duration::from_secs(5), async {
while backend.exact_remove_count() < 2 {
tokio::task::yield_now().await;
}
})
.await
.expect("Drop cleanup and failed journal recovery must both preserve the exact version constraint");
let failed_exact_attempts = backend.exact_remove_count();
assert_eq!(backend.object_count().await, 1);
assert!(backend.remove_versions().await.is_empty());
backend.set_remove_failure(false);
let recovered = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("recovery should delete the candidate retained by the cancelled transition");
assert_eq!((recovered.scanned, recovered.deleted, recovered.failed), (1, 1, 0));
assert_eq!(backend.remove_versions().await, backend.put_versions().await);
assert_eq!(backend.exact_remove_count(), failed_exact_attempts + 1);
assert_eq!(backend.object_count().await, 0);
let empty = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("successful recovery should remove the cancellation journal");
assert_eq!(empty.scanned, 0);
assert_eq!(
read_object_fully(&ecstore, bucket_name.as_str(), object_name).await,
payload,
"cancelled transition cleanup must preserve the local source"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial]
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial"]
async fn rejected_transition_cleanup_durability_matrix() {
#[derive(Clone, Copy)]
enum CleanupCase {
Persisted,
DeleteFallback,
RetryPersisted,
FullyFailed,
}
let (_disk_paths, ecstore) = setup_isolated_test_env(false).await;
for case in [
CleanupCase::Persisted,
CleanupCase::DeleteFallback,
CleanupCase::RetryPersisted,
CleanupCase::FullyFailed,
] {
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
let backend = register_mock_tier(&tier_name).await;
let remote_version = Uuid::new_v4().to_string();
backend.set_put_remote_version(Some(remote_version.clone())).await;
backend.set_reject_non_empty_remote_versions(true);
backend.set_remove_failure(matches!(case, CleanupCase::FullyFailed));
let put_barrier = backend.arm_put_barrier().await;
let remove_barrier = if matches!(case, CleanupCase::RetryPersisted) {
Some(backend.arm_failing_remove_barrier().await)
} else {
None
};
let bucket_name = format!("test-transition-journal-failure-{}", &Uuid::new_v4().simple().to_string()[..8]);
let object_name = "test/rejected-candidate.bin";
let payload = b"journal failure must either delete the exact candidate or report both failures".repeat(1024);
create_test_bucket(&ecstore, bucket_name.as_str()).await;
upload_test_object(&ecstore, bucket_name.as_str(), object_name, &payload).await;
let original = ecstore
.get_object_info(bucket_name.as_str(), object_name, &ObjectOptions::default())
.await
.expect("source object metadata should resolve before transition");
let opts = ObjectOptions {
no_lock: true,
transition: TransitionOptions {
status: TRANSITION_PENDING.to_string(),
tier: tier_name,
etag: original.etag.clone().expect("uploaded source object should have an ETag"),
..Default::default()
},
version_id: original.version_id.map(|version| version.to_string()),
mod_time: original.mod_time,
..Default::default()
};
let transition_store = ecstore.clone();
let transition_bucket = bucket_name.clone();
let transition = tokio::spawn(async move {
transition_store
.transition_object(transition_bucket.as_str(), object_name, &opts)
.await
});
put_barrier.wait_until_paused().await;
let set = ecstore.pools[0].get_disks(0);
let mut saved_disks = if matches!(case, CleanupCase::Persisted) {
None
} else {
let mut disks = set.disks.write().await;
let saved = std::mem::take(&mut *disks);
*disks = vec![None; saved.len()];
Some(saved)
};
put_barrier.release();
if let Some(remove_barrier) = remove_barrier.as_ref() {
remove_barrier.wait_until_paused().await;
*set.disks.write().await = saved_disks.take().expect("offline disks should be restorable");
backend.set_remove_failure(true);
remove_barrier.release();
}
let transition_result = tokio::time::timeout(Duration::from_secs(30), transition).await;
if let Some(saved_disks) = saved_disks {
*set.disks.write().await = saved_disks;
}
let err = transition_result
.expect("transition should finish while validating cleanup durability")
.expect("transition task should not panic")
.expect_err("a versioned candidate must not commit to an unversioned tier");
match case {
CleanupCase::Persisted | CleanupCase::DeleteFallback => {
assert_eq!(backend.remove_versions().await, backend.put_versions().await);
assert_eq!(backend.object_count().await, 0, "cleanup must remove the exact candidate");
let recovery = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("successful cleanup must not retain a journal entry");
assert_eq!(recovery.scanned, 0);
}
CleanupCase::RetryPersisted => {
assert!(
!err.to_string().contains("journal retry error"),
"a successful journal retry must preserve the original version-constraint error"
);
assert_eq!(backend.object_count().await, 1);
let retained = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("the retried journal should be recoverable");
assert_eq!((retained.scanned, retained.deleted, retained.failed), (1, 0, 1));
backend.set_remove_failure(false);
let recovered = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("recovery should delete the exact retried candidate");
assert_eq!((recovered.scanned, recovered.deleted, recovered.failed), (1, 1, 0));
assert_eq!(backend.remove_versions().await, backend.put_versions().await);
assert_eq!(backend.object_count().await, 0);
let empty = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("successful recovery must remove the retried journal");
assert_eq!(empty.scanned, 0);
}
CleanupCase::FullyFailed => {
let message = err.to_string();
assert!(message.contains("initial journal error"), "{message}");
assert!(message.contains("cleanup error"), "{message}");
assert!(message.contains("journal retry error"), "{message}");
assert_eq!(backend.object_count().await, 1, "both failed safeguards must leave the candidate visible");
assert!(backend.remove_versions().await.is_empty());
let recovery = recover_tier_delete_journal_entries(ecstore.clone(), 100, None)
.await
.expect("failed journal writes must not create partial recovery entries");
assert_eq!(recovery.scanned, 0);
}
}
assert_eq!(
read_object_fully(&ecstore, bucket_name.as_str(), object_name).await,
payload,
"rejected transition cleanup must preserve the local source"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial]
#[ignore = "global-state ILM integration test: runs serialized in the CI ILM Integration (serial) lane, see ci.yml test-ilm-integration-serial and rustfs/backlog#1148 (ilm-1)"]
+7 -9
View File
@@ -12,11 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
pub(crate) use rustfs_ecstore::api::bucket::lifecycle::tier_delete_journal::recover_tier_delete_journal_entries;
pub(crate) use rustfs_ecstore::api::bucket::lifecycle::{
bucket_lifecycle_audit::LcEventSrc,
bucket_lifecycle_ops::{enqueue_transition_for_existing_objects, expire_transitioned_object, init_background_expiry},
lifecycle::{Event as LcEvent, IlmAction, TRANSITION_PENDING, TransitionOptions},
lifecycle::{Event as LcEvent, IlmAction, TransitionOptions},
};
pub(crate) use rustfs_ecstore::api::bucket::metadata::BUCKET_LIFECYCLE_CONFIG;
pub(crate) use rustfs_ecstore::api::bucket::metadata_sys::{
@@ -33,8 +32,8 @@ pub(crate) use rustfs_ecstore::api::storage::{ECStore, init_local_disks};
// backend and xl.meta assertion helpers now live in ecstore behind the
// `test-util` feature instead of being copied into this crate.
pub(crate) use rustfs_ecstore::api::tier::test_util::{
MockWarmBackend, TransitionCleanupStoreBarrier, assert_transition_meta_consistent, free_version_count,
register_mock_tier as register_mock_tier_util, wait_for_free_version_absence,
MockWarmBackend, assert_transition_meta_consistent, free_version_count, register_mock_tier as register_mock_tier_util,
wait_for_free_version_absence,
};
use rustfs_storage_api as storage_contracts;
@@ -46,11 +45,10 @@ pub(crate) mod lifecycle {
pub(crate) use super::{
BUCKET_LIFECYCLE_CONFIG, BucketVersioningSys, DiskOption, ECStore, EcstoreError, Endpoint, EndpointServerPools,
Endpoints, IlmAction, LcEvent, LcEventSrc, MockWarmBackend, PoolEndpoints, STORAGE_FORMAT_FILE, TRANSITION_PENDING,
TransitionCleanupStoreBarrier, TransitionOptions, assert_transition_meta_consistent,
enqueue_transition_for_existing_objects, expire_transitioned_object, free_version_count, get_bucket_metadata,
get_global_tier_config_mgr, init_background_expiry, init_bucket_metadata_sys, init_local_disks, is_err_object_not_found,
is_err_version_not_found, new_disk, path2_bucket_object_with_base_path, recover_tier_delete_journal_entries,
Endpoints, IlmAction, LcEvent, LcEventSrc, MockWarmBackend, PoolEndpoints, STORAGE_FORMAT_FILE, TransitionOptions,
assert_transition_meta_consistent, enqueue_transition_for_existing_objects, expire_transitioned_object,
free_version_count, get_bucket_metadata, get_global_tier_config_mgr, init_background_expiry, init_bucket_metadata_sys,
init_local_disks, is_err_object_not_found, is_err_version_not_found, new_disk, path2_bucket_object_with_base_path,
register_mock_tier_util, update_bucket_metadata, wait_for_free_version_absence,
};
}
+2 -4
View File
@@ -33,7 +33,7 @@ pub struct MakeBucketOptions {
}
/// Operation to perform on a bucket during site replication delete.
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Default, Clone, PartialEq)]
pub enum SRBucketDeleteOp {
/// No operation.
#[default]
@@ -45,7 +45,7 @@ pub enum SRBucketDeleteOp {
}
/// Options for deleting a bucket.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[derive(Debug, Default, Clone)]
pub struct DeleteBucketOptions {
/// Skip acquiring namespace lock.
pub no_lock: bool,
@@ -53,8 +53,6 @@ pub struct DeleteBucketOptions {
pub no_recreate: bool,
/// Force deletion even if bucket is not empty.
pub force: bool,
/// Force deletion only after the local peer verifies the bucket is empty.
pub force_if_empty: bool,
/// Site replication delete operation.
pub srdelete_op: SRBucketDeleteOp,
}
+1 -1
View File
@@ -26,7 +26,7 @@ lapin = { workspace = true, default-features = false, features = ["tokio", "rust
libc = { workspace = true }
pulsar = { workspace = true, default-features = false, features = ["tokio-rustls-runtime", "telemetry"] }
regex = { workspace = true }
reqwest = { workspace = true }
reqwest = { workspace = true, default-features = false, features = ["rustls", "http2", "system-proxy"] }
rumqttc = { workspace = true, features = ["websocket"] }
redis = { workspace = true, features = ["connection-manager", "tokio-rustls-comp", "tls-rustls-insecure"] }
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
-59
View File
@@ -896,63 +896,4 @@ mod tests {
handle.join().expect("mock server thread");
}
#[tokio::test]
async fn test_webhook_client_reaches_https_origin_with_custom_ca() {
use rustls::{
ServerConfig, ServerConnection, StreamOwned,
pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},
};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Once};
static INSTALL_CRYPTO_PROVIDER: Once = Once::new();
INSTALL_CRYPTO_PROVIDER.call_once(|| {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
});
let rcgen::CertifiedKey { cert, signing_key } =
rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).expect("cert should generate");
let temp_dir = tempfile::tempdir().expect("tempdir");
let ca_path = temp_dir.path().join("webhook-ca.pem");
std::fs::write(&ca_path, cert.pem()).expect("write ca pem");
let cert_chain = vec![cert.der().clone()];
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
let server_config = Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key_der)
.expect("server cert should be valid"),
);
let listener = TcpListener::bind("127.0.0.1:0").expect("bind tls server");
let addr = listener.local_addr().expect("local addr");
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().expect("accept tls client");
let connection = ServerConnection::new(server_config).expect("server connection");
let mut tls_stream = StreamOwned::new(connection, stream);
let mut buf = [0u8; 1024];
let _ = tls_stream.read(&mut buf);
let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok";
tls_stream.write_all(response.as_bytes()).expect("write response");
tls_stream.flush().expect("flush response");
});
let args = WebhookArgs {
client_ca: ca_path.to_string_lossy().into_owned(),
..base_args()
};
let client = WebhookTarget::<serde_json::Value>::build_http_client(&args).expect("build https client");
let resp = client
.head(format!("https://localhost:{}/hook", addr.port()))
.send()
.await
.expect("https webhook probe should trust configured ca");
assert_eq!(resp.status(), reqwest::StatusCode::OK);
assert_eq!(resp.text_with_charset("utf-8").await.expect("read response body"), "");
handle.join().expect("tls server thread");
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ http = { workspace = true }
ipnetwork = { workspace = true, features = ["serde"] }
metrics = { workspace = true }
moka = { workspace = true, features = ["future", "sync"] }
reqwest = { workspace = true, features = ["json"] }
reqwest = { workspace = true, default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "json"] }
rustfs-config = { workspace = true }
rustfs-utils = { workspace = true, features = ["net"] }
serde = { workspace = true, features = ["derive"] }
+6
View File
@@ -41,6 +41,12 @@ allow-git = [
# owner: marshawcoco review: 2026-10
"https://github.com/s3s-project/s3s.git",
"https://github.com/apache/datafusion.git",
# hyper pinned (via [patch.crates-io]) to a rev carrying the HTTP/1
# flush-before-shutdown fix (hyperium/hyper#4018, commit 72046cc7) that is
# not yet in a crates.io release. Fixes sporadic large-GET `unexpected EOF`
# (rustfs/backlog#1232). Remove once a released hyper > 1.10.1 contains it.
# owner: rustfs-maintainers review: 2026-07
"https://github.com/hyperium/hyper.git",
]
[bans]
-5
View File
@@ -56,7 +56,6 @@ Explicit per-operation overrides always win over the profile, so you can select
| Environment variable | Default | `high_latency` default | Bounds |
|---|---:|---:|---|
| `RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS` | `5` | `60` | Max time a single walk filesystem call may go without answering during a listing walk. **The knob for listing failures on large prefixes.** |
| `RUSTFS_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS` | `10` | `120` | Max time the metacache merge consumer waits for the next visible entry from a walk reader. Values below the resolved stall timeout are clamped up to the stall timeout. |
| `RUSTFS_DRIVE_WALKDIR_TIMEOUT_SECS` | `5` | `60` | Total wall-clock timeout for a `walk_dir`. Retained for non-foreground callers; the foreground listing path no longer uses it (see below). |
| `RUSTFS_DRIVE_LIST_DIR_TIMEOUT_SECS` | `5` | `60` | Timeout for a standalone `list_dir` metadata listing. |
| `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS` | `5` | `60` | Timeout for metadata reads such as `read_metadata`. |
@@ -119,10 +118,6 @@ Raise the walk stall budget, or select the high-latency profile:
# widen just the listing walk budget
-e RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS=60
# widen the metacache reader wait budget when drives keep progressing but do
# not publish visible entries quickly enough for the merge consumer
-e RUSTFS_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS=120
# or raise every profile-aware drive default at once
-e RUSTFS_DRIVE_TIMEOUT_PROFILE=high_latency
```
+1 -1
View File
@@ -118,7 +118,7 @@ hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-gra
http.workspace = true
http-body.workspace = true
http-body-util.workspace = true
reqwest = { workspace = true, features = ["json", "stream"] }
reqwest = { workspace = true, default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream"] }
socket2 = { workspace = true, features = ["all"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "process", "io-util", "fs"] }
tokio-rustls = { workspace = true, default-features = false, features = ["logging", "tls12", "aws-lc-rs"] }
+8 -498
View File
@@ -28,7 +28,7 @@ use crate::auth::{check_key_valid, get_session_token};
use crate::cluster_snapshot::{
ClusterReadOnlySnapshot, ClusterRuntimeReadinessState, ClusterRuntimeStatusSnapshot, cluster_has_actionable_pressure,
};
use crate::server::{ADMIN_PREFIX, ReadinessDegradedReason, RemoteAddr};
use crate::server::{ADMIN_PREFIX, RemoteAddr};
use http::{HeaderMap, HeaderValue, StatusCode};
use hyper::Method;
use matchit::Params;
@@ -63,7 +63,6 @@ pub(crate) struct ClusterSnapshotDiscoveryResponse {
pub rpc_boundary: Option<CapabilityStatus>,
pub workload_admission: Option<CapabilityStatus>,
pub runtime: Option<CapabilityStatus>,
pub components: Option<ClusterComponentStatusView>,
}
async fn authorize_cluster_snapshot_request(req: &S3Request<Body>) -> S3Result<()> {
@@ -120,8 +119,7 @@ pub(crate) async fn build_cluster_snapshot_discovery_response() -> ClusterSnapsh
match snapshot {
Some(snapshot) => {
let components = ClusterComponentStatusView::from_snapshot(&snapshot);
let summary = ClusterSnapshotSummary::from_snapshot_and_components(&snapshot, &components);
let summary = ClusterSnapshotSummary::from(&snapshot);
ClusterSnapshotDiscoveryResponse {
path,
summary: Some(summary.actionable_pressure.clone()),
@@ -130,7 +128,6 @@ pub(crate) async fn build_cluster_snapshot_discovery_response() -> ClusterSnapsh
rpc_boundary: Some(summary.rpc_boundary),
workload_admission: Some(summary.workload_admission),
runtime: Some(summary.runtime),
components: Some(components),
}
}
None => ClusterSnapshotDiscoveryResponse {
@@ -141,7 +138,6 @@ pub(crate) async fn build_cluster_snapshot_discovery_response() -> ClusterSnapsh
rpc_boundary: None,
workload_admission: None,
runtime: None,
components: None,
},
}
}
@@ -151,7 +147,6 @@ pub(crate) struct ClusterSnapshotView {
pub summary: ClusterSnapshotSummary,
pub runtime_capabilities_path: String,
pub extensions_catalog_path: String,
pub components: ClusterComponentStatusView,
pub topology: TopologySnapshot,
pub membership: ClusterMembershipView,
pub pool_state: ClusterPoolStateView,
@@ -166,14 +161,11 @@ pub(crate) struct ClusterSnapshotView {
impl From<ClusterReadOnlySnapshot> for ClusterSnapshotView {
fn from(snapshot: ClusterReadOnlySnapshot) -> Self {
let components = ClusterComponentStatusView::from_snapshot(&snapshot);
let summary = ClusterSnapshotSummary::from_snapshot_and_components(&snapshot, &components);
let actionable_pressure = cluster_has_actionable_pressure(&snapshot);
Self {
summary,
summary: ClusterSnapshotSummary::from(&snapshot),
runtime_capabilities_path: format!("{}{}", ADMIN_PREFIX, system::RUNTIME_CAPABILITIES_ROUTE_SUFFIX),
extensions_catalog_path: format!("{}{}", ADMIN_PREFIX, "/v4/extensions/catalog"),
components,
topology: snapshot.topology,
membership: ClusterMembershipView::from(snapshot.membership),
pool_state: ClusterPoolStateView::from(snapshot.pool_state),
@@ -188,93 +180,12 @@ impl From<ClusterReadOnlySnapshot> for ClusterSnapshotView {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ClusterComponentStatusView {
pub storage: ClusterComponentStatus,
pub peer_health: ClusterComponentStatus,
pub listing: ClusterListingMetacacheStatus,
pub usage: ClusterUsageFreshnessStatus,
pub workload_admission: ClusterComponentStatus,
}
impl ClusterComponentStatusView {
fn from_snapshot(snapshot: &ClusterReadOnlySnapshot) -> Self {
Self {
storage: component_status_with_condition(
"runtime_readiness",
summarize_storage_readiness(snapshot),
if snapshot.runtime_status.readiness.storage_ready {
"healthy"
} else {
"degraded"
},
),
peer_health: component_status("cluster_peer_health", summarize_peer_health(snapshot)),
listing: summarize_listing_metacache(snapshot),
usage: summarize_usage_freshness(snapshot),
workload_admission: component_status("workload_admission", summarize_workload_admission(snapshot)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ClusterComponentStatus {
pub source: &'static str,
pub condition: &'static str,
pub status: CapabilityStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ClusterListingMetacacheStatus {
pub source: &'static str,
pub condition: &'static str,
pub status: CapabilityStatus,
pub internode_stall_timeouts_total: u64,
pub hint: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ClusterUsageFreshnessStatus {
pub source: &'static str,
pub condition: &'static str,
pub status: CapabilityStatus,
pub dirty_pending_buckets: u64,
pub last_dirty_mark_unix_secs: u64,
pub last_dirty_clear_unix_secs: u64,
pub last_cycle_dirty_buckets: u64,
pub last_cycle_cleared_dirty_buckets: u64,
pub last_usage_save_unix_secs: u64,
pub last_usage_save_result: String,
pub last_success_unix_secs: Option<u64>,
pub last_error: Option<String>,
}
fn component_status(source: &'static str, status: CapabilityStatus) -> ClusterComponentStatus {
let condition = condition_from_capability(&status);
component_status_with_condition(source, status, condition)
}
fn component_status_with_condition(
source: &'static str,
status: CapabilityStatus,
condition: &'static str,
) -> ClusterComponentStatus {
ClusterComponentStatus {
source,
condition,
status,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ClusterSnapshotSummary {
pub runtime: CapabilityStatus,
pub topology: CapabilityStatus,
pub membership: CapabilityStatus,
pub storage: CapabilityStatus,
pub peer_health: CapabilityStatus,
pub listing: CapabilityStatus,
pub usage: CapabilityStatus,
pub rpc_boundary: CapabilityStatus,
pub observability: CapabilityStatus,
pub workload_admission: CapabilityStatus,
@@ -283,17 +194,12 @@ pub(crate) struct ClusterSnapshotSummary {
impl From<&ClusterReadOnlySnapshot> for ClusterSnapshotSummary {
fn from(snapshot: &ClusterReadOnlySnapshot) -> Self {
let components = ClusterComponentStatusView::from_snapshot(snapshot);
Self::from_snapshot_and_components(snapshot, &components)
}
}
impl ClusterSnapshotSummary {
fn from_snapshot_and_components(snapshot: &ClusterReadOnlySnapshot, components: &ClusterComponentStatusView) -> Self {
let topology = summarize_topology(snapshot);
let membership = summarize_membership(snapshot);
let peer_health = summarize_peer_health(snapshot);
let rpc_boundary = summarize_rpc_boundary(snapshot);
let observability = summarize_observability(snapshot);
let workload_admission = summarize_workload_admission(snapshot);
let actionable_pressure = if cluster_has_actionable_pressure(snapshot) {
CapabilityStatus::supported().with_reason("cluster snapshot reports degraded runtime or non-open admission")
} else {
@@ -304,13 +210,10 @@ impl ClusterSnapshotSummary {
runtime: summarize_runtime(snapshot),
topology,
membership,
storage: components.storage.status.clone(),
peer_health: components.peer_health.status.clone(),
listing: components.listing.status.clone(),
usage: components.usage.status.clone(),
peer_health,
rpc_boundary,
observability,
workload_admission: components.workload_admission.status.clone(),
workload_admission,
actionable_pressure,
}
}
@@ -608,130 +511,6 @@ fn runtime_readiness_state_label(state: ClusterRuntimeReadinessState) -> &'stati
}
}
fn condition_from_capability(status: &CapabilityStatus) -> &'static str {
match status.state {
CapabilityState::Supported => "healthy",
CapabilityState::Unsupported => "unsupported",
CapabilityState::Disabled => "not_reported",
CapabilityState::Unknown => "unknown",
}
}
fn summarize_storage_readiness(snapshot: &ClusterReadOnlySnapshot) -> CapabilityStatus {
if snapshot.runtime_status.readiness.storage_ready {
return CapabilityStatus::supported().with_reason("storage readiness reports ready");
}
let storage_reasons = snapshot
.runtime_status
.degraded_reasons
.iter()
.filter_map(|reason| match reason {
ReadinessDegradedReason::StorageQuorumUnavailable
| ReadinessDegradedReason::StorageAndIamUnavailable
| ReadinessDegradedReason::StorageAndLockUnavailable
| ReadinessDegradedReason::StorageIamAndLockUnavailable => Some(reason.as_str()),
_ => None,
})
.collect::<Vec<_>>();
if storage_reasons.is_empty() {
CapabilityStatus::unknown().with_reason("storage readiness is false without a storage-specific degraded reason")
} else {
CapabilityStatus::unknown().with_reason(format!("storage readiness degraded: {}", storage_reasons.join(", ")))
}
}
fn summarize_listing_readiness(snapshot: &ClusterReadOnlySnapshot) -> CapabilityStatus {
match snapshot
.workload_admission
.entries()
.iter()
.find(|entry| entry.class == WorkloadClass::ForegroundRead)
{
Some(entry) if entry.state == AdmissionState::Open => {
CapabilityStatus::supported().with_reason("foreground read admission is open")
}
Some(entry) => {
let mut reason = format!("foreground read admission is {}", admission_state_label(entry.state));
if let Some(detail) = entry.reason.as_deref() {
reason.push_str(": ");
reason.push_str(detail);
}
CapabilityStatus::unknown().with_reason(reason)
}
None => CapabilityStatus::unknown().with_reason("foreground read admission is not reported"),
}
}
fn summarize_listing_metacache(snapshot: &ClusterReadOnlySnapshot) -> ClusterListingMetacacheStatus {
const HINT: &str = "bucket and prefix attribution is emitted through metacache quorum logs; inspect operation-labelled walk_dir metrics for internode traffic";
let admission = summarize_listing_readiness(snapshot);
let stalls = snapshot.listing_diagnostics.internode_stall_timeouts_total;
let (condition, status) = if admission.state == CapabilityState::Supported {
("healthy", admission)
} else {
(condition_from_capability(&admission), admission)
};
ClusterListingMetacacheStatus {
source: "workload_admission+internode_metrics",
condition,
status,
internode_stall_timeouts_total: stalls,
hint: HINT,
}
}
fn summarize_usage_freshness(snapshot: &ClusterReadOnlySnapshot) -> ClusterUsageFreshnessStatus {
let freshness = &snapshot.usage_freshness;
let (condition, status) = match freshness.last_usage_save_result.as_str() {
"success" if freshness.dirty_pending_buckets == 0 => (
"healthy",
CapabilityStatus::supported().with_reason("usage cache was saved successfully and has no pending dirty buckets"),
),
"success" | "" if freshness.dirty_pending_buckets > 0 => (
"stale",
CapabilityStatus::unknown()
.with_reason(format!("usage cache has {} pending dirty buckets", freshness.dirty_pending_buckets)),
),
"skipped_stale" => (
"stale",
CapabilityStatus::unknown().with_reason("last usage cache save was skipped because scanner data was stale"),
),
"failed" => ("degraded", CapabilityStatus::unknown().with_reason("last usage cache save failed")),
"encode_failed" => (
"degraded",
CapabilityStatus::unknown().with_reason("last usage cache save failed during encoding"),
),
_ => (
"unknown",
CapabilityStatus::unknown().with_reason("no usage cache save result has been reported"),
),
};
let last_success_unix_secs = (freshness.last_usage_save_result == "success" && freshness.last_usage_save_unix_secs > 0)
.then_some(freshness.last_usage_save_unix_secs);
let last_error = match freshness.last_usage_save_result.as_str() {
"failed" | "skipped_stale" | "encode_failed" => Some(freshness.last_usage_save_result.clone()),
_ => None,
};
ClusterUsageFreshnessStatus {
source: "scanner_metrics",
condition,
status,
dirty_pending_buckets: freshness.dirty_pending_buckets,
last_dirty_mark_unix_secs: freshness.last_dirty_mark_unix_secs,
last_dirty_clear_unix_secs: freshness.last_dirty_clear_unix_secs,
last_cycle_dirty_buckets: freshness.last_cycle_dirty_buckets,
last_cycle_cleared_dirty_buckets: freshness.last_cycle_cleared_dirty_buckets,
last_usage_save_unix_secs: freshness.last_usage_save_unix_secs,
last_usage_save_result: freshness.last_usage_save_result.clone(),
last_success_unix_secs,
last_error,
}
}
fn summarize_runtime(snapshot: &ClusterReadOnlySnapshot) -> CapabilityStatus {
match snapshot.runtime_status.state {
ClusterRuntimeReadinessState::Ready => CapabilityStatus::supported().with_reason("runtime readiness reports ready"),
@@ -785,17 +564,8 @@ fn summarize_peer_health(snapshot: &ClusterReadOnlySnapshot) -> CapabilityStatus
.iter()
.filter(|peer| peer.status.state == CapabilityState::Unknown)
.count();
let disabled = snapshot
.peer_health
.peers
.iter()
.filter(|peer| peer.status.state == CapabilityState::Disabled)
.count();
if unknown > 0 {
CapabilityStatus::unknown().with_reason(format!("cluster peer health has {unknown} unresolved peers"))
} else if disabled > 0 {
let noun = if disabled == 1 { "peer" } else { "peers" };
CapabilityStatus::disabled().with_reason(format!("cluster peer health is not reported by {disabled} {noun}"))
} else {
CapabilityStatus::supported().with_reason("cluster peer health resolved for all peers")
}
@@ -899,10 +669,7 @@ mod tests {
ClusterMembershipSnapshot, ClusterNodeMembership, ClusterPeerHealth, ClusterPeerHealthSnapshot, ClusterPoolState,
ClusterPoolStateSnapshot, ClusterRpcBoundarySnapshot, ClusterRpcChannelSnapshot, ClusterRpcPlane, ClusterRpcTransport,
};
use crate::cluster_snapshot::{
ClusterListingDiagnosticsSnapshot, ClusterReadOnlySnapshot, ClusterRuntimeReadinessState, ClusterRuntimeStatusSnapshot,
ClusterUsageFreshnessSnapshot,
};
use crate::cluster_snapshot::{ClusterReadOnlySnapshot, ClusterRuntimeReadinessState, ClusterRuntimeStatusSnapshot};
use crate::server::{DependencyReadiness, ReadinessDegradedReason};
use rustfs_concurrency::{AdmissionState, WorkloadAdmissionRegistrySnapshot, WorkloadAdmissionSnapshot, WorkloadClass};
@@ -940,7 +707,6 @@ mod tests {
assert_eq!(response.rpc_boundary, None);
assert_eq!(response.workload_admission, None);
assert_eq!(response.runtime, None);
assert_eq!(response.components, None);
}
#[test]
@@ -1008,29 +774,11 @@ mod tests {
state: ClusterRuntimeReadinessState::Degraded,
degraded_reasons: vec![ReadinessDegradedReason::StorageAndLockUnavailable],
},
usage_freshness: ClusterUsageFreshnessSnapshot {
dirty_pending_buckets: 3,
last_usage_save_unix_secs: 123,
last_usage_save_result: "skipped_stale".to_string(),
last_usage_save_result_code: 3,
..Default::default()
},
listing_diagnostics: ClusterListingDiagnosticsSnapshot {
internode_stall_timeouts_total: 2,
},
};
let value = serde_json::to_value(ClusterSnapshotView::from(snapshot)).expect("serialize view");
assert_eq!(value["runtime_capabilities_path"], "/rustfs/admin/v4/runtime/capabilities");
assert_eq!(value["extensions_catalog_path"], "/rustfs/admin/v4/extensions/catalog");
assert_eq!(value["components"]["storage"]["source"], "runtime_readiness");
assert_eq!(value["components"]["storage"]["condition"], "degraded");
assert_eq!(value["components"]["peer_health"]["source"], "cluster_peer_health");
assert_eq!(value["components"]["listing"]["source"], "workload_admission+internode_metrics");
assert_eq!(value["components"]["listing"]["condition"], "unknown");
assert_eq!(value["components"]["listing"]["internode_stall_timeouts_total"], 2);
assert_eq!(value["components"]["usage"]["source"], "scanner_metrics");
assert_eq!(value["components"]["usage"]["condition"], "stale");
assert_eq!(value["membership"]["drives"][0]["endpoint_type"], "url");
assert_eq!(value["workload_admission"][0]["class"], "repair");
assert_eq!(value["workload_admission"][0]["state"], "unknown");
@@ -1068,255 +816,17 @@ mod tests {
state: ClusterRuntimeReadinessState::Ready,
degraded_reasons: Vec::new(),
},
usage_freshness: ClusterUsageFreshnessSnapshot::default(),
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
};
let summary = ClusterSnapshotSummary::from(&snapshot);
assert_eq!(summary.runtime.state, CapabilityState::Supported);
assert_eq!(summary.membership.state, CapabilityState::Unknown);
assert_eq!(summary.storage.state, CapabilityState::Supported);
assert_eq!(summary.storage.reason.as_deref(), Some("storage readiness reports ready"));
assert_eq!(summary.peer_health.state, CapabilityState::Unknown);
assert_eq!(summary.listing.state, CapabilityState::Supported);
assert_eq!(summary.listing.reason.as_deref(), Some("foreground read admission is open"));
assert_eq!(summary.usage.state, CapabilityState::Unknown);
assert_eq!(summary.usage.reason.as_deref(), Some("no usage cache save result has been reported"));
assert_eq!(summary.rpc_boundary.state, CapabilityState::Supported);
assert_eq!(summary.workload_admission.state, CapabilityState::Supported);
assert_eq!(summary.actionable_pressure.state, CapabilityState::Disabled);
}
#[test]
fn cluster_snapshot_components_split_storage_peer_listing_and_usage_states() {
let snapshot = ClusterReadOnlySnapshot {
topology: TopologySnapshot::default(),
membership: ClusterMembershipSnapshot {
nodes: vec![ClusterNodeMembership {
node_id: "node-a".to_string(),
grid_host: "node-a:9000".to_string(),
is_local: true,
pools: vec![0],
}],
drives: Vec::new(),
},
pool_state: ClusterPoolStateSnapshot::default(),
local_storage: ClusterLocalNodeStorageSnapshot::default(),
peer_health: ClusterPeerHealthSnapshot {
peers: vec![ClusterPeerHealth {
node_id: "node-a".to_string(),
is_local: true,
status: CapabilityStatus::disabled().with_reason("peer health not reported by endpoints"),
}],
},
rpc_boundary: sample_rpc_boundary_snapshot(),
observability: ObservabilitySnapshot::default(),
workload_admission: WorkloadAdmissionRegistrySnapshot::new(vec![
WorkloadAdmissionSnapshot::new(WorkloadClass::ForegroundRead, AdmissionState::Throttled)
.with_counts(Some(4), Some(8), Some(4))
.with_reason("metacache listing backpressure"),
]),
runtime_status: ClusterRuntimeStatusSnapshot {
readiness: DependencyReadiness {
storage_ready: false,
iam_ready: true,
lock_quorum_ready: true,
peer_health_ready: true,
},
state: ClusterRuntimeReadinessState::Degraded,
degraded_reasons: vec![ReadinessDegradedReason::StorageQuorumUnavailable],
},
usage_freshness: ClusterUsageFreshnessSnapshot {
dirty_pending_buckets: 3,
last_usage_save_unix_secs: 123,
last_usage_save_result: "skipped_stale".to_string(),
last_usage_save_result_code: 3,
..Default::default()
},
listing_diagnostics: ClusterListingDiagnosticsSnapshot {
internode_stall_timeouts_total: 0,
},
};
let view = ClusterSnapshotView::from(snapshot);
assert_eq!(view.components.storage.status.state, CapabilityState::Unknown);
assert_eq!(
view.components.storage.status.reason.as_deref(),
Some("storage readiness degraded: storage_quorum_unavailable")
);
assert_eq!(view.components.storage.condition, "degraded");
assert_eq!(view.components.peer_health.status.state, CapabilityState::Disabled);
assert_eq!(view.components.peer_health.condition, "not_reported");
assert_eq!(view.components.listing.status.state, CapabilityState::Unknown);
assert_eq!(
view.components.listing.status.reason.as_deref(),
Some("foreground read admission is throttled: metacache listing backpressure")
);
assert_eq!(view.components.listing.condition, "unknown");
assert_eq!(view.components.usage.status.state, CapabilityState::Unknown);
assert_eq!(view.components.usage.condition, "stale");
assert_eq!(view.components.usage.last_usage_save_unix_secs, 123);
assert_eq!(view.components.usage.last_usage_save_result, "skipped_stale");
}
#[test]
fn cluster_snapshot_listing_component_keeps_historical_stalls_as_evidence() {
let snapshot = ClusterReadOnlySnapshot {
topology: TopologySnapshot::default(),
membership: ClusterMembershipSnapshot::default(),
pool_state: ClusterPoolStateSnapshot::default(),
local_storage: ClusterLocalNodeStorageSnapshot::default(),
peer_health: ClusterPeerHealthSnapshot::default(),
rpc_boundary: ClusterRpcBoundarySnapshot::default(),
observability: ObservabilitySnapshot::default(),
workload_admission: WorkloadAdmissionRegistrySnapshot::new(vec![WorkloadAdmissionSnapshot::new(
WorkloadClass::ForegroundRead,
AdmissionState::Open,
)]),
runtime_status: ClusterRuntimeStatusSnapshot {
readiness: DependencyReadiness::default(),
state: ClusterRuntimeReadinessState::Unknown,
degraded_reasons: Vec::new(),
},
usage_freshness: ClusterUsageFreshnessSnapshot::default(),
listing_diagnostics: ClusterListingDiagnosticsSnapshot {
internode_stall_timeouts_total: 2,
},
};
let component = super::summarize_listing_metacache(&snapshot);
assert_eq!(component.status.state, CapabilityState::Supported);
assert_eq!(component.status.reason.as_deref(), Some("foreground read admission is open"));
assert_eq!(component.condition, "healthy");
assert_eq!(component.internode_stall_timeouts_total, 2);
}
#[test]
fn cluster_component_condition_preserves_capability_states() {
assert_eq!(super::condition_from_capability(&CapabilityStatus::supported()), "healthy");
assert_eq!(super::condition_from_capability(&CapabilityStatus::unsupported()), "unsupported");
assert_eq!(super::condition_from_capability(&CapabilityStatus::disabled()), "not_reported");
assert_eq!(super::condition_from_capability(&CapabilityStatus::unknown()), "unknown");
}
#[test]
fn cluster_snapshot_usage_component_reports_clean_success() {
let snapshot = ClusterReadOnlySnapshot {
topology: TopologySnapshot::default(),
membership: ClusterMembershipSnapshot::default(),
pool_state: ClusterPoolStateSnapshot::default(),
local_storage: ClusterLocalNodeStorageSnapshot::default(),
peer_health: ClusterPeerHealthSnapshot::default(),
rpc_boundary: ClusterRpcBoundarySnapshot::default(),
observability: ObservabilitySnapshot::default(),
workload_admission: WorkloadAdmissionRegistrySnapshot::default(),
runtime_status: ClusterRuntimeStatusSnapshot {
readiness: DependencyReadiness::default(),
state: ClusterRuntimeReadinessState::Unknown,
degraded_reasons: Vec::new(),
},
usage_freshness: ClusterUsageFreshnessSnapshot {
dirty_pending_buckets: 0,
last_usage_save_unix_secs: 456,
last_usage_save_result: "success".to_string(),
last_usage_save_result_code: 1,
..Default::default()
},
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
};
let component = super::summarize_usage_freshness(&snapshot);
assert_eq!(component.status.state, CapabilityState::Supported);
assert_eq!(component.condition, "healthy");
assert_eq!(component.last_usage_save_unix_secs, 456);
assert_eq!(component.last_usage_save_result, "success");
}
#[test]
fn cluster_snapshot_usage_component_reports_dirty_without_save_result_as_stale() {
let snapshot = ClusterReadOnlySnapshot {
topology: TopologySnapshot::default(),
membership: ClusterMembershipSnapshot::default(),
pool_state: ClusterPoolStateSnapshot::default(),
local_storage: ClusterLocalNodeStorageSnapshot::default(),
peer_health: ClusterPeerHealthSnapshot::default(),
rpc_boundary: ClusterRpcBoundarySnapshot::default(),
observability: ObservabilitySnapshot::default(),
workload_admission: WorkloadAdmissionRegistrySnapshot::default(),
runtime_status: ClusterRuntimeStatusSnapshot {
readiness: DependencyReadiness::default(),
state: ClusterRuntimeReadinessState::Unknown,
degraded_reasons: Vec::new(),
},
usage_freshness: ClusterUsageFreshnessSnapshot {
dirty_pending_buckets: 2,
..Default::default()
},
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
};
let component = super::summarize_usage_freshness(&snapshot);
assert_eq!(component.status.state, CapabilityState::Unknown);
assert_eq!(component.condition, "stale");
assert_eq!(component.status.reason.as_deref(), Some("usage cache has 2 pending dirty buckets"));
}
#[test]
fn cluster_snapshot_summary_treats_not_reported_peer_health_as_disabled() {
let snapshot = ClusterReadOnlySnapshot {
topology: TopologySnapshot::default(),
membership: ClusterMembershipSnapshot {
nodes: vec![ClusterNodeMembership {
node_id: "node-a".to_string(),
grid_host: "node-a:9000".to_string(),
is_local: true,
pools: vec![0],
}],
drives: Vec::new(),
},
pool_state: ClusterPoolStateSnapshot::default(),
local_storage: ClusterLocalNodeStorageSnapshot::default(),
peer_health: ClusterPeerHealthSnapshot {
peers: vec![ClusterPeerHealth {
node_id: "node-a".to_string(),
is_local: true,
status: CapabilityStatus::disabled().with_reason("peer health not reported by endpoints"),
}],
},
rpc_boundary: sample_rpc_boundary_snapshot(),
observability: ObservabilitySnapshot::default(),
workload_admission: WorkloadAdmissionRegistrySnapshot::new(vec![WorkloadAdmissionSnapshot::new(
WorkloadClass::ForegroundRead,
AdmissionState::Open,
)]),
runtime_status: ClusterRuntimeStatusSnapshot {
readiness: DependencyReadiness {
storage_ready: true,
iam_ready: true,
lock_quorum_ready: true,
peer_health_ready: true,
},
state: ClusterRuntimeReadinessState::Ready,
degraded_reasons: Vec::new(),
},
usage_freshness: ClusterUsageFreshnessSnapshot::default(),
listing_diagnostics: ClusterListingDiagnosticsSnapshot::default(),
};
let summary = ClusterSnapshotSummary::from(&snapshot);
assert_eq!(summary.peer_health.state, CapabilityState::Disabled);
assert_eq!(
summary.peer_health.reason.as_deref(),
Some("cluster peer health is not reported by 1 peer")
);
assert_eq!(summary.actionable_pressure.state, CapabilityState::Disabled);
}
fn sample_rpc_boundary_snapshot() -> ClusterRpcBoundarySnapshot {
ClusterRpcBoundarySnapshot {
control_channels: vec![ClusterRpcChannelSnapshot {
+1 -1
View File
@@ -371,7 +371,7 @@ impl Operation for ListTargetsArns {
log_notification_target_operation_blocked!("list_target_arns", None, None, &reason);
return Err(s3_error!(InvalidRequest, "{reason}"));
}
let ns = get_notification_system().await?;
let ns = get_notification_system()?;
let region = req.region.clone().ok_or_else(|| {
warn!(
+1 -12
View File
@@ -23,9 +23,7 @@ pub(crate) fn iam_error_to_s3_error(err: IamError) -> S3Error {
| IamError::NoSuchTempAccount(_)
| IamError::NoSuchGroup(_)
| IamError::NoSuchPolicy => S3ErrorCode::NoSuchResource,
IamError::InvalidAccessKeyLength | IamError::InvalidSecretKeyLength | IamError::AccessKeyAlreadyExists => {
S3ErrorCode::InvalidArgument
}
IamError::InvalidAccessKeyLength | IamError::InvalidSecretKeyLength => S3ErrorCode::InvalidArgument,
_ => S3ErrorCode::InternalError,
};
@@ -66,15 +64,6 @@ mod tests {
}
}
#[test]
fn duplicate_access_key_maps_to_invalid_argument() {
let s3_error = iam_error_to_s3_error(IamError::AccessKeyAlreadyExists);
assert_eq!(s3_error.code(), &S3ErrorCode::InvalidArgument);
assert_eq!(s3_error.status_code(), Some(http::StatusCode::BAD_REQUEST));
assert_eq!(s3_error.message(), Some("access key is already in use"));
}
#[test]
fn non_validation_iam_errors_remain_internal_errors() {
let s3_error = iam_error_to_s3_error(IamError::IamSysNotInitialized);
@@ -12,30 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::server::init_event_notifier;
use rustfs_config::server_config::Config;
use s3s::{S3Result, s3_error};
use std::sync::Arc;
use tokio::sync::Mutex;
static NOTIFICATION_SYSTEM_INIT_LOCK: Mutex<()> = Mutex::const_new(());
pub(crate) async fn get_notification_system() -> S3Result<Arc<rustfs_notify::NotificationSystem>> {
if let Some(system) = rustfs_notify::notification_system() {
return Ok(system);
}
let _guard = NOTIFICATION_SYSTEM_INIT_LOCK.lock().await;
if let Some(system) = rustfs_notify::notification_system() {
return Ok(system);
}
init_event_notifier().await;
pub(crate) fn get_notification_system() -> S3Result<Arc<rustfs_notify::NotificationSystem>> {
rustfs_notify::notification_system().ok_or_else(|| s3_error!(InternalError, "notification system not initialized"))
}
pub(crate) async fn load_notification_config_snapshot() -> S3Result<(Arc<rustfs_notify::NotificationSystem>, Config)> {
let system = get_notification_system().await?;
let system = get_notification_system()?;
let config = system.config.read().await.clone();
Ok((system, config))
}
@@ -45,7 +31,7 @@ pub(crate) async fn set_notification_target_config(
target_name: &str,
kvs: rustfs_config::server_config::KVS,
) -> S3Result<()> {
let system = get_notification_system().await?;
let system = get_notification_system()?;
system
.set_target_config(subsystem, target_name, kvs)
.await
@@ -53,7 +39,7 @@ pub(crate) async fn set_notification_target_config(
}
pub(crate) async fn remove_notification_target_config(subsystem: &str, target_name: &str) -> S3Result<()> {
let system = get_notification_system().await?;
let system = get_notification_system()?;
system
.remove_target_config(subsystem, target_name)
.await
+5 -5
View File
@@ -398,9 +398,9 @@ impl Operation for AddServiceAccount {
"admin service account state"
);
match e {
rustfs_iam::error::Error::InvalidAccessKeyLength
| rustfs_iam::error::Error::InvalidSecretKeyLength
| rustfs_iam::error::Error::AccessKeyAlreadyExists => iam_error_to_s3_error(e),
rustfs_iam::error::Error::InvalidAccessKeyLength | rustfs_iam::error::Error::InvalidSecretKeyLength => {
iam_error_to_s3_error(e)
}
err => s3_error!(InternalError, "create service account failed, e: {:?}", err),
}
})?;
@@ -1580,8 +1580,8 @@ mod tests {
let create_block = &create_block[..create_end];
assert!(
create_block.contains("AccessKeyAlreadyExists") && create_block.contains("iam_error_to_s3_error(e)"),
"duplicate access keys must map to client S3 errors"
create_block.contains("iam_error_to_s3_error(e)"),
"service-account create validation errors must map to client S3 errors"
);
}
+82 -352
View File
@@ -21,8 +21,8 @@ use crate::admin::runtime_sources::{
current_token_signing_key, object_store_from_req,
};
use crate::admin::site_replication_identity::{
canonical_endpoint, deployment_id_for_endpoint, is_https_endpoint, mark_unknown_peer_sync_enabled,
normalize_peer_map_by_identity_with, same_identity_endpoint, site_identity_key,
canonical_endpoint, deployment_id_for_endpoint, normalize_peer_map_by_identity_with, same_identity_endpoint,
site_identity_key,
};
use crate::admin::storage_api::bucket::metadata::{
BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_POLICY_CONFIG, BUCKET_QUOTA_CONFIG_FILE, BUCKET_REPLICATION_CONFIG,
@@ -119,7 +119,6 @@ const SITE_REPL_MAX_NETPERF_DURATION: Duration = Duration::from_secs(30);
const SITE_REPLICATION_PEER_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const SITE_REPLICATION_PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
const SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT: usize = 256;
const SITE_REPLICATION_INITIAL_SYNC_ERROR_LIMIT: usize = 32;
const MAX_PEER_CA_CERT_PEM_SIZE: usize = 256 * 1024;
const ALLOW_LOOPBACK_REPLICATION_TARGET_ENV: &str = "RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET";
const SITE_REPLICATION_RETRY_QUEUE_LIMIT: usize = 256;
@@ -428,8 +427,6 @@ struct SiteReplicationState {
pending_endpoint_refresh: Option<PendingEndpointRefresh>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
retry_queue: Vec<SiteReplicationRetryEvent>,
#[serde(default)]
sync_state_initialized: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -522,57 +519,6 @@ struct SiteReplicationBootstrapPlan {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct SRPeerJoinResponse {
peer: PeerInfo,
#[serde(rename = "initialSyncErrorMessage", default, skip_serializing_if = "String::is_empty")]
initial_sync_error_message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct SRPeerJoinEnvelope {
#[serde(flatten)]
request: SRPeerJoinReq,
#[serde(rename = "deferSyncStateEnable", default, skip_serializing_if = "std::ops::Not::not")]
defer_sync_state_enable: bool,
}
#[derive(Debug, Default)]
struct SiteReplicationErrorSummary {
entries: Vec<String>,
total: usize,
}
impl SiteReplicationErrorSummary {
fn push(&mut self, error: impl AsRef<str>) {
self.total = self.total.saturating_add(1);
if self.entries.len() < SITE_REPLICATION_INITIAL_SYNC_ERROR_LIMIT {
self.entries.push(summarize_peer_error_detail(error.as_ref()));
}
}
fn extend(&mut self, other: Self) {
self.total = self.total.saturating_add(other.total);
let remaining = SITE_REPLICATION_INITIAL_SYNC_ERROR_LIMIT.saturating_sub(self.entries.len());
self.entries.extend(other.entries.into_iter().take(remaining));
}
fn is_empty(&self) -> bool {
self.total == 0
}
fn reported(&self) -> usize {
self.entries.len()
}
fn render(&self) -> String {
let mut message = self.entries.join("; ");
let omitted = self.total.saturating_sub(self.entries.len());
if omitted > 0 {
if !message.is_empty() {
message.push_str("; ");
}
message.push_str(&format!("{omitted} additional error(s) omitted"));
}
message
}
}
const GO_GOB_SITE_NETPERF_SCHEMA: &[u8] = &[
@@ -892,12 +838,6 @@ async fn load_site_replication_state() -> S3Result<SiteReplicationState> {
let mut state: SiteReplicationState = serde_json::from_slice(&data)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("invalid site replication state: {e}")))?;
state.peers = normalize_peer_map_by_identity(state.peers);
if !state.sync_state_initialized {
if state.enabled() {
mark_unknown_peer_sync_enabled(&mut state.peers);
}
state.sync_state_initialized = true;
}
Ok(state)
}
Err(StorageError::ConfigNotFound) => Ok(SiteReplicationState::default()),
@@ -1717,32 +1657,6 @@ fn validate_add_sites(sites: &[PeerSite], local_peer: &PeerInfo) -> S3Result<()>
Ok(())
}
/// The web console's "Set Up Site Replication" flow sends only the remote peer(s) and omits the
/// local deployment from the add payload. The add preflight requires the local deployment to be
/// present (`validate_add_preflight_topology`), so inject the local site when the payload does not
/// already include it. `mc admin replicate add` includes every site (matched here by endpoint
/// identity), so this is a no-op for the CLI. The local site carries no credentials — they are not
/// required for the local peer (`validate_add_sites` skips credential checks for it).
fn ensure_local_site_present(sites: &mut Vec<PeerSite>, local_peer: &PeerInfo) {
if sites
.iter()
.any(|site| same_identity_endpoint(&site.endpoint, &local_peer.endpoint))
{
return;
}
sites.insert(
0,
PeerSite {
name: local_peer.name.clone(),
endpoint: local_peer.endpoint.clone(),
access_key: String::new(),
secret_key: String::new(),
skip_tls_verify: local_peer.skip_tls_verify,
ca_cert_pem: local_peer.ca_cert_pem.clone(),
},
);
}
fn idp_settings_value(settings: &IDPSettings) -> S3Result<serde_json::Value> {
serde_json::to_value(settings)
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize IDP settings failed: {e}")))
@@ -2167,9 +2081,7 @@ fn build_join_peers(
if let Some(local_site) = sites
.iter()
.find(|site| same_identity_endpoint(&site.endpoint, &normalized_local.endpoint))
&& (is_https_endpoint(&local_site.endpoint) || !is_https_endpoint(&normalized_local.endpoint))
{
normalized_local.endpoint = local_site.endpoint.clone();
normalized_local.skip_tls_verify = local_site.skip_tls_verify;
normalized_local.ca_cert_pem = local_site.ca_cert_pem.clone();
}
@@ -2200,6 +2112,21 @@ fn build_join_peers(
normalize_peer_map_by_identity(peers)
}
/// Promote each peer's persisted `sync_state` from `Unknown` to `Enable` once site
/// replication has been established for it. The persisted value is a static
/// "configured & enabled" signal so that the `replicate info` endpoint (and the
/// console, which read the persisted peer map verbatim) do not report `Unknown` for a
/// healthy peer. `build_status_info` still refines this to `Disable`/`Unknown` from
/// live health signals at query time for the `replicate status` endpoint. A peer that
/// has been explicitly marked `Disable` is left untouched.
fn mark_peers_sync_enabled(peers: &mut BTreeMap<String, PeerInfo>) {
for peer in peers.values_mut() {
if peer.sync_state == SyncStatus::Unknown {
peer.sync_state = SyncStatus::Enable;
}
}
}
fn normalize_join_peers_for_local(local_peer: &PeerInfo, peers: BTreeMap<String, PeerInfo>) -> BTreeMap<String, PeerInfo> {
let mut normalized = BTreeMap::new();
@@ -2221,12 +2148,6 @@ fn normalize_join_peers_for_local(local_peer: &PeerInfo, peers: BTreeMap<String,
normalize_peer_map_by_identity(normalized)
}
fn initialize_join_peer_sync_state(peers: &mut BTreeMap<String, PeerInfo>, defer_sync_state_enable: bool) {
if !defer_sync_state_enable {
mark_unknown_peer_sync_enabled(peers);
}
}
fn reconcile_peer_with_actual_identity(mut state: SiteReplicationState, actual_peer: PeerInfo) -> SiteReplicationState {
let mut actual_peer = normalize_peer_info(actual_peer);
if let Some(requested_peer) = state
@@ -2777,25 +2698,27 @@ async fn bootstrap_existing_metadata_after_add(
state: &SiteReplicationState,
local_peer: &PeerInfo,
service_account_secret_key: &str,
) -> SiteReplicationErrorSummary {
) -> Vec<String> {
let info = match build_sr_info(state, local_peer).await {
Ok(info) => info,
Err(err) => {
let mut errors = SiteReplicationErrorSummary::default();
errors.push(format!("local snapshot failed: {err}"));
return errors;
return vec![format!(
"local snapshot failed: {}",
summarize_peer_error_detail(&err.to_string())
)];
}
};
let plan = match site_replication_bootstrap_plan(&info) {
Ok(plan) => plan,
Err(err) => {
let mut errors = SiteReplicationErrorSummary::default();
errors.push(format!("bootstrap plan failed: {err}"));
return errors;
return vec![format!(
"bootstrap plan failed: {}",
summarize_peer_error_detail(&err.to_string())
)];
}
};
let mut errors = SiteReplicationErrorSummary::default();
let mut errors = Vec::new();
for peer in state.peers.values() {
if peer.deployment_id == local_peer.deployment_id || same_identity_endpoint(&peer.endpoint, &local_peer.endpoint) {
continue;
@@ -2820,6 +2743,17 @@ async fn bootstrap_existing_metadata_after_add(
errors
}
/// Combine the metadata-bootstrap and object-back-fill failure lists into the single
/// `initial_sync_error_message` surfaced by `mc admin replicate add`, so an operator can
/// see exactly which buckets did not propagate instead of receiving an unqualified success.
fn compose_initial_sync_error_message(bootstrap_errors: Vec<String>, backfill_errors: Vec<String>) -> String {
bootstrap_errors
.into_iter()
.chain(backfill_errors)
.collect::<Vec<_>>()
.join("; ")
}
pub async fn site_replication_make_bucket_hook(bucket: &str, lock_enabled: bool) -> S3Result<()> {
let _bucket_op_guard = SITE_REPLICATION_BUCKET_OP_LOCK.read().await;
let runtime = {
@@ -5471,10 +5405,10 @@ async fn backfill_existing_buckets_after_add(
state: &SiteReplicationState,
local_peer: &PeerInfo,
bootstrap_token: Option<&str>,
) -> SiteReplicationErrorSummary {
let mut errors = SiteReplicationErrorSummary::default();
) -> Vec<String> {
let mut errors = Vec::new();
let Some(store) = current_object_store_handle() else {
errors.push("object store not initialized; pre-existing buckets were not backfilled");
errors.push("object store not initialized; pre-existing buckets were not backfilled".to_string());
return errors;
};
let buckets = match store.list_bucket(&BucketOptions::default()).await {
@@ -5488,7 +5422,7 @@ async fn backfill_existing_buckets_after_add(
error = ?err,
"admin site replication state"
);
errors.push(format!("list buckets failed: {err}"));
errors.push(format!("list buckets failed: {}", summarize_peer_error_detail(&err.to_string())));
return errors;
}
};
@@ -5507,7 +5441,10 @@ async fn backfill_existing_buckets_after_add(
error = ?err,
"admin site replication state"
);
errors.push(format!("{name}: versioning setup failed: {err}"));
errors.push(format!(
"{name}: versioning setup failed: {}",
summarize_peer_error_detail(&err.to_string())
));
continue;
}
match ensure_site_replication_bucket_setup(name).await {
@@ -5537,7 +5474,7 @@ async fn backfill_existing_buckets_after_add(
error = ?err,
"admin site replication state"
);
errors.push(format!("{name}: bucket setup failed: {err}"));
errors.push(format!("{name}: bucket setup failed: {}", summarize_peer_error_detail(&err.to_string())));
}
}
// Broadcast the bucket to peers so they create it too (idempotent on the peer side).
@@ -5569,7 +5506,10 @@ async fn backfill_existing_buckets_after_add(
error = ?err,
"admin site replication state"
);
errors.push(format!("{name}: make-bucket broadcast failed: {err}"));
errors.push(format!(
"{name}: make-bucket broadcast failed: {}",
summarize_peer_error_detail(&err.to_string())
));
}
// Kick a resync toward every remote peer so existing objects travel across.
for peer in state.peers.values() {
@@ -5588,7 +5528,11 @@ async fn backfill_existing_buckets_after_add(
detail = %result.err_detail,
"admin site replication state"
);
errors.push(format!("{name} -> {}: resync kick failed: {}", peer.endpoint, result.err_detail));
errors.push(format!(
"{name} -> {}: resync kick failed: {}",
peer.endpoint,
summarize_peer_error_detail(&result.err_detail)
));
}
}
}
@@ -6236,10 +6180,7 @@ impl Operation for SiteReplicationAddHandler {
return Err(s3_error!(InvalidRequest, "endpoint target refresh is pending"));
}
let local_peer = current_local_peer(&req, &current_state);
let mut sites: Vec<PeerSite> = read_site_replication_json(req, &cred.secret_key, true).await?;
// The web console's "Set Up Site Replication" omits the local deployment from the payload;
// inject it so the add preflight (which requires the local deployment) succeeds. No-op for `mc`.
ensure_local_site_present(&mut sites, &local_peer);
let sites: Vec<PeerSite> = read_site_replication_json(req, &cred.secret_key, true).await?;
validate_add_sites(&sites, &local_peer)?;
let mut preflight_infos = Vec::with_capacity(sites.len());
for site in &sites {
@@ -6275,22 +6216,17 @@ impl Operation for SiteReplicationAddHandler {
cred.access_key.clone(),
replicate_ilm_expiry,
);
state.sync_state_initialized = true;
let join_req = SRPeerJoinEnvelope {
request: SRPeerJoinReq {
svc_acct_access_key: service_account_access_key,
svc_acct_secret_key: service_account_secret_key.clone(),
svc_acct_parent: String::new(),
peers: state.peers.clone(),
updated_at: state.updated_at,
},
defer_sync_state_enable: true,
let join_req = SRPeerJoinReq {
svc_acct_access_key: service_account_access_key,
svc_acct_secret_key: service_account_secret_key.clone(),
svc_acct_parent: String::new(),
peers: state.peers.clone(),
updated_at: state.updated_at,
};
let peer_join_path =
with_site_replication_bootstrap_token(SITE_REPLICATION_PEER_JOIN_PATH, &add_in_progress_guard.token.to_string());
let mut joined_endpoints = HashSet::new();
let mut initial_sync_errors = SiteReplicationErrorSummary::default();
for site in &sites {
if same_identity_endpoint(&site.endpoint, &local_peer.endpoint)
|| !joined_endpoints.insert(site_identity_key(&site.endpoint))
@@ -6299,7 +6235,7 @@ impl Operation for SiteReplicationAddHandler {
}
let mut peer_join_req = join_req.clone();
peer_join_req.request.svc_acct_parent = site.access_key.clone();
peer_join_req.svc_acct_parent = site.access_key.clone();
let connection = PeerConnection::try_from(site)?;
let body =
send_peer_admin_request(&connection, &peer_join_path, &site.access_key, &site.secret_key, &peer_join_req).await?;
@@ -6310,9 +6246,6 @@ impl Operation for SiteReplicationAddHandler {
format!("parse peer join response from {} failed: {e}", site.endpoint),
)
})?;
if !join_response.initial_sync_error_message.is_empty() {
initial_sync_errors.push(format!("{}: {}", site.endpoint, join_response.initial_sync_error_message));
}
state = reconcile_peer_with_actual_identity(state, join_response.peer);
let reconciled_peer = existing_peer_for_endpoint(&state, &site.endpoint).ok_or_else(|| {
S3Error::with_message(
@@ -6328,49 +6261,22 @@ impl Operation for SiteReplicationAddHandler {
})?;
}
mark_unknown_peer_sync_enabled(&mut state.peers);
// BUG1: persist a real sync_state so `mc admin replicate info` and the console report
// Enable for healthy peers instead of Unknown. build_status_info still refines this from
// live health for the `replicate status` endpoint.
mark_peers_sync_enabled(&mut state.peers);
persist_site_replication_state(&state).await?;
for target in state.peers.values() {
if target.deployment_id == local_peer.deployment_id || same_identity_endpoint(&target.endpoint, &local_peer.endpoint)
{
continue;
}
let transport = match PeerTransport::for_runtime_peer(target).await {
Ok(transport) => transport,
Err(err) => {
initial_sync_errors.push(format!("{}: finalize peer sync state failed: {err}", target.endpoint));
continue;
}
};
for peer in state.peers.values() {
if let Err(err) = send_peer_admin_request_with_client(
&transport.client,
&transport.connection,
SITE_REPLICATION_PEER_EDIT_PATH,
&state.service_account_access_key,
&service_account_secret_key,
peer,
)
.await
{
initial_sync_errors
.push(format!("{}: finalize sync state for {} failed: {err}", target.endpoint, peer.endpoint));
}
}
}
initial_sync_errors.extend(bootstrap_existing_metadata_after_add(&state, &local_peer, &service_account_secret_key).await);
let bootstrap_errors = bootstrap_existing_metadata_after_add(&state, &local_peer, &service_account_secret_key).await;
// Fix 1: back-fill pre-existing buckets so objects created before `replicate add`
// are not silently left out of replication. Per-bucket failures are surfaced in the add
// response below (BUG2) rather than swallowed; they do not abort the overall add.
initial_sync_errors.extend(backfill_existing_buckets_after_add(&state, &local_peer, None).await);
let backfill_errors = backfill_existing_buckets_after_add(&state, &local_peer, None).await;
json_response(&ReplicateAddStatus {
success: true,
status: SITE_REPL_ADD_SUCCESS.to_string(),
initial_sync_error_message: initial_sync_errors.render(),
initial_sync_error_message: compose_initial_sync_error_message(bootstrap_errors, backfill_errors),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
..Default::default()
})
@@ -6598,22 +6504,18 @@ impl Operation for SRPeerJoinHandler {
let _state_guard = SITE_REPLICATION_STATE_LOCK.lock().await;
let mut state = load_site_replication_state().await?;
let local_peer = current_local_peer(&req, &state);
let join_envelope: SRPeerJoinEnvelope = read_site_replication_json(req, &cred.secret_key, true).await?;
let defer_sync_state_enable = join_envelope.defer_sync_state_enable;
let join_req = join_envelope.request;
let join_req: SRPeerJoinReq = read_site_replication_json(req, &cred.secret_key, true).await?;
validate_join_peer_snapshot(&join_req.peers)?;
if let Some(current_updated_at) = state.updated_at {
let Some(incoming_updated_at) = join_req.updated_at else {
return json_response(&SRPeerJoinResponse {
peer: state.peers.get(&local_peer.deployment_id).cloned().unwrap_or(local_peer),
..Default::default()
});
};
if incoming_updated_at <= current_updated_at {
return json_response(&SRPeerJoinResponse {
peer: state.peers.get(&local_peer.deployment_id).cloned().unwrap_or(local_peer),
..Default::default()
});
}
}
@@ -6672,8 +6574,9 @@ impl Operation for SRPeerJoinHandler {
state.service_account_parent = join_req.svc_acct_parent;
state.updated_at = join_req.updated_at.or_else(|| Some(OffsetDateTime::now_utc()));
state.peers = normalize_join_peers_for_local(&local_peer, join_req.peers);
initialize_join_peer_sync_state(&mut state.peers, defer_sync_state_enable);
state.sync_state_initialized = true;
// BUG1: persist Enable on the joining side too, so its `replicate info` endpoint reports a
// real sync state rather than Unknown.
mark_peers_sync_enabled(&mut state.peers);
state.name = state
.peers
.get(&local_peer.deployment_id)
@@ -6691,14 +6594,12 @@ impl Operation for SRPeerJoinHandler {
component = LOG_COMPONENT_ADMIN,
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
result = "join_backfill_incomplete",
error_count = backfill_errors.total,
reported_error_count = backfill_errors.reported(),
errors = %backfill_errors.join("; "),
"admin site replication state"
);
}
json_response(&SRPeerJoinResponse {
peer: state.peers.get(&local_peer.deployment_id).cloned().unwrap_or(local_peer),
initial_sync_error_message: backfill_errors.render(),
})
}
}
@@ -7397,7 +7298,7 @@ impl Operation for SiteReplicationRepairHandler {
} else {
"Partial".to_string()
},
err_detail: repair_errors.render(),
err_detail: repair_errors.join("; "),
api_version: Some(SITE_REPL_API_VERSION.to_string()),
})
}
@@ -8156,66 +8057,6 @@ mod tests {
assert_eq!(local.ca_cert_pem, "local-ca");
}
#[test]
fn test_build_join_peers_prefers_explicit_https_for_all_local_peer_tls_modes() {
let local_peer = PeerInfo {
deployment_id: "local-deployment".to_string(),
..peer("local", "http://local.example.com:9000")
};
let custom_ca = valid_test_ca_pem("local.example.com");
for (skip_tls_verify, ca_cert_pem) in [(false, String::new()), (true, String::new()), (false, custom_ca)] {
let peers = build_join_peers(
&SiteReplicationState::default(),
&local_peer,
vec![PeerSite {
name: "local".to_string(),
endpoint: "https://local.example.com:9000".to_string(),
skip_tls_verify,
ca_cert_pem: ca_cert_pem.clone(),
..PeerSite::default()
}],
false,
);
let local = peers.get("local-deployment").expect("local peer should be present");
assert_eq!(local.endpoint, "https://local.example.com:9000");
assert_eq!(local.skip_tls_verify, skip_tls_verify);
assert_eq!(local.ca_cert_pem, ca_cert_pem);
assert!(validate_join_peer_snapshot(&peers).is_ok());
}
}
#[test]
fn test_build_join_peers_does_not_downgrade_local_https_tls_modes() {
let custom_ca = valid_test_ca_pem("local.example.com");
for (skip_tls_verify, ca_cert_pem) in [(false, String::new()), (true, String::new()), (false, custom_ca)] {
let local_peer = PeerInfo {
deployment_id: "local-deployment".to_string(),
skip_tls_verify,
ca_cert_pem: ca_cert_pem.clone(),
..peer("local", "https://local.example.com:9000")
};
let peers = build_join_peers(
&SiteReplicationState::default(),
&local_peer,
vec![PeerSite {
name: "local".to_string(),
endpoint: "http://local.example.com:9000".to_string(),
..PeerSite::default()
}],
false,
);
let local = peers.get("local-deployment").expect("local peer should be present");
assert_eq!(local.endpoint, "https://local.example.com:9000");
assert_eq!(local.skip_tls_verify, skip_tls_verify);
assert_eq!(local.ca_cert_pem, ca_cert_pem);
assert!(validate_join_peer_snapshot(&peers).is_ok());
}
}
#[test]
fn test_build_join_peers_explicitly_disables_existing_remote_tls_settings() {
let local_peer = PeerInfo {
@@ -9168,63 +9009,6 @@ mod tests {
assert!(err.to_string().contains("secretKey is required"));
}
// Console fix: the web UI omits the local deployment from the add payload. ensure_local_site_present
// injects it so the add preflight (which requires the local deployment) succeeds.
#[test]
fn test_ensure_local_site_present_injects_when_missing() {
let local_peer = peer("local", "https://local.example.com");
let mut sites = vec![PeerSite {
name: "remote".to_string(),
endpoint: "https://remote.example.com".to_string(),
access_key: "remote-ak".to_string(),
secret_key: "remote-sk".to_string(),
..Default::default()
}];
ensure_local_site_present(&mut sites, &local_peer);
assert_eq!(sites.len(), 2, "the local site must be injected when missing");
assert!(
sites
.iter()
.any(|s| same_identity_endpoint(&s.endpoint, &local_peer.endpoint)),
"an injected site must match the local endpoint"
);
// The console payload (remote-only) now validates end-to-end at the add-sites stage.
validate_add_sites(&sites, &local_peer).expect("add sites must validate after injecting the local site");
}
#[test]
fn test_ensure_local_site_present_noop_when_already_included() {
let local_peer = peer("local", "https://local.example.com");
let mut sites = vec![
PeerSite {
name: "local".to_string(),
endpoint: "https://local.example.com".to_string(),
..Default::default()
},
PeerSite {
name: "remote".to_string(),
endpoint: "https://remote.example.com".to_string(),
access_key: "remote-ak".to_string(),
secret_key: "remote-sk".to_string(),
..Default::default()
},
];
ensure_local_site_present(&mut sites, &local_peer);
assert_eq!(sites.len(), 2, "the local site must not be duplicated when already present");
assert_eq!(
sites
.iter()
.filter(|s| same_identity_endpoint(&s.endpoint, &local_peer.endpoint))
.count(),
1,
"exactly one local site entry"
);
}
fn preflight_site(name: &str, endpoint: &str, deployment_id: &str, bucket_count: usize) -> SiteReplicationAddPreflightInfo {
SiteReplicationAddPreflightInfo {
name: name.to_string(),
@@ -11281,7 +11065,7 @@ mod tests {
peers.values().all(|p| p.sync_state == SyncStatus::Unknown),
"freshly constructed peers default to Unknown"
);
mark_unknown_peer_sync_enabled(&mut peers);
mark_peers_sync_enabled(&mut peers);
assert!(
!peers.is_empty() && peers.values().all(|p| p.sync_state == SyncStatus::Enable),
"add/join must persist Enable so the info endpoint reports a real sync state"
@@ -11308,35 +11092,11 @@ mod tests {
..peer("b", "https://b.example.com")
},
);
mark_unknown_peer_sync_enabled(&mut peers);
mark_peers_sync_enabled(&mut peers);
assert_eq!(peers["a"].sync_state, SyncStatus::Enable, "Unknown must be promoted to Enable");
assert_eq!(peers["b"].sync_state, SyncStatus::Disable, "explicit Disable must be preserved");
}
#[test]
fn test_join_peer_sync_state_waits_for_deferred_commit() {
let mut peers = BTreeMap::from([("a".to_string(), peer("a", "https://a.example.com"))]);
initialize_join_peer_sync_state(&mut peers, true);
assert_eq!(peers["a"].sync_state, SyncStatus::Unknown);
initialize_join_peer_sync_state(&mut peers, false);
assert_eq!(peers["a"].sync_state, SyncStatus::Enable);
}
#[test]
fn test_join_deferred_sync_state_flag_is_wire_compatible() {
let legacy: SRPeerJoinEnvelope = serde_json::from_value(serde_json::json!({})).expect("parse legacy peer join request");
assert!(!legacy.defer_sync_state_enable);
let value = serde_json::to_value(SRPeerJoinEnvelope {
defer_sync_state_enable: true,
..Default::default()
})
.expect("serialize peer join request");
assert_eq!(value.get("deferSyncStateEnable"), Some(&Value::Bool(true)));
}
// BUG2: pre-existing-bucket back-fill failures must be surfaced in the add response's
// initial_sync_error_message, not swallowed behind an unqualified success.
#[test]
@@ -11346,11 +11106,7 @@ mod tests {
"test78787: replication setup skipped (site replication runtime unavailable)".to_string(),
"test78787 -> https://peer.example.com: resync kick failed: timeout".to_string(),
];
let mut errors = SiteReplicationErrorSummary::default();
for error in bootstrap_errors.into_iter().chain(backfill_errors) {
errors.push(error);
}
let msg = errors.render();
let msg = compose_initial_sync_error_message(bootstrap_errors, backfill_errors);
assert!(msg.contains("peer-x: metadata sync failed"), "bootstrap errors must be surfaced");
assert!(
msg.contains("test78787: replication setup skipped"),
@@ -11360,34 +11116,8 @@ mod tests {
}
#[test]
fn test_initial_sync_error_summary_is_bounded() {
let mut errors = SiteReplicationErrorSummary::default();
for index in 0..(SITE_REPLICATION_INITIAL_SYNC_ERROR_LIMIT + 5) {
errors.push(format!("bucket-{index}: {}", "x".repeat(SITE_REPLICATION_PEER_ERROR_DETAIL_LIMIT + 32)));
}
let message = errors.render();
assert_eq!(errors.reported(), SITE_REPLICATION_INITIAL_SYNC_ERROR_LIMIT);
assert!(message.contains("5 additional error(s) omitted"));
assert!(message.chars().count() <= SITE_REPLICATION_INITIAL_SYNC_ERROR_LIMIT * 258 + 64);
}
#[test]
fn test_peer_join_response_error_summary_is_wire_compatible() {
let response: SRPeerJoinResponse = serde_json::from_value(serde_json::json!({
"peer": peer("remote", "https://remote.example.com")
}))
.expect("parse legacy peer join response");
assert!(response.initial_sync_error_message.is_empty());
let value = serde_json::to_value(SRPeerJoinResponse {
peer: peer("remote", "https://remote.example.com"),
initial_sync_error_message: "bucket setup failed".to_string(),
})
.expect("serialize peer join response");
assert_eq!(value.get("initialSyncErrorMessage").and_then(Value::as_str), Some("bucket setup failed"));
fn test_compose_initial_sync_error_message_empty_on_success() {
assert_eq!(compose_initial_sync_error_message(Vec::new(), Vec::new()), "");
}
// Fix 5: remove --all must purge local state unconditionally even when peer errors occur
-25
View File
@@ -79,14 +79,6 @@ pub struct AddTierQuery {
pub struct AddTier {}
fn wasabi_payload_name(config: &TierConfig) -> S3Result<String> {
config
.wasabi
.as_ref()
.map(|wasabi| wasabi.name.clone())
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, "missing Wasabi configuration"))
}
fn spawn_transition_tier_config_propagation(action: &'static str) {
if let Some(notification_sys) = current_notification_system() {
spawn_traced(async move {
@@ -279,9 +271,6 @@ impl Operation for AddTier {
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InvalidRequest, "missing S3 configuration"))?
.name;
}
TierType::Wasabi => {
args.name = wasabi_payload_name(&args)?;
}
TierType::RustFS => {
args.name = args
.rustfs
@@ -955,20 +944,6 @@ mod tests {
use http::Uri;
use matchit::Router;
#[test]
fn wasabi_payload_name_requires_nested_configuration() {
let config: TierConfig = serde_json::from_slice(
br#"{"type":"wasabi","wasabi":{"name":"WASABI-FIRST","accessKey":"ak","secretKey":"sk","bucket":"archive","region":"us-east-1"}}"#,
)
.expect("Wasabi AddTier payload should decode");
assert_eq!(wasabi_payload_name(&config).expect("Wasabi payload name should exist"), "WASABI-FIRST");
let missing: TierConfig = serde_json::from_slice(br#"{"type":"wasabi"}"#).expect("type-only payload should decode");
let err = wasabi_payload_name(&missing).expect_err("missing Wasabi payload must fail at the AddTier boundary");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert_eq!(err.message(), Some("missing Wasabi configuration"));
}
#[test]
fn resolve_tier_name_prefers_path_parameter() {
let uri: Uri = "/rustfs/admin/v3/tier/HOT?tier=COLD".parse().expect("uri should parse");
+6 -34
View File
@@ -183,14 +183,6 @@ fn imported_service_account_access_key_failure(entry_access_key: &str, payload_a
}
pub struct AddUser {}
fn map_add_user_create_error(err: rustfs_iam::error::Error) -> S3Error {
match err {
rustfs_iam::error::Error::AccessKeyAlreadyExists => iam_error_to_s3_error(err),
err => S3Error::with_message(S3ErrorCode::InternalError, format!("failed to create user: {err}")),
}
}
#[async_trait::async_trait]
impl Operation for AddUser {
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
@@ -282,7 +274,10 @@ impl Operation for AddUser {
)
.await?;
let updated_at = iam_store.create_user(ak, &args).await.map_err(map_add_user_create_error)?;
let updated_at = iam_store
.create_user(ak, &args)
.await
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to create user: {e}")))?;
if let Err(err) = site_replication_iam_change_hook(SRIAMItem {
r#type: "iam-user".to_string(),
@@ -1340,38 +1335,15 @@ mod tests {
use super::{
GROUP_POLICY_MAPPING_USER_TYPE, SERVICE_ACCOUNT_ACCESS_KEY_MISMATCH_ERROR, SERVICE_ACCOUNT_PARENT_SCOPE_ERROR,
imported_service_account_access_key_failure, imported_service_account_parent_allowed,
imported_service_account_parent_scope_failure, imported_service_account_status, map_add_user_create_error,
should_check_deny_only, should_reject_group_import_name, should_restore_group_as_disabled,
imported_service_account_parent_scope_failure, imported_service_account_status, should_check_deny_only,
should_reject_group_import_name, should_restore_group_as_disabled,
};
use rustfs_credentials::{Credentials, IAM_POLICY_CLAIM_NAME_SA};
use rustfs_iam::error::Error as IamError;
use rustfs_madmin::user::SRSvcAccCreate;
use s3s::S3ErrorCode;
use serde_json::Value;
use std::collections::HashMap;
#[test]
fn add_user_maps_duplicate_access_keys_to_s3_errors() {
let err = map_add_user_create_error(IamError::AccessKeyAlreadyExists);
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
assert_eq!(err.message(), Some("access key is already in use"));
}
#[test]
fn add_user_preserves_internal_error_fallback() {
let err = map_add_user_create_error(IamError::IamSysNotInitialized);
assert_eq!(err.code(), &S3ErrorCode::InternalError);
assert_eq!(err.message(), Some("failed to create user: not initialized"));
}
#[test]
fn add_user_operation_maps_create_errors() {
let mapper_call = concat!("map_err(map_add_user_", "create_error)");
assert!(include_str!("user.rs").contains(mapper_call));
}
#[test]
fn test_should_check_deny_only_for_regular_self_request() {
let cred = Credentials {
+13 -82
View File
@@ -13,9 +13,7 @@
// limitations under the License.
use crate::admin::runtime_sources::{AppContext, current_app_context, current_object_store_handle_for_context};
use crate::admin::site_replication_identity::{
deployment_id_for_endpoint, mark_unknown_peer_sync_enabled, normalize_peer_map_by_identity_with,
};
use crate::admin::site_replication_identity::{deployment_id_for_endpoint, normalize_peer_map_by_identity_with};
use crate::admin::storage_api::config::{read_admin_config, save_admin_config};
use crate::admin::storage_api::error::Error as StorageError;
use rustfs_madmin::PeerInfo;
@@ -24,9 +22,8 @@ use serde_json::{Map, Value};
use tracing::info;
const SITE_REPLICATION_STATE_PATH: &str = "config/site-replication/state.json";
const SYNC_STATE_INITIALIZED_FIELD: &str = "sync_state_initialized";
fn normalize_peers_map(peers: &Map<String, Value>, initialize_sync_state: bool) -> Map<String, Value> {
fn normalize_peers_map(peers: &Map<String, Value>) -> Map<String, Value> {
let mut valid_peers = std::collections::BTreeMap::<String, PeerInfo>::new();
let mut passthrough_invalid = Vec::<(String, Value)>::new();
@@ -49,10 +46,7 @@ fn normalize_peers_map(peers: &Map<String, Value>, initialize_sync_state: bool)
}
}
let mut deduped_by_deployment = normalize_peer_map_by_identity_with(valid_peers, |peer| peer);
if initialize_sync_state && deduped_by_deployment.len() > 1 {
mark_unknown_peer_sync_enabled(&mut deduped_by_deployment);
}
let deduped_by_deployment = normalize_peer_map_by_identity_with(valid_peers, |peer| peer);
let mut normalized = Map::new();
for (_, peer) in deduped_by_deployment {
@@ -75,32 +69,20 @@ fn normalize_site_replication_state_json(data: &[u8]) -> Result<Option<Vec<u8>>,
let before = obj.get("peers").and_then(|v| v.as_object()).map(|v| v.len()).unwrap_or(0);
let sync_state_initialized = obj
.get(SYNC_STATE_INITIALIZED_FIELD)
.and_then(Value::as_bool)
.unwrap_or(false);
let Some(peers_obj) = obj.get("peers").and_then(|v| v.as_object()) else {
return Ok(None);
};
let normalized_peers = normalize_peers_map(peers_obj, !sync_state_initialized);
if normalized_peers == *peers_obj && sync_state_initialized {
let normalized_peers = normalize_peers_map(peers_obj);
if normalized_peers == *peers_obj {
return Ok(None);
}
let after = normalized_peers.len();
obj.insert("peers".to_string(), Value::Object(normalized_peers));
obj.insert(SYNC_STATE_INITIALIZED_FIELD.to_string(), Value::Bool(true));
let normalized =
serde_json::to_vec(&state).map_err(|e| format!("serialize normalized site replication state failed: {e}"))?;
info!(
event = "site_replication_state_normalized",
component = "admin",
subsystem = "site_replication",
peers_before = before,
peers_after = after,
sync_state_migrated = !sync_state_initialized,
"site replication state normalized"
);
info!("normalized site-replication peers during reload: {before} -> {after}");
Ok(Some(normalized))
}
@@ -147,12 +129,12 @@ mod tests {
serde_json::json!({
"name": name,
"endpoint": endpoint,
"deploymentID": deployment_id,
"sync": "unknown",
"defaultbandwidth": {},
"replicate-ilm-expiry": false,
"objectNamingMode": "",
"apiVersion": "1"
"deployment_id": deployment_id,
"sync_state": "",
"default_bandwidth": {},
"replicate_ilm_expiry": false,
"object_naming_mode": "",
"api_version": "1"
})
}
@@ -238,55 +220,4 @@ mod tests {
assert_eq!(peers.len(), 2);
}
#[test]
fn test_normalize_state_json_migrates_legacy_unknown_sync_state() {
let data = serde_json::to_vec(&serde_json::json!({
"name": "local",
"peers": {
"local": peer_value("local", "https://local.example.com", "local"),
"remote": peer_value("remote", "https://remote.example.com", "remote")
}
}))
.expect("serialize legacy state");
let normalized = normalize_site_replication_state_json(&data)
.expect("normalize legacy state")
.expect("legacy state should be migrated");
let value: Value = serde_json::from_slice(&normalized).expect("parse normalized state");
let peers = value.get("peers").and_then(Value::as_object).expect("normalized peers");
assert_eq!(value.get(SYNC_STATE_INITIALIZED_FIELD), Some(&Value::Bool(true)));
assert!(
peers
.values()
.all(|peer| peer.get("sync").and_then(Value::as_str) == Some("enable"))
);
}
#[test]
fn test_normalize_state_json_preserves_initialized_pending_sync_state() {
let data = serde_json::to_vec(&serde_json::json!({
"name": "local",
(SYNC_STATE_INITIALIZED_FIELD): true,
"peers": {
"local": peer_value("local", "https://local.example.com", "local"),
"remote": peer_value("remote", "https://remote.example.com", "remote")
}
}))
.expect("serialize pending state");
let normalized = normalize_site_replication_state_json(&data)
.expect("normalize pending state")
.expect("pending state should receive canonical peer fields");
let value: Value = serde_json::from_slice(&normalized).expect("parse normalized pending state");
let peers = value.get("peers").and_then(Value::as_object).expect("normalized peers");
assert_eq!(value.get(SYNC_STATE_INITIALIZED_FIELD), Some(&Value::Bool(true)));
assert!(
peers
.values()
.all(|peer| peer.get("sync").and_then(Value::as_str) == Some("unknown"))
);
}
}
+2 -10
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_madmin::{PeerInfo, SyncStatus};
use rustfs_madmin::PeerInfo;
use std::collections::{BTreeMap, hash_map::DefaultHasher};
use std::hash::{Hash, Hasher};
use url::Url;
@@ -76,15 +76,7 @@ pub fn same_identity_endpoint(left: &str, right: &str) -> bool {
site_identity_key(left) == site_identity_key(right)
}
pub(crate) fn mark_unknown_peer_sync_enabled(peers: &mut BTreeMap<String, PeerInfo>) {
for peer in peers.values_mut() {
if peer.sync_state == SyncStatus::Unknown {
peer.sync_state = SyncStatus::Enable;
}
}
}
pub(super) fn is_https_endpoint(endpoint: &str) -> bool {
fn is_https_endpoint(endpoint: &str) -> bool {
canonical_endpoint(endpoint).starts_with("https://")
}
+4 -6
View File
@@ -330,6 +330,10 @@ impl DefaultAdminUsecase {
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
};
if endpoints.legacy() {
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
}
Self::refresh_pool_status_snapshot(store.as_ref()).await?;
let mut pool_statuses = Vec::new();
@@ -396,12 +400,6 @@ impl DefaultAdminUsecase {
let Some(store) = self.object_store() else {
return Err(Self::app_error(S3ErrorCode::InternalError, "Not init"));
};
let Some(endpoints) = self.endpoints() else {
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
};
if endpoints.legacy() {
return Err(Self::app_error_default(S3ErrorCode::NotImplemented));
}
let pool_statuses = self.execute_list_pool_statuses().await?;
Self::refresh_rebalance_status_snapshot(store.as_ref()).await?;
let mut pools = Vec::with_capacity(pool_statuses.len());
+9 -4
View File
@@ -153,11 +153,13 @@ async fn data_movement_put_object_marks_dirty_disks_for_capacity_manager() {
let actual_paths: HashSet<_> = dirty_disks
.into_iter()
.map(|disk| stdfs::canonicalize(&disk.drive_path).unwrap().to_string_lossy().into_owned())
.filter_map(|disk| stdfs::canonicalize(&disk.drive_path).ok())
.map(|p| p.to_string_lossy().into_owned())
.collect();
let expected_paths: HashSet<_> = disk_paths
.iter()
.map(|path| stdfs::canonicalize(path).unwrap().to_string_lossy().into_owned())
.filter_map(|path| stdfs::canonicalize(path).ok())
.map(|p| p.to_string_lossy().into_owned())
.collect();
// The global dirty scope registry is process-wide; concurrent tests may
// add extra entries, so we only verify that our expected paths are present.
@@ -217,9 +219,12 @@ async fn heal_object_marks_missing_shard_disk_dirty_for_capacity_manager() {
let dirty_disks = manager.get_dirty_disks().await;
let actual_paths: HashSet<_> = dirty_disks
.into_iter()
.map(|disk| stdfs::canonicalize(&disk.drive_path).unwrap().to_string_lossy().into_owned())
.filter_map(|disk| stdfs::canonicalize(&disk.drive_path).ok())
.map(|p| p.to_string_lossy().into_owned())
.collect();
let expected_missing_disk = stdfs::canonicalize(&disk_paths[0]).unwrap().to_string_lossy().into_owned();
let expected_missing_disk = stdfs::canonicalize(&disk_paths[0])
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| disk_paths[0].to_string_lossy().into_owned());
assert!(
error.is_none() || actual_paths.contains(&expected_missing_disk),
@@ -836,13 +836,9 @@ async fn complete_multipart_upload_transitions_immediately_via_usecase() {
.build()
.unwrap();
let complete_output = Box::pin(usecase.execute_complete_multipart_upload(build_request(complete_input, Method::POST)))
Box::pin(usecase.execute_complete_multipart_upload(build_request(complete_input, Method::POST)))
.await
.expect("Failed to complete multipart upload through usecase");
assert!(
complete_output.output.version_id.is_some(),
"versioned CompleteMultipartUpload should return the created version ID"
);
let info = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
.await

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