mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-05 19:55:37 +00:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8cecf6462 | |||
| 980f3abbd3 | |||
| e36650827b | |||
| 09c8e10d5e | |||
| f053862aad | |||
| 0ff03c596c | |||
| e8a7f4bc4a | |||
| a74919db8e | |||
| e77c6f0ca5 | |||
| 3149411943 | |||
| 7ba5cd6888 | |||
| acfeef55ab | |||
| 0d1b312673 | |||
| 42c32381b6 | |||
| e6bf2a4646 | |||
| cf9688898d | |||
| 2d159635ed | |||
| 2f02d1d2d8 | |||
| 8ae8fb7eea | |||
| bbd7b9ef17 | |||
| 15e9bc5ed0 | |||
| 882d9ca8a4 | |||
| 19a29a7027 | |||
| 2e4ab045b6 | |||
| cbfd5b92f4 | |||
| 971f9acdf4 | |||
| a6589c19e3 | |||
| 0885c721fe | |||
| eaf5159d0f | |||
| 2477e31059 | |||
| d8c3b1bb26 | |||
| a3b8183be9 | |||
| 4dbc58887a | |||
| 123967e729 | |||
| 4b0d597d4d | |||
| 13e6424e99 | |||
| b33693fc19 | |||
| 9ed1d46090 | |||
| 6eb60f8e72 | |||
| 8dd3cabd41 | |||
| e648f683bf | |||
| 193b1b7d3f | |||
| 3b920c7999 | |||
| 5f8b097172 | |||
| 445114577f | |||
| 923bde6904 | |||
| 10ccf7c31a | |||
| 146003a426 | |||
| 65ed86f76e | |||
| 3da3bcd9ee | |||
| 20e4fd7de6 | |||
| da28f8c843 | |||
| 507447da12 | |||
| 81014fd233 | |||
| ff3c5a4989 | |||
| 4d226998e2 | |||
| f6bed1a73a | |||
| 7ff578ff20 | |||
| f16a30b231 | |||
| 6f717b2752 | |||
| 3654c147e2 | |||
| a9373288f2 | |||
| fdb28e611f | |||
| 5f739944a3 | |||
| 2bc88418f5 | |||
| 9d10d69a6d | |||
| 9863f4848d | |||
| 7dfc2ee5f0 | |||
| 80c88a9031 | |||
| 3005efe845 | |||
| 3a914b429d | |||
| 8a8616b8f7 | |||
| 62e66baf89 | |||
| 53cabe9274 | |||
| 0f272ddb14 | |||
| 0181a583a6 | |||
| 0713a723cd | |||
| a6cb34c7a4 | |||
| 703766677b | |||
| f7c51271ee | |||
| 0e6ee3bf62 | |||
| 3ab7a1921f | |||
| 86ebcb325c | |||
| 8023cf3e26 | |||
| 54a7e9f307 | |||
| 5e58b1d3a2 | |||
| df30dff1a7 | |||
| d011ec9952 | |||
| 1747ed0292 | |||
| 74be040c62 | |||
| df4fdef1d8 | |||
| 759a395fec | |||
| d04611ba38 | |||
| 37344a84da | |||
| c1070f1447 | |||
| 8bf569899a | |||
| 98f7e63396 | |||
| 07dce1cab0 | |||
| b727e669db | |||
| 0fe6cc3641 | |||
| 5be9d255e6 | |||
| 817adb127c | |||
| e3349f5f30 | |||
| a5bde8b0af | |||
| a23d4b05a3 | |||
| 183b5c9ede | |||
| 1ab6405ac9 | |||
| 9e0663cbba | |||
| ba20af77bb | |||
| 2231633ae1 | |||
| 2e2bc814b1 | |||
| 1dd81cf276 | |||
| 01db1f6644 | |||
| 7e1f261e38 | |||
| 99f85ca2b1 | |||
| afc66b7182 | |||
| 922552083f | |||
| 32eb116cbc | |||
| 68f47b9219 | |||
| de7422b508 | |||
| 1bbfa71b11 | |||
| c066faf07a | |||
| ca8bbbf8f3 | |||
| bc789332b6 | |||
| b422d1fea9 | |||
| 87bc9d14ea | |||
| fddaeba247 | |||
| b46a8164f8 | |||
| b1faaafb1f | |||
| 0a975f2fe2 | |||
| ceeff52229 | |||
| e04e15aed1 | |||
| afa84fa988 | |||
| 7aaed4d67b | |||
| 36e07e104a | |||
| b03804566c |
@@ -22,3 +22,17 @@
|
||||
fixtures and encrypted migration data.
|
||||
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
|
||||
condition, and default toward reading old data safely.
|
||||
|
||||
## Outbound targets
|
||||
|
||||
- A change to what the replication or migration client sends by default
|
||||
(checksum policy, payload framing, headers, version-id addressing) is judged
|
||||
against every target class, not the one it fixes. Name each target-side rule
|
||||
the current default satisfies — checksum required with Object Lock
|
||||
parameters, `aws-chunked` decoding, version-id adoption, ETag equals content
|
||||
MD5 — and show which cell of
|
||||
`crates/e2e_test/src/replication_target_matrix_test.rs` covers each.
|
||||
- A test that asserts the fix ("no trailer header") is not evidence; the
|
||||
matrix cell that asserts the target accepted and stored the object is.
|
||||
- Every new environment escape hatch appears in
|
||||
`docs/operations/replication-outbound-transport.md` in the same diff.
|
||||
|
||||
@@ -50,10 +50,11 @@ consider adding it to the script's `checked_files` list.
|
||||
|
||||
## `check_doc_paths.sh`
|
||||
|
||||
Instruction/architecture docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`,
|
||||
`docs/architecture/*.md`) must not reference repo file paths that no longer
|
||||
exist. If your refactor moved code, update the docs that point at it — the
|
||||
error message lists `doc -> stale-path` pairs.
|
||||
Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
|
||||
Markdown file under `docs/` (architecture, operations, testing, index) must not
|
||||
reference repo file paths that no longer exist. If your refactor moved code,
|
||||
update the docs that point at it — the error message lists `doc -> stale-path`
|
||||
pairs. Cite paths plus symbol names, never line numbers (see `docs/README.md`).
|
||||
|
||||
## `check_no_planning_docs.sh`
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=ef914ec0b8daa9c2c5e52f501d339914662f42d6f6ed9d33877d56b97adf16f9
|
||||
sha256-linux=a8a816d7bb0e7cb5632b1863b33794bcb9fc7e765f150aa5e1bf16518e28dfb4
|
||||
sha256-darwin=85ee9b9e66e916dbf51857298637b6bcd1c23c2b066aaa4c171ce961c2d002b5
|
||||
sha256-linux=a2933d83dfe74ffa03410a0959333a1c48288b8469ca9f17273d449d7510c24b
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
sha256=51da41c54167602f2bd6c45921b39a44562bf3cfcdf468d992bb992c62cad7fd
|
||||
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
|
||||
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
sha256=87c05c46d611ea7ed3feb5f7276bda8e5a0f70d72165d305d73a457907e7ba79
|
||||
@@ -1 +1 @@
|
||||
sha256=8d5517f5f2fc32d561782dfccd51b7f746f5e25b2835e37e100c883f7f18777d
|
||||
sha256=95c8adc016bbc0df9fb2afa24a108bcdf6567ec4d0518725a6cae301593ab556
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=dbebfbab9b9efd4eff31211e69dd32235dc00e207f2ab0dd919a1b2ac9e724c2
|
||||
sha256=a2542dc86bbff56b2177efc621785c56fa7e8d813b209b7d935e1e41a9f0ad15
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"lane": "ci/test-and-lint",
|
||||
"tests": [
|
||||
{
|
||||
"invariant": "write-quorum",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::inline_put_commit_path_tests::inline_put_direct_commit_accepts_exact_quorum_and_rejects_quorum_minus_one"
|
||||
},
|
||||
{
|
||||
"invariant": "metadata-rollback",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::write_unique_file_info_reverts_metadata_when_write_quorum_fails"
|
||||
},
|
||||
{
|
||||
"invariant": "stale-writer",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::put_object_no_lock_aborts_after_outer_namespace_lock_loss"
|
||||
},
|
||||
{
|
||||
"invariant": "range-body",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::transition_upload_integrity_tests::transitioned_compressed_object_range_get_returns_plaintext_slice"
|
||||
},
|
||||
{
|
||||
"invariant": "multipart-cancellation",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::multipart::tests::cancelled_complete_keeps_upload_lock_through_tail_cleanup"
|
||||
},
|
||||
{
|
||||
"invariant": "list-uncommitted-version",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
|
||||
},
|
||||
{
|
||||
"invariant": "minio-object-fixture",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "filemeta::test::parses_real_minio_object_xlmeta"
|
||||
},
|
||||
{
|
||||
"invariant": "corrupt-part-arrays",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "filemeta::test::crc_valid_but_part_arrays_corrupt_into_fileinfo_errors_not_panics"
|
||||
}
|
||||
],
|
||||
"fixtures": [
|
||||
{
|
||||
"path": "crates/filemeta/tests/fixtures/minio/object_large_bin.xlmeta.hex",
|
||||
"sha256": "e8093767806d701e639b48d023190e858fbc4cde69bcfd83c22af8cba8452ce5",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/filemeta/tests/fixtures/minio/object_small_txt.xlmeta.hex",
|
||||
"sha256": "2a415ad3a3be5a9440035d4026ff880e0e8c1ec1701be9f4e077734e8dce03da",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/filemeta/tests/fixtures/minio/object_versioned_txt.xlmeta.hex",
|
||||
"sha256": "7f21f50c326dd8b0228deb6dbdb7052b3d0a3f8ee6c85d43486f0e6bb7a97261",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata.blob.hex",
|
||||
"sha256": "f2b6e260aff106adf6039feb1c645686e84e75404ff725491fb18668be5db203",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata_full.xlmeta.hex",
|
||||
"sha256": "3b6de589519c08a1614c8bd409bb8199c17d42043861b07bce513075e6fbfc12",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,8 +38,10 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_python_bin.sh
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
|
||||
+88
-13
@@ -69,7 +69,7 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
@@ -210,7 +210,7 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
@@ -355,7 +355,8 @@ test-group = 'ecstore-serial-flaky'
|
||||
# allowlist", so any new replication test lands in nightly by default (never
|
||||
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
||||
# regexes byte-identical. The committed profile selection digests make changes
|
||||
# visible in CI; current counts live in docs/testing/e2e-suite-inventory.md.
|
||||
# visible in CI; list current membership with `cargo nextest list -p e2e_test
|
||||
# --profile <profile>` (platform-dependent; see docs/testing/README.md).
|
||||
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
|
||||
# (#4724) because they set a loopback (127.0.0.1) replication target that the
|
||||
# SSRF egress guard rejected on every PR after repl-1 (#4712). That is fixed —
|
||||
@@ -393,13 +394,32 @@ test-group = 'ecstore-serial-flaky'
|
||||
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
|
||||
# Single-node servers on random ports with isolated temp dirs — meets the
|
||||
# admission criteria unchanged.
|
||||
#
|
||||
# On-demand migration GA (backlog#2163 ODM-16): three named cases join the
|
||||
# lane, one per user-visible contract of the feature — a GET miss that pulls
|
||||
# the object and persists it locally, a HEAD miss that answers from the source
|
||||
# and stores nothing, and the admin config/status pair that must redact the
|
||||
# source secret. Each spawns one single-node rustfs server plus the in-process
|
||||
# fake S3 source (`fake_s3_target`, already in the first clause), so they meet
|
||||
# the admission criteria unchanged; measured at 15.8 s / 15.8 s / 15.9 s, which
|
||||
# is entirely the shared server startup and overlaps the lane's other tests.
|
||||
# The rest of `on_demand_migration::{get_basic,interaction,backfill,
|
||||
# harness_self}_test` stays in e2e-full and the fault / concurrency /
|
||||
# real-source modules stay in e2e-nightly; this is an allowlist, not a module
|
||||
# clause, so a new ODM test never lands here silently.
|
||||
#
|
||||
# Scanner authoritative usage publication (backlog#2213): data_usage_test is
|
||||
# the PR-lane e2e coverage for scanner usage snapshots consumed by quota and
|
||||
# admin surfaces. It uses the same single-node, random-port, isolated-temp-dir
|
||||
# fixture as the existing smoke modules.
|
||||
[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|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_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::/)
|
||||
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
@@ -439,6 +459,10 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
# until it is explicitly promoted to the fast PR subset — no replication test
|
||||
# is ever silently left out of CI.
|
||||
#
|
||||
# replication_target_matrix_test (the outbound target matrix: every object
|
||||
# shape against every remote-target failure mode the fake target models) runs
|
||||
# here in full; its expectation table pins known-red cells to open issues.
|
||||
#
|
||||
# #[serial] does NOT serialize under nextest (process-per-test; see the file
|
||||
# header). These tests need no cross-test serialization: each spawns its own
|
||||
# server(s) on random ports with isolated temp dirs, so they are parallel-safe
|
||||
@@ -456,7 +480,7 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
[profile.e2e-repl-nightly]
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& test(/^replication_extension_test::/)
|
||||
& (test(/^replication_extension_test::/) | test(/^replication_target_matrix_test::/))
|
||||
& !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)$/)
|
||||
"""
|
||||
fail-fast = false
|
||||
@@ -469,15 +493,29 @@ path = "junit.xml"
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-nightly profile — destructive multi-process cluster fault domains
|
||||
# ---------------------------------------------------------------------------
|
||||
# These seven modules are deliberately outside e2e-full's merge budget. Each
|
||||
# These eight modules are deliberately outside e2e-full's merge budget. Each
|
||||
# starts a real multi-process or multi-disk topology and exercises node/disk
|
||||
# loss, quorum, cleanup, notification fan-in, or admin-timeout behavior. The
|
||||
# consolidated nightly workflow runs them serially to avoid resource
|
||||
# starvation; failures are never retried.
|
||||
#
|
||||
# heal_erasure_disk_rebuild_test also runs in e2e-full so core heal rebuild
|
||||
# regressions are caught no later than the merge/main lane. It remains here for
|
||||
# nightly serial coverage with the other cluster fault domains.
|
||||
#
|
||||
# On-demand migration (backlog#2158 ODM-11) joins by the second clause: the
|
||||
# fault matrix waits out the 30 s circuit-breaker window, the concurrency
|
||||
# matrix drives 100-deep bursts, and the real-source cases start a second
|
||||
# (loop guard: a third) RustFS process. They are too slow or too heavy for
|
||||
# the merge budget; `on_demand_migration::{get_basic,interaction}_test` stay
|
||||
# in e2e-full, which excludes exactly these three modules.
|
||||
[profile.e2e-nightly]
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& (
|
||||
test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
| test(/^on_demand_migration::(concurrency_test|fault_test|real_source_test)::/)
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
|
||||
@@ -488,6 +526,34 @@ path = "junit.xml"
|
||||
filter = 'package(e2e_test)'
|
||||
test-group = 'e2e-cluster-nightly'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
|
||||
# ---------------------------------------------------------------------------
|
||||
# backlog#2167. Report-only, scheduled, never a required check; wired by
|
||||
# .github/workflows/on-demand-migration-interop.yml.
|
||||
#
|
||||
# The four cases in `on_demand_migration::interop_test` take their source from
|
||||
# the environment (`RUSTFS_ODM_INTEROP_*`, documented on the constants in
|
||||
# `crates/e2e_test/src/on_demand_migration/common.rs`), so the same bodies run
|
||||
# against the in-process fake source locally and against a MinIO container or a
|
||||
# real cloud provider in the lane. The cloud jobs narrow this profile with their
|
||||
# own `-E` filter to the three-case minimum (GET miss, HEAD miss, merged list
|
||||
# pagination) and pass `--no-tests=fail` so a rename cannot silently select
|
||||
# nothing; the MinIO job runs the whole profile, backfill included.
|
||||
#
|
||||
# These cases are deliberately absent from every other lane: without an
|
||||
# interop source they only re-prove what `get_basic_test` and
|
||||
# `list_through_test` already cover in e2e-smoke and e2e-full. The committed
|
||||
# selection digest is the guard against a rename dropping one of them.
|
||||
[profile.e2e-odm-interop]
|
||||
default-filter = 'package(e2e_test) & test(/^on_demand_migration::interop_test::/)'
|
||||
fail-fast = false
|
||||
|
||||
[profile.e2e-odm-interop.junit]
|
||||
# Emitted to target/nextest/e2e-odm-interop/junit.xml; the lane uploads it and
|
||||
# reconciles it against the per-case JSON report entries.
|
||||
path = "junit.xml"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-protocols profile — serial protocol lane
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -508,16 +574,23 @@ path = "junit.xml"
|
||||
# quota, checksum, encryption,
|
||||
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
|
||||
# skips. Budget <= 45 min; authority for the suite count is `cargo nextest list
|
||||
# --profile e2e-full` (see docs/testing/e2e-suite-inventory.md).
|
||||
# --profile e2e-full -p e2e_test` (platform-dependent; see docs/testing/README.md).
|
||||
#
|
||||
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
|
||||
# * protocols:: — FTPS/SFTP/WebDAV, run from the dedicated protocol profile
|
||||
# with one worker because the suite owns fixed ports.
|
||||
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
|
||||
# * cluster suites that spin up a RustFSTestClusterEnvironment
|
||||
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
|
||||
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
|
||||
# object_lambda) — too heavy for the merge budget; they run in the
|
||||
# e2e-nightly serial cluster-fault lane.
|
||||
# namespace_lock_quorum, admin_timeout_regression, object_lambda) — too
|
||||
# heavy for the merge budget; they run in the e2e-nightly serial
|
||||
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
|
||||
# excluded here because backlog#2213 promotes core heal rebuild coverage to
|
||||
# this merge/main lane while retaining nightly coverage.
|
||||
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
|
||||
# cases, which are meaningless without a source: they run in the dedicated
|
||||
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
|
||||
# MinIO container or a real cloud provider. Excluding them here also keeps
|
||||
# this profile's committed selection digest stable.
|
||||
# * replication_extension_test — repl-1 already splits it into the PR
|
||||
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
|
||||
# it for those, so e2e-full does not double-run it.
|
||||
@@ -533,8 +606,10 @@ path = "junit.xml"
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& !test(/^protocols::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^replication_extension_test::/)
|
||||
& !test(/^replication_target_matrix_test::/)
|
||||
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
||||
"""
|
||||
fail-fast = false
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
name: On-demand migration interop report
|
||||
description: >-
|
||||
Merge the per-case JSON entries an on-demand-migration interop run wrote with
|
||||
the nextest JUnit result into one provider report, and summarise it.
|
||||
|
||||
inputs:
|
||||
provider:
|
||||
description: Provider the run addressed (minio, aws, r2, gcs).
|
||||
required: true
|
||||
cases-dir:
|
||||
description: Directory the cases wrote their JSON entries into.
|
||||
required: true
|
||||
junit:
|
||||
description: nextest JUnit XML of the run.
|
||||
required: true
|
||||
output:
|
||||
description: Path of the merged JSON report to write.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# The JUnit file is authoritative for which cases ran and how they ended:
|
||||
# a case that fails or panics never reaches its own report entry, so
|
||||
# trusting the entries alone would silently shorten the report exactly when
|
||||
# something went wrong. The entries only add what JUnit cannot know — the
|
||||
# source request accounting and the bucket's migration counters.
|
||||
- name: Merge interop case reports
|
||||
shell: bash
|
||||
env:
|
||||
ODM_REPORT_PROVIDER: ${{ inputs.provider }}
|
||||
ODM_REPORT_CASES_DIR: ${{ inputs.cases-dir }}
|
||||
ODM_REPORT_JUNIT: ${{ inputs.junit }}
|
||||
ODM_REPORT_OUTPUT: ${{ inputs.output }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import xml.etree.ElementTree as ElementTree
|
||||
|
||||
provider = os.environ["ODM_REPORT_PROVIDER"]
|
||||
cases_dir = pathlib.Path(os.environ["ODM_REPORT_CASES_DIR"])
|
||||
junit = pathlib.Path(os.environ["ODM_REPORT_JUNIT"])
|
||||
output = pathlib.Path(os.environ["ODM_REPORT_OUTPUT"])
|
||||
|
||||
entries = {}
|
||||
if cases_dir.is_dir():
|
||||
for path in sorted(cases_dir.glob("*.json")):
|
||||
entry = json.loads(path.read_text())
|
||||
entries[entry["case"]] = entry
|
||||
|
||||
cases = []
|
||||
for case in ElementTree.parse(junit).getroot().iter("testcase"):
|
||||
name = case.get("name", "")
|
||||
failed = [child for child in case if child.tag in ("failure", "error")]
|
||||
skipped = [child for child in case if child.tag == "skipped"]
|
||||
outcome = "failed" if failed else "skipped" if skipped else "passed"
|
||||
entry = entries.get(name.rsplit("::", 1)[-1], {})
|
||||
cases.append(
|
||||
{
|
||||
"name": name,
|
||||
"outcome": outcome,
|
||||
"junit_duration_ms": round(float(case.get("time", "0")) * 1000),
|
||||
"case_duration_ms": entry.get("duration_ms"),
|
||||
"source_requests": entry.get("source_requests"),
|
||||
"odm_counters": entry.get("odm_counters"),
|
||||
}
|
||||
)
|
||||
|
||||
report = {
|
||||
"provider": provider,
|
||||
"repository": os.environ.get("GITHUB_REPOSITORY", ""),
|
||||
"sha": os.environ.get("GITHUB_SHA", ""),
|
||||
"run_id": os.environ.get("GITHUB_RUN_ID", ""),
|
||||
"cases": cases,
|
||||
"totals": {
|
||||
"cases": len(cases),
|
||||
"passed": sum(1 for case in cases if case["outcome"] == "passed"),
|
||||
"failed": sum(1 for case in cases if case["outcome"] == "failed"),
|
||||
},
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
summary = [f"### On-demand migration interop: `{provider}`", "", "| Case | Outcome | Duration | Source requests |", "|---|---|---|---|"]
|
||||
for case in cases:
|
||||
requests = case["source_requests"]
|
||||
counted = f"{requests['total']} ({requests['counted_by']})" if requests else "not reported"
|
||||
summary.append(f"| `{case['name']}` | {case['outcome']} | {case['junit_duration_ms']} ms | {counted} |")
|
||||
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(summary) + "\n\n")
|
||||
|
||||
# A passed case with no entry of its own means the harness stopped
|
||||
# writing one: the report would keep looking complete while silently
|
||||
# losing its request accounting.
|
||||
unreported = [case["name"] for case in cases if case["outcome"] == "passed" and case["source_requests"] is None]
|
||||
if unreported:
|
||||
raise SystemExit(f"passed cases wrote no interop report entry: {', '.join(unreported)}")
|
||||
PY
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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.
|
||||
|
||||
name: Quick Checks
|
||||
description: Run the shared compile-free RustFS quality checks.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install quality tools
|
||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||
with:
|
||||
tool: |
|
||||
ripgrep@15.2.0
|
||||
shellcheck@0.11.0
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
actionlint_dir="$(mktemp -d "${RUNNER_TEMP}/actionlint.XXXXXX")"
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "$actionlint_dir/actionlint.tar.gz" \
|
||||
https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
|
||||
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $actionlint_dir/actionlint.tar.gz" | sha256sum --check --status
|
||||
tar -xzf "$actionlint_dir/actionlint.tar.gz" -C "$actionlint_dir" actionlint
|
||||
rm "$actionlint_dir/actionlint.tar.gz"
|
||||
echo "$actionlint_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check workflow syntax and shell scripts
|
||||
shell: bash
|
||||
run: shellcheck --version && actionlint
|
||||
|
||||
- name: Check code formatting
|
||||
shell: bash
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Check unsafe code allowances
|
||||
shell: bash
|
||||
run: ./scripts/check_unsafe_code_allowances.sh
|
||||
|
||||
- name: Check layered dependencies
|
||||
shell: bash
|
||||
run: ./scripts/check_layer_dependencies.sh
|
||||
|
||||
- name: Check architecture migration rules
|
||||
shell: bash
|
||||
run: ./scripts/check_architecture_migration_rules.sh
|
||||
|
||||
- name: Check logging guardrails
|
||||
shell: bash
|
||||
run: ./scripts/check_logging_guardrails.sh
|
||||
|
||||
- name: Check error other(format!) ratchet
|
||||
shell: bash
|
||||
run: ./scripts/check_error_other_format_ratchet.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
shell: bash
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
- name: Check extension schema boundaries
|
||||
shell: bash
|
||||
run: ./scripts/check_extension_schema_boundaries.sh
|
||||
|
||||
- name: Check body-cache whitelist guard
|
||||
shell: bash
|
||||
run: ./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
- name: Check s3s footprint ratchet
|
||||
shell: bash
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
shell: bash
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
- name: Check no embedded secret material
|
||||
shell: bash
|
||||
run: ./scripts/check_embedded_secrets.sh
|
||||
|
||||
- name: Check test wiring
|
||||
shell: bash
|
||||
run: |
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/test_e2e_binary.py
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/test_security_workflow.py
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
|
||||
- name: Check no planning docs committed
|
||||
shell: bash
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Check CI paths stay in sync
|
||||
shell: bash
|
||||
run: ./scripts/check_ci_paths_sync.sh
|
||||
|
||||
- name: Check io_uring lane --lib precondition
|
||||
shell: bash
|
||||
run: ./scripts/check_uring_lane_lib_only.sh
|
||||
@@ -10,16 +10,16 @@ Use N/A when there is no related issue.
|
||||
|
||||
## Summary of Changes
|
||||
<!--
|
||||
Briefly explain what changed and why reviewers should accept it.
|
||||
Focus on behavior, compatibility, and review-relevant context.
|
||||
Describe the concrete problem and resulting behavior. For a behavior change, name the input or state that triggers it and the expected outcome. Explain any new dependency or abstraction that the change needs.
|
||||
-->
|
||||
|
||||
## Verification
|
||||
<!--
|
||||
List the commands or checks you ran, for example:
|
||||
- `make pre-commit`
|
||||
Give 1–3 concrete pieces of evidence for the changed behavior: the test or command, its observed result, and the regression it catches. For a bug fix, record a failing-before/passing-after check or explain why it was unavailable.
|
||||
|
||||
Use N/A only when verification is not applicable.
|
||||
Identify the tested commit and any local changes. When testing a prebuilt binary or external service, include its source/version and artifact identity; a successful run against a different build is not evidence for this change.
|
||||
|
||||
List relevant checks not run and the remaining risk. Use the validation tier in AGENTS.md; do not run broader checks solely to fill this section. For documentation-only changes, list the applicable documentation checks. Use N/A only when verification is not applicable.
|
||||
-->
|
||||
|
||||
## Impact
|
||||
|
||||
@@ -1065,7 +1065,7 @@ jobs:
|
||||
while IFS= read -r preview_tag; do
|
||||
[[ -n "$preview_tag" ]] || continue
|
||||
echo "🧹 Deleting preview release $preview_tag (tag kept)"
|
||||
gh release delete "$preview_tag" --yes
|
||||
gh release delete "$preview_tag" --repo "${GITHUB_REPOSITORY}" --yes
|
||||
DELETED=$((DELETED + 1))
|
||||
done < <(
|
||||
jq -r --arg tag "$TAG" '
|
||||
|
||||
@@ -12,24 +12,10 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Companion to ci.yml for required status checks.
|
||||
#
|
||||
# ci.yml skips docs-only pull requests via paths-ignore, but the branch ruleset
|
||||
# requires a check named "Test and Lint" — without this workflow a docs-only PR
|
||||
# would wait on it forever. This workflow triggers on exactly the paths ci.yml
|
||||
# ignores and reports success under the same job name. Mixed PRs trigger both
|
||||
# workflows and the real check still gates: a required check with any failing
|
||||
# run blocks the merge.
|
||||
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
|
||||
#
|
||||
# "Quick Checks" is mirrored here ahead of the ruleset change that will make it
|
||||
# required too (rustfs/backlog#1599). Until that change lands this job is
|
||||
# inert; mirroring it first is what lets the ruleset change happen without
|
||||
# stranding docs-only PRs on a check nobody reports.
|
||||
#
|
||||
# Keep the paths list below in sync with the pull_request paths-ignore list
|
||||
# in ci.yml, and keep the quick-checks steps below byte-identical to the
|
||||
# quick-checks job in ci.yml.
|
||||
# Reports the existing required checks for paths excluded by ci.yml.
|
||||
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
||||
# action to keep validation coverage aligned. Keep this paths list in sync with
|
||||
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
||||
|
||||
name: Continuous Integration (docs only)
|
||||
|
||||
@@ -59,19 +45,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Deliberately NOT a bare `echo`. Once "Quick Checks" becomes a required
|
||||
# check, ci.yml gates every expensive job behind it, so a mixed PR reports
|
||||
# two check runs with this name: the real one (45-51s) and this companion.
|
||||
# GitHub has no written contract for how it picks between same-named
|
||||
# required check runs ("latest wins" vs "any failure blocks"), so instead of
|
||||
# relying on ordering we make both runs execute the same commands against
|
||||
# the same merge ref — their conclusions are then necessarily identical and
|
||||
# the choice does not matter. Keep these steps byte-identical to the
|
||||
# quick-checks job in ci.yml (a guard script that asserts this, and the paths
|
||||
# sync below, is tracked in rustfs/backlog#1603).
|
||||
#
|
||||
# For a genuinely docs-only PR this adds no strictness (no code changed, so
|
||||
# fmt and the guards always pass) and costs ~50s of ubuntu-latest.
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
runs-on: ubuntu-latest
|
||||
@@ -82,63 +55,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install ripgrep
|
||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||
with:
|
||||
tool: ripgrep@15.2.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check code formatting
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Check unsafe code allowances
|
||||
run: ./scripts/check_unsafe_code_allowances.sh
|
||||
|
||||
- name: Check layered dependencies
|
||||
run: ./scripts/check_layer_dependencies.sh
|
||||
|
||||
- name: Check architecture migration rules
|
||||
run: ./scripts/check_architecture_migration_rules.sh
|
||||
|
||||
- name: Check logging guardrails
|
||||
run: ./scripts/check_logging_guardrails.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
- name: Check extension schema boundaries
|
||||
run: ./scripts/check_extension_schema_boundaries.sh
|
||||
|
||||
- name: Check body-cache whitelist guard
|
||||
run: ./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
- name: Check s3s footprint ratchet
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
- name: Check no embedded secret material
|
||||
run: ./scripts/check_embedded_secrets.sh
|
||||
|
||||
- name: Check test wiring
|
||||
run: |
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Check CI paths stay in sync
|
||||
run: ./scripts/check_ci_paths_sync.sh
|
||||
|
||||
- name: Check io_uring lane --lib precondition
|
||||
run: ./scripts/check_uring_lane_lib_only.sh
|
||||
- name: Run shared quick checks
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
test-and-lint:
|
||||
name: Test and Lint
|
||||
|
||||
+24
-76
@@ -100,12 +100,7 @@ jobs:
|
||||
- name: Typos check with custom config file
|
||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||
|
||||
# Fast, compile-free checks that fail early so contributors get feedback in
|
||||
# ~1 minute instead of waiting for the full test job.
|
||||
#
|
||||
# These steps are mirrored byte-for-byte in ci-docs-only.yml so that a mixed
|
||||
# PR, which reports two check runs named "Quick Checks", cannot get one red
|
||||
# and one green. Edit both jobs together.
|
||||
# Fail early with compile-free checks shared with docs-only CI.
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
@@ -117,66 +112,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install ripgrep
|
||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||
with:
|
||||
tool: ripgrep@15.2.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check code formatting
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Check unsafe code allowances
|
||||
run: ./scripts/check_unsafe_code_allowances.sh
|
||||
|
||||
- name: Check layered dependencies
|
||||
run: ./scripts/check_layer_dependencies.sh
|
||||
|
||||
- name: Check architecture migration rules
|
||||
run: ./scripts/check_architecture_migration_rules.sh
|
||||
|
||||
- name: Check logging guardrails
|
||||
run: ./scripts/check_logging_guardrails.sh
|
||||
|
||||
- name: Check error other(format!) ratchet
|
||||
run: ./scripts/check_error_other_format_ratchet.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
- name: Check extension schema boundaries
|
||||
run: ./scripts/check_extension_schema_boundaries.sh
|
||||
|
||||
- name: Check body-cache whitelist guard
|
||||
run: ./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
- name: Check s3s footprint ratchet
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
- name: Check no embedded secret material
|
||||
run: ./scripts/check_embedded_secrets.sh
|
||||
|
||||
- name: Check test wiring
|
||||
run: |
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Check CI paths stay in sync
|
||||
run: ./scripts/check_ci_paths_sync.sh
|
||||
|
||||
- name: Check io_uring lane --lib precondition
|
||||
run: ./scripts/check_uring_lane_lib_only.sh
|
||||
- name: Run shared quick checks
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
test-and-lint:
|
||||
name: Test and Lint
|
||||
@@ -269,6 +206,7 @@ jobs:
|
||||
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||
run: |
|
||||
mkdir -p artifacts/test-and-lint
|
||||
rm -f target/nextest/ci/junit.xml
|
||||
./scripts/ci/resource_sampler.sh start nextest
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
set +e
|
||||
@@ -277,6 +215,12 @@ jobs:
|
||||
--status-level all --final-status-level all \
|
||||
2>&1 | tee artifacts/test-and-lint/nextest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
if [[ "${status}" -eq 0 ]]; then
|
||||
cargo nextest list --profile ci --all --exclude e2e_test --message-format json \
|
||||
> artifacts/test-and-lint/core-test-listing.json \
|
||||
&& python3 scripts/check_test_wiring.py --check-core artifacts/test-and-lint/core-test-listing.json \
|
||||
&& test -s target/nextest/ci/junit.xml || status=$?
|
||||
fi
|
||||
{
|
||||
echo "command=cargo nextest run --profile ci --all --exclude e2e_test"
|
||||
echo "exit_status=${status}"
|
||||
@@ -638,13 +582,15 @@ jobs:
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build debug binary
|
||||
run: cargo build -p rustfs --bins --features e2e-test-hooks
|
||||
run: python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
- name: Upload debug binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-debug-binary
|
||||
path: target/debug/rustfs
|
||||
path: |
|
||||
target/debug/rustfs
|
||||
target/debug/rustfs.e2e.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -676,13 +622,15 @@ jobs:
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build debug binary with rio-v2
|
||||
run: cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
||||
run: python3 scripts/e2e_binary.py build --bins --features rio-v2,e2e-test-hooks
|
||||
|
||||
- name: Upload debug binary
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-debug-binary-rio-v2
|
||||
path: target/debug/rustfs
|
||||
path: |
|
||||
target/debug/rustfs
|
||||
target/debug/rustfs.e2e.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -831,7 +779,7 @@ jobs:
|
||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
||||
run: |
|
||||
cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||
--status-level all --final-status-level all --failure-output final
|
||||
|
||||
- name: Upload e2e smoke diagnostics
|
||||
@@ -867,7 +815,7 @@ jobs:
|
||||
RUSTFS_TEST_PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
||||
RUSTFS_TEST_PORT="${RUSTFS_TEST_PORT}" \
|
||||
RUSTFS_TEST_LOG="${RUN_ROOT}/rustfs.log" \
|
||||
./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
||||
|
||||
- name: Upload test logs
|
||||
if: failure()
|
||||
@@ -969,7 +917,7 @@ jobs:
|
||||
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
|
||||
# debug binary; each test spawns its own rustfs server on a random port.
|
||||
- name: Run e2e full suite
|
||||
run: cargo nextest run --profile e2e-full -p e2e_test
|
||||
run: python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||
|
||||
- name: Upload junit
|
||||
if: always()
|
||||
@@ -1030,7 +978,7 @@ jobs:
|
||||
- name: Run end-to-end tests
|
||||
run: |
|
||||
s3s-e2e --version
|
||||
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
||||
python3 scripts/e2e_binary.py run --features rio-v2,e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
||||
|
||||
- name: Upload test logs
|
||||
if: failure()
|
||||
@@ -1073,7 +1021,7 @@ jobs:
|
||||
S3_PORT="${S3_PORT}" \
|
||||
DATA_ROOT="${RUN_ROOT}" \
|
||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||
./scripts/s3-tests/run.sh
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
||||
|
||||
- name: Upload s3 test artifacts
|
||||
if: always()
|
||||
@@ -1155,7 +1103,7 @@ jobs:
|
||||
S3_PORT="${S3_PORT}" \
|
||||
DATA_ROOT="${RUN_ROOT}" \
|
||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||
./scripts/s3-tests/run.sh
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
||||
|
||||
- name: Upload s3 test artifacts
|
||||
if: always()
|
||||
|
||||
@@ -89,14 +89,10 @@ jobs:
|
||||
- name: Verify awscurl
|
||||
run: test -x "$AWSCURL_PATH"
|
||||
|
||||
# Build the rustfs binary once up front. The e2e tests spawn it as a
|
||||
# child process (crates/e2e_test/src/common.rs) and will build it on
|
||||
# demand otherwise, but a single explicit build avoids several parallel
|
||||
# nextest test processes racing to build it at once.
|
||||
# Build once and carry its source/binary identity into the test invocation.
|
||||
- name: Build rustfs binary
|
||||
run: |
|
||||
cargo build -p rustfs --bins
|
||||
: > target/debug/rustfs.features
|
||||
python3 scripts/e2e_binary.py build --bins
|
||||
|
||||
- name: Verify replication e2e membership
|
||||
env:
|
||||
@@ -108,7 +104,7 @@ jobs:
|
||||
- name: Run replication e2e nightly suite
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
||||
run: cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
|
||||
- name: Upload nextest junit report
|
||||
if: always()
|
||||
@@ -144,8 +140,7 @@ jobs:
|
||||
|
||||
- name: Build rustfs binary
|
||||
run: |
|
||||
cargo build -p rustfs --bins --features e2e-test-hooks
|
||||
: > target/debug/rustfs.features
|
||||
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
- name: Verify cluster fault e2e membership
|
||||
env:
|
||||
@@ -157,7 +152,7 @@ jobs:
|
||||
- name: Run cluster fault e2e nightly suite
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
||||
run: cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
run: python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
|
||||
- name: Upload cluster fault diagnostics
|
||||
if: always()
|
||||
@@ -198,6 +193,9 @@ jobs:
|
||||
sudo apt-get install -y -qq iproute2
|
||||
ss -tn state CLOSE-WAIT >/dev/null
|
||||
|
||||
- name: Build protocol server
|
||||
run: python3 scripts/e2e_binary.py build --features "$RUSTFS_BUILD_FEATURES"
|
||||
|
||||
# The suite owns fixed protocol ports and serializes its internal cases.
|
||||
- name: Verify protocol e2e membership
|
||||
env:
|
||||
@@ -210,7 +208,7 @@ jobs:
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
||||
run: >-
|
||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||
python3 scripts/e2e_binary.py run --features "$RUSTFS_BUILD_FEATURES" -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||
|
||||
- name: Upload protocol diagnostics
|
||||
if: always()
|
||||
|
||||
@@ -98,12 +98,11 @@ jobs:
|
||||
|
||||
- name: Build current RustFS binary
|
||||
run: |
|
||||
cargo build --locked -p rustfs --bin rustfs
|
||||
: > target/debug/rustfs.features
|
||||
python3 scripts/e2e_binary.py build
|
||||
|
||||
- name: Run upgrade compatibility test
|
||||
run: |
|
||||
cargo test --locked -p e2e_test \
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||
-- --ignored --exact --nocapture
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# 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.
|
||||
|
||||
# On-demand migration provider interop (rustfs/backlog#2167, ODM-20).
|
||||
#
|
||||
# The in-process fake source that the merge-gate ODM suite runs against covers
|
||||
# the protocol semantics, but real implementations differ in path-style vs
|
||||
# virtual-host addressing, region handling, ETag shape, list pagination and
|
||||
# rate limiting. This lane runs the same case bodies
|
||||
# (crates/e2e_test/src/on_demand_migration/interop_test.rs) against real
|
||||
# sources; the source is injected through RUSTFS_ODM_INTEROP_* environment
|
||||
# variables, so nothing about the cases is duplicated per provider.
|
||||
#
|
||||
# Report-only and scheduled. It is never a required check and must not be
|
||||
# promoted to one: it depends on third-party endpoints and on repository
|
||||
# secrets that a fork does not have.
|
||||
#
|
||||
# Jobs:
|
||||
# * minio-source runs the whole e2e-odm-interop profile — read-through,
|
||||
# HEAD passthrough, merged list pagination and a backfill — against a
|
||||
# pinned MinIO container. The backfill is sized at 5,000 objects here: the
|
||||
# fake source retains at most 4,096 object versions and 4,096 journal
|
||||
# entries, so the merge-gate backfill coverage cannot go past that, and a
|
||||
# real source is where a production-shaped batch belongs.
|
||||
# * cloud-source runs the three-case minimum (GET miss, HEAD miss, merged
|
||||
# list pagination) against AWS S3, Cloudflare R2 and the GCS XML
|
||||
# interoperability API. Each provider is skipped with a summary note when
|
||||
# its ODM_INTEROP_* repository secrets are absent, which is the normal
|
||||
# state on a fork and in any clone of this repository.
|
||||
#
|
||||
# Every job uploads one JSON report per provider naming the cases, their
|
||||
# timings and the source request accounting.
|
||||
name: on-demand-migration-interop
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Nightly at 05:23 UTC, offset from the other nightly lanes.
|
||||
- cron: "23 5 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
# The three cases a cloud provider is asked for. Named individually rather
|
||||
# than by module so adding a fourth case does not silently start billing a
|
||||
# cloud account for it.
|
||||
CLOUD_CASE_FILTER: >-
|
||||
package(e2e_test) & test(/^on_demand_migration::interop_test::(interop_get_miss_pulls_from_the_source_and_serves_locally|interop_head_miss_answers_from_the_source_without_persisting|interop_list_through_pages_the_source_namespace)$/)
|
||||
CLOUD_CASE_COUNT: "3"
|
||||
|
||||
jobs:
|
||||
minio-source:
|
||||
name: MinIO source (read-through, list-through, backfill)
|
||||
# Skip on forks: needs this repository's runners and is not a contributor
|
||||
# gate.
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
# Fixed credentials of the container this job starts and throws away;
|
||||
# not a secret and deliberately not read from one, so the lane runs
|
||||
# unattended in any clone that enables it.
|
||||
MINIO_ROOT_USER: rustfsodminterop
|
||||
MINIO_ROOT_PASSWORD: rustfsodminteropsecret
|
||||
RUSTFS_ODM_INTEROP_PROVIDER: minio
|
||||
RUSTFS_ODM_INTEROP_ENDPOINT: http://127.0.0.1:9100
|
||||
RUSTFS_ODM_INTEROP_REGION: auto
|
||||
RUSTFS_ODM_INTEROP_BUCKET: odm-interop-source
|
||||
RUSTFS_ODM_INTEROP_PATH_STYLE: path
|
||||
RUSTFS_ODM_INTEROP_ACCESS_KEY: rustfsodminterop
|
||||
RUSTFS_ODM_INTEROP_SECRET_KEY: rustfsodminteropsecret
|
||||
RUSTFS_ODM_INTEROP_BACKFILL_OBJECTS: "5000"
|
||||
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/minio/cases
|
||||
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/minio/selection.json
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: ci-odm-interop
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Start MinIO source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/odm-interop/minio
|
||||
docker run -d --name rustfs-odm-interop-minio \
|
||||
-e "MINIO_ROOT_USER=${MINIO_ROOT_USER}" \
|
||||
-e "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" \
|
||||
-p 9100:9000 \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
|
||||
for _ in $(seq 1 120); do
|
||||
curl -fsS http://127.0.0.1:9100/minio/health/live >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://127.0.0.1:9100/minio/health/live
|
||||
|
||||
# The harness never creates a bucket, so that pointing it at a cloud
|
||||
# account cannot create one there either. The source bucket for the
|
||||
# container is created here instead.
|
||||
- name: Create the MinIO source bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ env.MINIO_ROOT_USER }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ env.MINIO_ROOT_PASSWORD }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
aws --endpoint-url "${RUSTFS_ODM_INTEROP_ENDPOINT}" \
|
||||
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
||||
|
||||
- name: Build the RustFS binary under test
|
||||
run: python3 scripts/e2e_binary.py build --bins
|
||||
|
||||
# The lane selects tests by module, so a rename would quietly shrink it.
|
||||
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
||||
# closed on that.
|
||||
- name: Verify interop lane membership
|
||||
run: |
|
||||
cargo nextest list --profile e2e-odm-interop -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
||||
|
||||
- name: Run the interop cases against MinIO
|
||||
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
||||
|
||||
- name: Build the MinIO interop report
|
||||
if: always()
|
||||
uses: ./.github/actions/odm-interop-report
|
||||
with:
|
||||
provider: minio
|
||||
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
|
||||
junit: target/nextest/e2e-odm-interop/junit.xml
|
||||
output: artifacts/odm-interop/minio/report.json
|
||||
|
||||
- name: Collect MinIO logs
|
||||
if: always()
|
||||
run: |
|
||||
docker logs --tail 500 rustfs-odm-interop-minio \
|
||||
> artifacts/odm-interop/minio/minio.log 2>&1 || true
|
||||
|
||||
- name: Stop MinIO source
|
||||
if: always()
|
||||
run: docker rm -f rustfs-odm-interop-minio >/dev/null 2>&1 || true
|
||||
|
||||
- name: Upload the MinIO interop report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: odm-interop-minio-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/odm-interop/minio
|
||||
target/nextest/e2e-odm-interop/junit.xml
|
||||
retention-days: 14
|
||||
|
||||
# Unlike a production migration source, which needs read access only, the
|
||||
# credentials here also seed the objects each case reads back, so they need
|
||||
# write and delete on the interop bucket. Every run seeds under
|
||||
# `odm-interop/<case>/<uuid>/` and deletes what it seeded when the case
|
||||
# passes; give the bucket an expiration lifecycle rule so the prefixes a
|
||||
# failing case leaves behind cannot accumulate.
|
||||
cloud-source:
|
||||
name: ${{ matrix.provider }} source (three-case minimum)
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- provider: aws
|
||||
secret_prefix: AWS
|
||||
path_style: virtual
|
||||
- provider: r2
|
||||
secret_prefix: R2
|
||||
path_style: virtual
|
||||
- provider: gcs
|
||||
secret_prefix: GCS_HMAC
|
||||
path_style: virtual
|
||||
env:
|
||||
RUSTFS_ODM_INTEROP_PROVIDER: ${{ matrix.provider }}
|
||||
RUSTFS_ODM_INTEROP_PATH_STYLE: ${{ matrix.path_style }}
|
||||
RUSTFS_ODM_INTEROP_ENDPOINT: ${{ secrets[format('ODM_INTEROP_{0}_ENDPOINT', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_REGION: ${{ secrets[format('ODM_INTEROP_{0}_REGION', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_BUCKET: ${{ secrets[format('ODM_INTEROP_{0}_BUCKET', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_ACCESS_KEY: ${{ secrets[format('ODM_INTEROP_{0}_ACCESS_KEY_ID', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_SECRET_KEY: ${{ secrets[format('ODM_INTEROP_{0}_SECRET_ACCESS_KEY', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/cases
|
||||
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/selection.json
|
||||
steps:
|
||||
# Absent secrets are the normal state, not a failure: the lane reports
|
||||
# which providers it could reach and skips the rest. An empty value is
|
||||
# what an unset repository secret expands to, so it is checked, not the
|
||||
# secret's existence.
|
||||
- name: Check for provider credentials
|
||||
id: credentials
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${RUSTFS_ODM_INTEROP_ENDPOINT}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_REGION}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_BUCKET}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_ACCESS_KEY}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_SECRET_KEY}" ]; then
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### On-demand migration interop: \`${{ matrix.provider }}\`"
|
||||
echo
|
||||
echo "Skipped: the \`ODM_INTEROP_${{ matrix.secret_prefix }}_*\` repository secrets"
|
||||
echo "(\`_ENDPOINT\`, \`_REGION\`, \`_BUCKET\`, \`_ACCESS_KEY_ID\`, \`_SECRET_ACCESS_KEY\`)"
|
||||
echo "are not configured, so no real \`${{ matrix.provider }}\` source was reached."
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: ci-odm-interop
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build the RustFS binary under test
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: python3 scripts/e2e_binary.py build --bins
|
||||
|
||||
# A filterset that matches nothing is valid, so the count is asserted
|
||||
# rather than inferred from a green run.
|
||||
- name: Verify the three-case minimum still selects three cases
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$(dirname "${NEXTEST_LISTING}")"
|
||||
cargo nextest list --profile e2e-odm-interop -p e2e_test \
|
||||
-E "${CLOUD_CASE_FILTER}" --message-format json > "${NEXTEST_LISTING}"
|
||||
selected="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(sum(1 for suite in d.get("rust-suites", {}).values() for test in suite.get("testcases", {}).values() if test.get("filter-match", {}).get("status") == "matches"))' "${NEXTEST_LISTING}")"
|
||||
echo "cloud interop cases selected: ${selected}"
|
||||
if [ "${selected}" != "${CLOUD_CASE_COUNT}" ]; then
|
||||
echo "::error::CLOUD_CASE_FILTER selected ${selected} cases, expected ${CLOUD_CASE_COUNT}; the interop cases were renamed or moved. Context: rustfs/backlog#2167."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run the three-case minimum
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: |
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
||||
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
||||
|
||||
- name: Build the ${{ matrix.provider }} interop report
|
||||
if: always() && steps.credentials.outputs.present == 'true'
|
||||
uses: ./.github/actions/odm-interop-report
|
||||
with:
|
||||
provider: ${{ matrix.provider }}
|
||||
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
|
||||
junit: target/nextest/e2e-odm-interop/junit.xml
|
||||
output: artifacts/odm-interop/${{ matrix.provider }}/report.json
|
||||
|
||||
- name: Upload the ${{ matrix.provider }} interop report
|
||||
if: always() && steps.credentials.outputs.present == 'true'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: odm-interop-${{ matrix.provider }}-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/odm-interop/${{ matrix.provider }}
|
||||
target/nextest/e2e-odm-interop/junit.xml
|
||||
retention-days: 14
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [minio-source, cloud-source]
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -12,10 +12,10 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Functional chain driver: runs the nine functional suites in a fixed order
|
||||
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security, with
|
||||
# performance on its own runner in parallel) and guarantees the chain keeps
|
||||
# moving even when individual suites fail.
|
||||
# Functional chain driver: runs the ten functional suites in a fixed order
|
||||
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
|
||||
# replication, with performance on its own runner in parallel) and guarantees
|
||||
# the chain keeps moving even when individual suites fail.
|
||||
#
|
||||
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
|
||||
# only chain-triggered runs forward to the next suite via repository_dispatch,
|
||||
|
||||
@@ -152,6 +152,60 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
STEPS_TABLE="/tmp/rustfs-heal-steps.md"
|
||||
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
step_re = re.compile(r'^\[HEAL-STEP\]\s+(\d+)\s+(.+?)\s+(PASS|FAIL|SKIP)\s*$')
|
||||
ver_re = re.compile(r'^\[HEAL-VERSION\]\s+(\S+)(?:\s+\(node\s+(\S+)\))?\s*$')
|
||||
result_re = re.compile(r'^\[HEAL-RESULT\]\s+(PASS|FAIL)\s+(.*)$')
|
||||
|
||||
steps = {}
|
||||
order = []
|
||||
version = None
|
||||
version_node = None
|
||||
verdict = None
|
||||
verdict_detail = ''
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = step_re.match(line)
|
||||
if m:
|
||||
n, desc, status = m.group(1), m.group(2), m.group(3)
|
||||
if n not in steps:
|
||||
order.append(n)
|
||||
steps[n] = (desc, status) # later lines win (fail after pass)
|
||||
continue
|
||||
m = ver_re.match(line)
|
||||
if m:
|
||||
version, version_node = m.group(1), m.group(2)
|
||||
continue
|
||||
m = result_re.match(line)
|
||||
if m:
|
||||
verdict, verdict_detail = m.group(1), m.group(2)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Step Results\n\n')
|
||||
if version:
|
||||
node_note = f' (captured via `rustfs --version` on {version_node})' if version_node else ''
|
||||
out.write(f'- Version under test: **{version}**{node_note}\n')
|
||||
if verdict:
|
||||
out.write(f'- Overall result: **{verdict}** — {verdict_detail}\n')
|
||||
out.write('\n')
|
||||
out.write('| Step | Description | Result |\n')
|
||||
out.write('| --- | --- | --- |\n')
|
||||
for n in sorted(order, key=int):
|
||||
desc, status = steps[n]
|
||||
out.write(f'| {n} | {desc} | {status} |\n')
|
||||
if not order:
|
||||
out.write('| - | - | NOT RUN (no step result lines found) |\n')
|
||||
PY
|
||||
{
|
||||
echo "# RustFS heal test report"
|
||||
echo ""
|
||||
@@ -160,6 +214,8 @@ jobs:
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${STEPS_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
@@ -293,7 +349,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${{GH_TOKEN:-}}" ]; then
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -302,31 +358,31 @@ jobs:
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-pool' \
|
||||
-F 'client_payload[from_suite]=heal'; then
|
||||
echo "dispatched next suite Pool expansion (attempt ${{attempt}})"
|
||||
echo "dispatched next suite Pool expansion (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${{attempt}} failed; retrying in ${{attempt}}0s" >&2
|
||||
sleep "${{attempt}}0"
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${{DISPATCHED:-0}}" -ne 1 ]; then
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Pool expansion after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after heal (run ${{GITHUB_RUN_ID}})"
|
||||
TITLE="[functional][chain] stalled after heal (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **heal** to **Pool expansion** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${{GITHUB_SERVER_URL}}/${{GITHUB_REPOSITORY}}/actions/runs/${{GITHUB_RUN_ID}}"
|
||||
echo "- Expected next event: `rustfs-chain-pool`"
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-pool'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
echo " ```"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-pool'"
|
||||
echo " ```"
|
||||
} > "${{BODY_FILE}}"
|
||||
gh issue create -R rustfs/backlog --title "${{TITLE}}" \
|
||||
--body-file "${{BODY_FILE}}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${{TITLE}}" --body-file "${{BODY_FILE}}" \
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -349,7 +349,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${{GH_TOKEN:-}}" ]; then
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -358,31 +358,31 @@ jobs:
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-tier' \
|
||||
-F 'client_payload[from_suite]=kms'; then
|
||||
echo "dispatched next suite Tier (attempt ${{attempt}})"
|
||||
echo "dispatched next suite Tier (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${{attempt}} failed; retrying in ${{attempt}}0s" >&2
|
||||
sleep "${{attempt}}0"
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${{DISPATCHED:-0}}" -ne 1 ]; then
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Tier after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after kms (run ${{GITHUB_RUN_ID}})"
|
||||
TITLE="[functional][chain] stalled after kms (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **kms** to **Tier** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${{GITHUB_SERVER_URL}}/${{GITHUB_REPOSITORY}}/actions/runs/${{GITHUB_RUN_ID}}"
|
||||
echo "- Expected next event: `rustfs-chain-tier`"
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-tier'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
echo " ```"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-tier'"
|
||||
echo " ```"
|
||||
} > "${{BODY_FILE}}"
|
||||
gh issue create -R rustfs/backlog --title "${{TITLE}}" \
|
||||
--body-file "${{BODY_FILE}}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${{TITLE}}" --body-file "${{BODY_FILE}}" \
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -380,6 +380,60 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
STEPS_TABLE="${POOL_ARTIFACT_DIR}/pool-steps.md"
|
||||
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
step_re = re.compile(r'^\[POOL-STEP\]\s+(\d+)\s+(.+?)\s+(PASS|FAIL|SKIP)\s*$')
|
||||
ver_re = re.compile(r'^\[POOL-VERSION\]\s+(\S+)(?:\s+\(node\s+(\S+)\))?\s*$')
|
||||
result_re = re.compile(r'^\[POOL-RESULT\]\s+(PASS|FAIL)\s+(.*)$')
|
||||
|
||||
steps = {}
|
||||
order = []
|
||||
version = None
|
||||
version_node = None
|
||||
verdict = None
|
||||
verdict_detail = ''
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = step_re.match(line)
|
||||
if m:
|
||||
n, desc, status = m.group(1), m.group(2), m.group(3)
|
||||
if n not in steps:
|
||||
order.append(n)
|
||||
steps[n] = (desc, status) # later lines win (fail after pass)
|
||||
continue
|
||||
m = ver_re.match(line)
|
||||
if m:
|
||||
version, version_node = m.group(1), m.group(2)
|
||||
continue
|
||||
m = result_re.match(line)
|
||||
if m:
|
||||
verdict, verdict_detail = m.group(1), m.group(2)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Step Results\n\n')
|
||||
if version:
|
||||
node_note = f' (captured via `rustfs --version` on {version_node})' if version_node else ''
|
||||
out.write(f'- Version under test: **{version}**{node_note}\n')
|
||||
if verdict:
|
||||
out.write(f'- Overall result: **{verdict}** — {verdict_detail}\n')
|
||||
out.write('\n')
|
||||
out.write('| Step | Description | Result |\n')
|
||||
out.write('| --- | --- | --- |\n')
|
||||
for n in sorted(order, key=int):
|
||||
desc, status = steps[n]
|
||||
out.write(f'| {n} | {desc} | {status} |\n')
|
||||
if not order:
|
||||
out.write('| - | - | NOT RUN (no step result lines found) |\n')
|
||||
PY
|
||||
{
|
||||
echo "# RustFS pool expansion test report"
|
||||
echo ""
|
||||
@@ -389,6 +443,8 @@ jobs:
|
||||
echo "- Warp concurrent: ${{ inputs.warp_concurrent || '32' }}"
|
||||
echo "- Test Step Outcome: ${{ steps.pool_test.outcome }}"
|
||||
echo ""
|
||||
cat "${STEPS_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
@@ -605,7 +661,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${{GH_TOKEN:-}}" ]; then
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -614,31 +670,31 @@ jobs:
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-security' \
|
||||
-F 'client_payload[from_suite]=pool'; then
|
||||
echo "dispatched next suite Security (attempt ${{attempt}})"
|
||||
echo "dispatched next suite Security (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${{attempt}} failed; retrying in ${{attempt}}0s" >&2
|
||||
sleep "${{attempt}}0"
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${{DISPATCHED:-0}}" -ne 1 ]; then
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Security after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after pool (run ${{GITHUB_RUN_ID}})"
|
||||
TITLE="[functional][chain] stalled after pool (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **pool** to **Security** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${{GITHUB_SERVER_URL}}/${{GITHUB_REPOSITORY}}/actions/runs/${{GITHUB_RUN_ID}}"
|
||||
echo "- Expected next event: `rustfs-chain-security`"
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-security'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
echo " ```"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-security'"
|
||||
echo " ```"
|
||||
} > "${{BODY_FILE}}"
|
||||
gh issue create -R rustfs/backlog --title "${{TITLE}}" \
|
||||
--body-file "${{BODY_FILE}}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${{TITLE}}" --body-file "${{BODY_FILE}}" \
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# 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.
|
||||
|
||||
name: RustFS Replication Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||
required: false
|
||||
default: '1.0.0-rc.4-preview.1'
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
suite:
|
||||
description: 'Suite to run (all = bucket REP-* then site SITE-*)'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- bucket
|
||||
- site
|
||||
default: all
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the security suite finishes. This is the
|
||||
# last link of the functional chain.
|
||||
types: [rustfs-chain-replication]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# The replication suite uses the same shared VMs as the other functional
|
||||
# tests, so it must serialize with them instead of running in parallel.
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
jobs:
|
||||
replication-test:
|
||||
runs-on: smoke-testing
|
||||
# A failed replication run must not break the chain or the workflow: the
|
||||
# failure is reported to rustfs/backlog instead (see the issue step).
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
jq --version
|
||||
openssl version
|
||||
aws --version
|
||||
df -h /data | tail -1 || true
|
||||
|
||||
- name: Cleanup environment (before)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2 /var/lib/rustfs/kms
|
||||
'
|
||||
done
|
||||
|
||||
- name: Run replication suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-replication.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-replication-test.sh
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
SUITE='${{ inputs.suite }}'
|
||||
ARGS=(-y --log-file "${LOG_FILE}")
|
||||
if [ "${SUITE}" = "all" ] || [ -z "${SUITE}" ] || [ "${SUITE}" = "null" ]; then
|
||||
ARGS+=(--suite all)
|
||||
else
|
||||
ARGS+=(--suite "${SUITE}")
|
||||
fi
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
|
||||
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||
else
|
||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-replication.log
|
||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
RUSTFS_VERSION_INFO="N/A"
|
||||
if [ "${#NODES[@]}" -gt 0 ]; then
|
||||
DETECTED_VERSION="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||
"${SSH_USER}@${NODES[0]}" 'rustfs --version' 2>/dev/null | tr -d '\r' | head -n 1 || true)"
|
||||
if [ -n "${DETECTED_VERSION}" ]; then
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-replication-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
{
|
||||
echo "# RustFS replication test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
||||
SUITE: replication
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'replication'
|
||||
SUITE_LABEL: 'Replication (bucket + site)'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-replication-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-replication.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-replication-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-replication.log
|
||||
/tmp/rustfs-replication-report.md
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2
|
||||
'
|
||||
done
|
||||
|
||||
- name: Chain complete
|
||||
# Replication is the last link of the functional chain: nothing to
|
||||
# dispatch after it. This step just records that the chain finished.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
run: |
|
||||
echo "Functional chain complete: replication (final suite) finished."
|
||||
echo "from_suite=security trigger=${{ github.event_name }} outcome=${{ steps.test.outcome }}"
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS replication suite failed"
|
||||
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded report and log artifacts for details."
|
||||
@@ -329,7 +329,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${{GH_TOKEN:-}}" ]; then
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -338,31 +338,31 @@ jobs:
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-kms' \
|
||||
-F 'client_payload[from_suite]=s3'; then
|
||||
echo "dispatched next suite KMS (attempt ${{attempt}})"
|
||||
echo "dispatched next suite KMS (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${{attempt}} failed; retrying in ${{attempt}}0s" >&2
|
||||
sleep "${{attempt}}0"
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${{DISPATCHED:-0}}" -ne 1 ]; then
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch KMS after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after s3 (run ${{GITHUB_RUN_ID}})"
|
||||
TITLE="[functional][chain] stalled after s3 (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **s3** to **KMS** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${{GITHUB_SERVER_URL}}/${{GITHUB_REPOSITORY}}/actions/runs/${{GITHUB_RUN_ID}}"
|
||||
echo "- Expected next event: `rustfs-chain-kms`"
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-kms'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
echo " ```"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-kms'"
|
||||
echo " ```"
|
||||
} > "${{BODY_FILE}}"
|
||||
gh issue create -R rustfs/backlog --title "${{TITLE}}" \
|
||||
--body-file "${{BODY_FILE}}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${{TITLE}}" --body-file "${{BODY_FILE}}" \
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -47,7 +47,7 @@ on:
|
||||
type: boolean
|
||||
default: true
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the pool expansion suite finishes (last link).
|
||||
# Chain handoff: dispatched when the pool expansion suite finishes.
|
||||
types: [rustfs-chain-security]
|
||||
|
||||
permissions:
|
||||
@@ -74,10 +74,23 @@ env:
|
||||
jobs:
|
||||
security-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for the OIDC live gate script)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize security evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${SECURITY_ARTIFACTS_DIR}"
|
||||
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -98,11 +111,6 @@ jobs:
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Checkout repository (for the OIDC live gate script)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
@@ -135,7 +143,8 @@ jobs:
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
REPORT_FILE: /tmp/rustfs-security-report.md
|
||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}
|
||||
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/scripts/test/oidc_keycloak_live.sh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -159,29 +168,48 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
./auto-testing/rustfs-security-test.sh "${ARGS[@]}"
|
||||
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
id: report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -f /tmp/rustfs-security-report.md ]; then
|
||||
{
|
||||
echo "# RustFS security test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Test Step Outcome: failure (suite did not produce a report)"
|
||||
} > /tmp/rustfs-security-report.md
|
||||
RESULT=failure
|
||||
if [ "${TEST_OUTCOME}" = "success" ] && [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
cat /tmp/rustfs-security-report.md >> "${GITHUB_STEP_SUMMARY}"
|
||||
{
|
||||
echo "# RustFS security test report"
|
||||
echo ""
|
||||
echo "- Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${TEST_OUTCOME}"
|
||||
echo ""
|
||||
# The dashboard prioritizes case rows over the step outcome.
|
||||
# Keep partial case results in the artifact when the suite fails.
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${SECURITY_ARTIFACTS_DIR}/suite-report.md"
|
||||
elif [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
|
||||
echo "The suite did not complete successfully. See suite-report.md in this run's artifact for diagnostics."
|
||||
else
|
||||
echo "The suite did not produce a non-empty report."
|
||||
fi
|
||||
} > "${SECURITY_ARTIFACTS_DIR}/report.md"
|
||||
cat "${SECURITY_ARTIFACTS_DIR}/report.md" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-security-report.md
|
||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||
SUITE: security
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -210,8 +238,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'security'
|
||||
SUITE_LABEL: 'Security'
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-security-report.md'
|
||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||
LOG_FILE: ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -245,7 +274,7 @@ jobs:
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
@@ -263,14 +292,12 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-security-test-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-security-report.md
|
||||
/tmp/rustfs-security.*/*
|
||||
if-no-files-found: ignore
|
||||
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.SECURITY_ARTIFACTS_DIR }}/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
@@ -292,6 +319,24 @@ jobs:
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: Replication)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: Replication"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-replication' \
|
||||
-F 'client_payload[from_suite]=security'
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${{GH_TOKEN:-}}" ]; then
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -353,31 +353,31 @@ jobs:
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-heal' \
|
||||
-F 'client_payload[from_suite]=storage'; then
|
||||
echo "dispatched next suite Heal (attempt ${{attempt}})"
|
||||
echo "dispatched next suite Heal (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${{attempt}} failed; retrying in ${{attempt}}0s" >&2
|
||||
sleep "${{attempt}}0"
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${{DISPATCHED:-0}}" -ne 1 ]; then
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Heal after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after storage (run ${{GITHUB_RUN_ID}})"
|
||||
TITLE="[functional][chain] stalled after storage (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **storage** to **Heal** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${{GITHUB_SERVER_URL}}/${{GITHUB_REPOSITORY}}/actions/runs/${{GITHUB_RUN_ID}}"
|
||||
echo "- Expected next event: `rustfs-chain-heal`"
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-heal'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
echo " ```"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-heal'"
|
||||
echo " ```"
|
||||
} > "${{BODY_FILE}}"
|
||||
gh issue create -R rustfs/backlog --title "${{TITLE}}" \
|
||||
--body-file "${{BODY_FILE}}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${{TITLE}}" --body-file "${{BODY_FILE}}" \
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -11,9 +11,20 @@ on:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
rc_sha256:
|
||||
description: 'Optional SHA-256 for the preinstalled rc binary; mismatch is an infrastructure failure.'
|
||||
rc_archive_url:
|
||||
description: 'Exact RustFS CLI Linux archive URL.'
|
||||
required: false
|
||||
default: 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz'
|
||||
type: string
|
||||
rc_archive_sha256:
|
||||
description: 'Expected SHA-256 of the RustFS CLI archive.'
|
||||
required: false
|
||||
default: 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df'
|
||||
type: string
|
||||
rc_sha256:
|
||||
description: 'Expected SHA-256 of the extracted RustFS CLI binary.'
|
||||
required: false
|
||||
default: '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df'
|
||||
type: string
|
||||
force_case_failure:
|
||||
description: 'Diagnostic only: rewrite single-single/TIER-101 to FAIL after execution to verify artifact and final-gate behavior.'
|
||||
@@ -41,7 +52,9 @@ env:
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || vars.RUSTFS_TIER_RC_SHA256 }}
|
||||
RUSTFS_RC_ARCHIVE_URL: ${{ inputs.rc_archive_url || 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz' }}
|
||||
RUSTFS_RC_ARCHIVE_SHA256: ${{ inputs.rc_archive_sha256 || 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df' }}
|
||||
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
TIER_ARTIFACTS_DIR: /tmp/rustfs-tier-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
@@ -86,6 +99,111 @@ jobs:
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Prepare pinned RustFS CLI
|
||||
id: rc
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
case "${RUSTFS_RC_ARCHIVE_URL}" in
|
||||
https://*) ;;
|
||||
*)
|
||||
echo "RustFS CLI archive URL must use HTTPS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
EXPECTED_ARCHIVE_SHA256="$(printf '%s' "${RUSTFS_RC_ARCHIVE_SHA256}" | tr '[:upper:]' '[:lower:]')"
|
||||
EXPECTED_RC_SHA256="$(printf '%s' "${RUSTFS_EXPECTED_RC_SHA256}" | tr '[:upper:]' '[:lower:]')"
|
||||
if ! [[ "${EXPECTED_ARCHIVE_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "invalid RustFS CLI archive SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "${EXPECTED_RC_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "invalid RustFS CLI binary SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
RC_ARCHIVE="${RC_ROOT}/rustfs-cli.tar.gz"
|
||||
RC_BIN="${RC_ROOT}/rc"
|
||||
if ! mkdir -- "${RC_ROOT}"; then
|
||||
echo "refusing to reuse RustFS CLI directory: ${RC_ROOT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --fail --location --retry 3 --retry-all-errors \
|
||||
--connect-timeout 15 --max-time 180 \
|
||||
--proto '=https' --proto-redir '=https' \
|
||||
--output "${RC_ARCHIVE}" "${RUSTFS_RC_ARCHIVE_URL}"
|
||||
RC_ARCHIVE_SIZE="$(wc -c < "${RC_ARCHIVE}" | tr -d '[:space:]')"
|
||||
if [ "${RC_ARCHIVE_SIZE}" -eq 0 ] || [ "${RC_ARCHIVE_SIZE}" -gt 33554432 ]; then
|
||||
echo "RustFS CLI archive size is outside the accepted range: ${RC_ARCHIVE_SIZE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! ACTUAL_ARCHIVE_SHA256="$(openssl dgst -sha256 -r "${RC_ARCHIVE}" | awk '{print $1}')"; then
|
||||
echo "failed to calculate RustFS CLI archive SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${ACTUAL_ARCHIVE_SHA256}" != "${EXPECTED_ARCHIVE_SHA256}" ]; then
|
||||
echo "RustFS CLI archive SHA-256 mismatch: expected ${EXPECTED_ARCHIVE_SHA256}, got ${ACTUAL_ARCHIVE_SHA256}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE_MEMBERS="$(tar -tzf "${RC_ARCHIVE}")"
|
||||
if ! grep -Fxq 'rc' <<< "${ARCHIVE_MEMBERS}"; then
|
||||
echo "RustFS CLI archive does not contain the rc entry" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! tar -xOzf "${RC_ARCHIVE}" rc > "${RC_BIN}"; then
|
||||
echo "failed to extract the RustFS CLI binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0700 "${RC_BIN}"
|
||||
if ! ACTUAL_RC_SHA256="$(openssl dgst -sha256 -r "${RC_BIN}" | awk '{print $1}')"; then
|
||||
echo "failed to calculate RustFS CLI binary SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${ACTUAL_RC_SHA256}" != "${EXPECTED_RC_SHA256}" ]; then
|
||||
echo "RustFS CLI binary SHA-256 mismatch: expected ${EXPECTED_RC_SHA256}, got ${ACTUAL_RC_SHA256}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! RC_VERSION_OUTPUT="$(timeout 30 "${RC_BIN}" --version 2>&1)"; then
|
||||
echo "failed to execute the pinned RustFS CLI" >&2
|
||||
exit 1
|
||||
fi
|
||||
RC_VERSION="${RC_VERSION_OUTPUT%%$'\n'*}"
|
||||
if [ -z "${RC_VERSION}" ]; then
|
||||
echo "pinned RustFS CLI returned an empty version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg schema_version '1' \
|
||||
--arg generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg archive_url "${RUSTFS_RC_ARCHIVE_URL}" \
|
||||
--arg archive_sha256 "${ACTUAL_ARCHIVE_SHA256}" \
|
||||
--arg archive_size "${RC_ARCHIVE_SIZE}" \
|
||||
--arg path "${RC_BIN}" \
|
||||
--arg version "${RC_VERSION}" \
|
||||
--arg sha256 "${ACTUAL_RC_SHA256}" \
|
||||
'{
|
||||
schema_version: ($schema_version | tonumber),
|
||||
generated_at: $generated_at,
|
||||
archive: {
|
||||
url: $archive_url,
|
||||
sha256: $archive_sha256,
|
||||
size: ($archive_size | tonumber)
|
||||
},
|
||||
binary: {
|
||||
path: $path,
|
||||
version: $version,
|
||||
sha256: $sha256
|
||||
}
|
||||
}' > "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json"
|
||||
printf 'path=%s\n' "${RC_BIN}" >> "${GITHUB_OUTPUT}"
|
||||
echo "RustFS CLI ready: ${RC_VERSION} (${ACTUAL_RC_SHA256})"
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
@@ -146,12 +264,12 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
|
||||
RC_BIN: ${{ steps.rc.outputs.path }}
|
||||
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
|
||||
chmod +x auto-testing/rustfs-tier-test.sh
|
||||
RC_BIN="$(command -v rc)"
|
||||
PACKAGE_URL="${PACKAGE_URL_INPUT}"
|
||||
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
|
||||
ARGS=(
|
||||
@@ -208,6 +326,11 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
if RC_BOOTSTRAP_SUMMARY="$(jq -r '.binary | "\(.version) / \(.sha256)"' "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json" 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
RC_BOOTSTRAP_SUMMARY="missing or invalid"
|
||||
fi
|
||||
set +e
|
||||
python3 auto-testing/rustfs_tier_report.py \
|
||||
--results-dir "${TIER_ARTIFACTS_DIR}/cases" \
|
||||
@@ -229,6 +352,7 @@ jobs:
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${TRIGGER_NAME}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Client bootstrap: ${RC_BOOTSTRAP_SUMMARY}"
|
||||
echo "- Test Step Outcome: ${TEST_OUTCOME}"
|
||||
echo "- Structured Gate Exit: ${CASE_GATE_RC}"
|
||||
echo ""
|
||||
@@ -279,6 +403,7 @@ jobs:
|
||||
rustfs-tier-report.md \
|
||||
rustfs-tier-cases.md \
|
||||
rustfs-tier-gate.rc \
|
||||
rc-bootstrap.json \
|
||||
provenance.json; do
|
||||
if [ ! -s "${TIER_ARTIFACTS_DIR}/${name}" ]; then
|
||||
echo "required tier evidence is missing or empty: ${name}" >&2
|
||||
@@ -326,6 +451,16 @@ jobs:
|
||||
'
|
||||
done
|
||||
|
||||
- name: Cleanup pinned RustFS CLI
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
rm -f -- "${RC_ROOT}/rustfs-cli.tar.gz" "${RC_ROOT}/rc"
|
||||
if [ -d "${RC_ROOT}" ]; then
|
||||
rmdir -- "${RC_ROOT}"
|
||||
fi
|
||||
|
||||
- name: Enforce tier suite result
|
||||
id: gate
|
||||
if: always()
|
||||
@@ -439,7 +574,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${{GH_TOKEN:-}}" ]; then
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -448,31 +583,31 @@ jobs:
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-storage' \
|
||||
-F 'client_payload[from_suite]=tier'; then
|
||||
echo "dispatched next suite Storage engine (attempt ${{attempt}})"
|
||||
echo "dispatched next suite Storage engine (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${{attempt}} failed; retrying in ${{attempt}}0s" >&2
|
||||
sleep "${{attempt}}0"
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${{DISPATCHED:-0}}" -ne 1 ]; then
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Storage engine after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after tier (run ${{GITHUB_RUN_ID}})"
|
||||
TITLE="[functional][chain] stalled after tier (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **tier** to **Storage engine** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${{GITHUB_SERVER_URL}}/${{GITHUB_REPOSITORY}}/actions/runs/${{GITHUB_RUN_ID}}"
|
||||
echo "- Expected next event: `rustfs-chain-storage`"
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-storage'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
echo " ```"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-storage'"
|
||||
echo " ```"
|
||||
} > "${{BODY_FILE}}"
|
||||
gh issue create -R rustfs/backlog --title "${{TITLE}}" \
|
||||
--body-file "${{BODY_FILE}}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${{TITLE}}" --body-file "${{BODY_FILE}}" \
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -18,15 +18,15 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
from_version:
|
||||
description: 'OLD RustFS release tag (e.g. 1.0.0-rc.4-preview.1)'
|
||||
description: 'OLD RustFS release tag, e.g. 1.0.0-rc.3 (its release must ship a .deb asset). Leave empty for the default.'
|
||||
required: false
|
||||
default: '1.0.0-rc.4-preview.1'
|
||||
default: '1.0.0-rc.3'
|
||||
from_url:
|
||||
description: 'OLD .deb URL. Overrides from_version.'
|
||||
required: false
|
||||
type: string
|
||||
to_version:
|
||||
description: 'NEW RustFS release tag (leave empty for latest nightly)'
|
||||
description: 'NEW RustFS release tag, e.g. 1.0.0-rc.5 (any version with a .deb asset). Leave empty for latest nightly.'
|
||||
required: false
|
||||
to_url:
|
||||
description: 'NEW .deb URL. Overrides to_version / nightly default.'
|
||||
@@ -145,6 +145,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-upgrade-test.sh
|
||||
@@ -175,6 +176,29 @@ jobs:
|
||||
else
|
||||
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
# Fail fast with a clear message when a requested release tag has
|
||||
# no .deb asset (e.g. 1.0.0-rc.4 ships only zips), instead of
|
||||
# letting the suite die mid-run on a 404.
|
||||
check_release_asset() {
|
||||
local version="$1" tag asset url
|
||||
[ -n "${version}" ] && [ "${version}" != "null" ] || return 0
|
||||
tag="${version#v}"
|
||||
asset="rustfs_${tag//-/.}_amd64.deb"
|
||||
url="https://github.com/rustfs/rustfs/releases/download/${tag}/${asset}"
|
||||
if ! gh api "repos/rustfs/rustfs/releases/tags/${tag}" --jq '.assets[].name' 2>/dev/null | grep -qxF "${asset}"; then
|
||||
echo "ERROR: release ${tag} has no downloadable asset ${asset}:" >&2
|
||||
echo " ${url}" >&2
|
||||
echo "Pick a tag whose release ships a .deb (check its release assets)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "resolved ${tag} -> ${url}"
|
||||
}
|
||||
if [ -z "${FROM_URL}" ]; then
|
||||
check_release_asset "${FROM_VERSION}"
|
||||
fi
|
||||
if [ -z "${TO_URL}" ]; then
|
||||
check_release_asset "${TO_VERSION}"
|
||||
fi
|
||||
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
@@ -203,54 +227,75 @@ jobs:
|
||||
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-upgrade-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
MATRIX_TABLE="/tmp/rustfs-upgrade-matrix.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
log_file, out_file, matrix_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
|
||||
topo_re = re.compile(
|
||||
r'^\[UPG-TOPO\]\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+PASS=(\d+)\s+FAIL=(\d+)\s*$')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
topo_rows = []
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = topo_re.match(line)
|
||||
if m:
|
||||
topo_rows.append(m.groups())
|
||||
continue
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
|
||||
# Upgrade matrix: one row per topology/backend with the versions
|
||||
# captured on the nodes (rustfs --version) and the aggregated
|
||||
# result. The dashboard renders this table directly.
|
||||
with open(matrix_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Upgrade Matrix\n\n')
|
||||
out.write('| Topology | KMS Backend | From Version | To Version | Result |\n')
|
||||
out.write('| --- | --- | --- | --- | --- |\n')
|
||||
for topo, backend, old_v, new_v, npass, nfail in topo_rows:
|
||||
result = 'PASS' if nfail == '0' else 'FAIL'
|
||||
out.write(f'| {topo} | {backend} | {old_v} | {new_v} | {result} (PASS={npass} FAIL={nfail}) |\n')
|
||||
if not topo_rows:
|
||||
out.write('| - | - | - | - | NOT RUN (suite failed before upgrade) |\n')
|
||||
PY
|
||||
{
|
||||
echo "# RustFS upgrade compatibility report"
|
||||
@@ -261,6 +306,8 @@ jobs:
|
||||
echo "- To: ${TO_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${MATRIX_TABLE}" || true
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
@@ -397,7 +444,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${{GH_TOKEN:-}}" ]; then
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -406,31 +453,31 @@ jobs:
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-s3' \
|
||||
-F 'client_payload[from_suite]=upgrade'; then
|
||||
echo "dispatched next suite S3 compatibility (attempt ${{attempt}})"
|
||||
echo "dispatched next suite S3 compatibility (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${{attempt}} failed; retrying in ${{attempt}}0s" >&2
|
||||
sleep "${{attempt}}0"
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${{DISPATCHED:-0}}" -ne 1 ]; then
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch S3 compatibility after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after upgrade (run ${{GITHUB_RUN_ID}})"
|
||||
TITLE="[functional][chain] stalled after upgrade (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **upgrade** to **S3 compatibility** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${{GITHUB_SERVER_URL}}/${{GITHUB_REPOSITORY}}/actions/runs/${{GITHUB_RUN_ID}}"
|
||||
echo "- Expected next event: `rustfs-chain-s3`"
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-s3'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
echo " ```"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-s3'"
|
||||
echo " ```"
|
||||
} > "${{BODY_FILE}}"
|
||||
gh issue create -R rustfs/backlog --title "${{TITLE}}" \
|
||||
--body-file "${{BODY_FILE}}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${{TITLE}}" --body-file "${{BODY_FILE}}" \
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
- name: Check latest scheduled runs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RUSTFS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set +e
|
||||
python3 scripts/check_scheduled_validation_freshness.py \
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
|
||||
- uses: overtrue/repo-visuals-action@fd79cba437ecfac933d00a69add17eb95d3939c3 # v1.3.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
output-branch: star-history
|
||||
|
||||
+5
-3
@@ -33,6 +33,7 @@ profile.json
|
||||
*.zst
|
||||
.secrets
|
||||
*.go
|
||||
!crates/zip/tests/fixtures/snowball/**/generate/*.go
|
||||
*.pb
|
||||
*.svg
|
||||
deploy/logs/*.log.*
|
||||
@@ -55,11 +56,10 @@ docs/*
|
||||
!docs/architecture/**
|
||||
!docs/operations/
|
||||
!docs/operations/**
|
||||
!docs/postmortems/
|
||||
!docs/postmortems/**
|
||||
!docs/testing/
|
||||
!docs/testing/**
|
||||
docs/heal-scanner-logging-governance.md
|
||||
docs/benchmark/rustfs-target-bench/
|
||||
docs/benchmark/*.md
|
||||
.codegraph/*
|
||||
.docker/test/compat/data/*
|
||||
.docker/test/compat/kms/*
|
||||
@@ -83,6 +83,8 @@ worktrees/*
|
||||
|
||||
# Local AI-agent review artifacts (omo evidence dumps)
|
||||
.omo/
|
||||
# Legacy per-tool skill dir; skills live in .agents/skills (shared by all agents)
|
||||
.mimocode/
|
||||
|
||||
# insta scratch files; the accepted .snap files ARE the assertions and are committed
|
||||
*.snap.new
|
||||
|
||||
@@ -86,6 +86,7 @@ This file contains repository-wide rules. Use the nearest subdirectory
|
||||
- CI gates: `.github/workflows/ci.yml`.
|
||||
- PR format: `.github/pull_request_template.md`.
|
||||
- Architecture routing: `ARCHITECTURE.md` and `docs/architecture/README.md`.
|
||||
- Knowledge-base index and documentation rules: `docs/architecture/README.md`.
|
||||
- Agent skills: `.agents/skills/*/SKILL.md`.
|
||||
|
||||
Do not commit one-shot plans, trackers, migration ledgers, benchmark snapshots,
|
||||
@@ -161,6 +162,12 @@ Risk and review shape:
|
||||
S3-visible semantics. Cover all applicable lenses using exactly two
|
||||
independent reviewers when delegation is explicitly authorized. Split the
|
||||
lenses between them. Otherwise perform two fresh sequential passes.
|
||||
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
|
||||
the remote SDK configuration sends to every replication or migration target
|
||||
is high risk for every target class even when the change fixes one. Follow
|
||||
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
|
||||
run the outbound target matrix, document each new env knob in the same PR,
|
||||
and list verified and unverified target classes in the PR Impact section.
|
||||
|
||||
Available domain lenses are security, concurrency/durability, compatibility,
|
||||
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
||||
|
||||
+3
-3
@@ -62,7 +62,7 @@ rustfs/ # Workspace root (virtual manifest)
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ ├── ... # (see "Crate Reference" below)
|
||||
│ └── e2e_test/ # End-to-end integration tests
|
||||
└── docs/ # Design documents and analysis
|
||||
└── docs/ # Agent knowledge base: contracts, runbooks, testing rules (index: docs/architecture/README.md)
|
||||
```
|
||||
|
||||
### Main Crate Layers (`rustfs/src/`)
|
||||
@@ -92,7 +92,7 @@ refactors.
|
||||
|
||||
| Domain | Current workspace crates | Responsibility |
|
||||
|--------|--------------------------|----------------|
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. |
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-metrics`, `utils` | Shared configuration, data-usage models, heal domain contracts, scanner telemetry types, utilities, and checksums. |
|
||||
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
|
||||
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
|
||||
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
|
||||
@@ -135,7 +135,7 @@ default build (lifecycle:
|
||||
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
|
||||
collision, not copies; renaming is tracked in rustfs/backlog#1847.
|
||||
- `LastMinuteLatency` has two deliberately different implementations: the
|
||||
per-second bucketed accumulator in `crates/scanner-contracts/src/last_minute.rs` and
|
||||
per-second bucketed accumulator in `crates/scanner-metrics/src/last_minute.rs` and
|
||||
the in-memory endpoint-health sample tracker in
|
||||
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
|
||||
why it stays local).
|
||||
|
||||
@@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||
|
||||
### Added
|
||||
- **On-Demand Migration**: Lazy, pull-style migration of an existing S3-compatible bucket into RustFS. A local bucket is attached to an external source bucket; a GET for a key that does not exist locally fetches it from the source, streams it to the client, and stores it locally in the same pass, so every later read is served locally. The module is on by default; set `RUSTFS_ON_DEMAND_MIGRATION_ENABLED=false` on every node to turn it off. A bucket with no source configured behaves exactly as before — the runtime never intervenes on its reads and makes no outbound call. Operator guide at `docs/operations/on-demand-migration.md`.
|
||||
- Per-bucket configuration persisted as `on-demand-migration.json` in the bucket metadata: source provider (`s3`, `aws`, `minio`, `rustfs`, `r2`, `gcs`), endpoint, region, addressing style, credentials and TLS material, an optional key-prefix filter and source-prefix rewrite, and a policy block covering the inline size threshold, multipart part size, concurrency, queue capacity, timeouts, bandwidth limit and negative-cache TTL
|
||||
- Admin routes under `/rustfs/admin/v3/on-demand-migration/{bucket}`: `PUT` (with `?dry-run=true` to validate and probe the source without saving), `GET`, `DELETE`, `GET .../status`, plus `POST .../backfill?op=start|cancel` and `GET .../backfill` for the background full-backfill job with its resumable checkpoint. Authorized by the new `admin:GetBucketOnDemandMigration` and `admin:SetBucketOnDemandMigration` actions; every response redacts `secret_key` and `session_token`
|
||||
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
|
||||
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
|
||||
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
|
||||
- Limitations: listings show only local objects (the source is not merged into `ListObjectsV2`); PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
|
||||
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
|
||||
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
|
||||
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
|
||||
|
||||
@@ -27,6 +27,7 @@ make build-docker BUILD_OS=ubuntu22.04
|
||||
|
||||
## Where to look (do not duplicate here)
|
||||
|
||||
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
|
||||
- Crate membership: `Cargo.toml` `[workspace].members`
|
||||
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
|
||||
|
||||
Generated
+473
-251
File diff suppressed because it is too large
Load Diff
+23
-19
@@ -52,7 +52,7 @@ members = [
|
||||
"crates/s3select-api", # S3 Select API interface
|
||||
"crates/s3select-query", # S3 Select query engine
|
||||
"crates/scanner", # Scanner for data integrity checks and health monitoring
|
||||
"crates/scanner-contracts", # Scanner metrics and cycle contracts
|
||||
"crates/scanner-metrics", # Scanner metrics and cycle telemetry
|
||||
"crates/security-governance", # Security governance contracts
|
||||
"crates/extension-schema", # Extension schema contracts
|
||||
"crates/signer", # client signer
|
||||
@@ -93,7 +93,7 @@ redundant_clone = "warn"
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.5" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.5" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.5" }
|
||||
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.5" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.0-rc.5" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.5" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.5" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.5" }
|
||||
@@ -145,7 +145,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
|
||||
async-channel = "2.5.0"
|
||||
async_zip = { default-features = false, version = "0.0.19" }
|
||||
mysql_async = { default-features = false, version = "0.37.1" }
|
||||
async-compression = { version = "0.4.43" }
|
||||
async-compression = { version = "0.4.44" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.92"
|
||||
async-nats = { version = "0.50.0", default-features = false }
|
||||
@@ -165,10 +165,10 @@ http-body = "1.1.0"
|
||||
http-body-util = "0.1.5"
|
||||
minlz = "1.2.3"
|
||||
reqwest = "0.13.4"
|
||||
rustfs-kafka-async = { version = "1.2.0" }
|
||||
rustfs-kafka-async = { version = "1.3.1" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.53.1" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.4" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.5" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.5"
|
||||
tokio-util = { version = "0.7.19" }
|
||||
@@ -234,20 +234,24 @@ tokio-postgres-rustls = "0.14.0"
|
||||
# Utilities and Tools
|
||||
anyhow = "1.0.104"
|
||||
arc-swap = "1.9.2"
|
||||
# RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin until every parser hardening used by Snowball is released upstream. Remove after astral-sh/tokio-tar#118 is merged and a published release includes extension, physical-entry, and sparse limits, cancellation-safe sparse parsing, and error-fused entry streams.
|
||||
# RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin while Snowball and Swift still depend on it. Remove after Snowball uses a released tar-codec/tar-framing API that exposes precedence-resolved MinIO vendor records, RustFS preserves cancellation-safe ownership of large streamed members, footerless minio-go input is accepted only at an authenticated complete request boundary, the existing resource-limit, cancellation, and error-fuse regressions pass, and Swift no longer needs this fork.
|
||||
astral-tokio-tar = { git = "https://github.com/cxymds/tokio-tar.git", rev = "603756478b7668436e464519c77ccac22a99ba96" }
|
||||
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
|
||||
tar-codec = "0.0.14"
|
||||
tar-framing = "0.0.14"
|
||||
atoi = "3.1.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.11.0" }
|
||||
aws-config = { version = "1.12.0" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.117.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.113.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.118.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.145.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.114.0" }
|
||||
aws-smithy-async = { version = "1.3.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
||||
aws-smithy-runtime-api = { version = "1.15.0" }
|
||||
aws-smithy-types = { version = "1.6.2" }
|
||||
aws-smithy-runtime-api = { version = "1.16.0" }
|
||||
aws-smithy-types = { version = "1.6.3" }
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
brotli = "9.0.0"
|
||||
clap = { version = "4.6.6" }
|
||||
const-str = { version = "1.1.0" }
|
||||
convert_case = "0.12.0"
|
||||
@@ -298,7 +302,7 @@ pretty_assertions = "1.4.1"
|
||||
rand = { version = "0.10.2" }
|
||||
ratelimit = "2.0.0"
|
||||
rayon = "1.12.0"
|
||||
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
|
||||
rustfs-erasure-codec = { version = "8.0.2" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||
@@ -307,11 +311,11 @@ rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.4" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "28e9ebb23dd2fb7d667084f34121b4aa4807a5c6", version = "0.15.0", features = ["minio"] }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "bdcb6259339c41369f9f1c60e3a42b5ab8da607b", version = "0.15.0", features = ["minio"] }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.3"
|
||||
smallvec = { version = "1.15.2" }
|
||||
smallvec = { version = "1.16.0" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.3.0" }
|
||||
@@ -359,15 +363,15 @@ libunftp = { version = "0.23.0" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "11.0.0" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.63.1" }
|
||||
russh = { version = "0.63.2" }
|
||||
russh-sftp = "2.4.0"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.5.1" }
|
||||
hotpath = { version = "0.24.0", default-features = false }
|
||||
rustfs-mimalloc = { version = "0.5.3" }
|
||||
hotpath = { version = "0.25.0", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
|
||||
@@ -48,16 +48,33 @@ Unlike other storage systems, RustFS is released under the permissible Apache 2.
|
||||
- **Open Source**: Licensed under Apache 2.0, encouraging unrestricted community contributions and commercial usage.
|
||||
- **User-Friendly**: Designed with simplicity in mind for easy deployment and management.
|
||||
|
||||
| Feature | Status | Feature | Status |
|
||||
| :---------------------- | :----------- | :----------------------- | :--------------- |
|
||||
| **S3 Core Features** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
||||
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
||||
| **Versioning** | ✅ Available | **Bucket Replication** | ✅ Available |
|
||||
| **Logging** | ✅ Available | **Lifecycle Management** | 🚧 Under Testing |
|
||||
| **Event Notifications** | ✅ Available | **Distributed Mode** | 🚧 Under Testing |
|
||||
| **K8s Helm Charts** | ✅ Available | **RustFS KMS** | 🚧 Under Testing |
|
||||
| **Keystone Auth** | ✅ Available | **Multi-Tenancy** | ✅ Available |
|
||||
| **Swift API** | ✅ Available | **Swift Metadata Ops** | 🚧 Partial |
|
||||
Status legend: ✅ Available — shipped and covered by CI gates; 🧪 Preview — shipped behind an opt-in flag or with a bounded compatibility claim.
|
||||
|
||||
| Feature | Status | Feature | Status |
|
||||
| :------------------------------- | :----------- | :--------------------------------- | :----------- |
|
||||
| **S3 Core Features** | ✅ Available | **Distributed Mode** | ✅ Available |
|
||||
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
||||
| **Versioning** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
||||
| **Object Lock (WORM)** | ✅ Available | **Healing & Scanner** | ✅ Available |
|
||||
| **Server-Side Encryption** | ✅ Available | **Pool Expansion / Decommission** | ✅ Available |
|
||||
| **RustFS KMS** | ✅ Available | **Bucket Replication** | ✅ Available |
|
||||
| **Lifecycle Management (ILM)** | ✅ Available | **Site Replication** | ✅ Available |
|
||||
| **ILM Tiering (Remote S3)** | ✅ Available | **Bucket Quota** | ✅ Available |
|
||||
| **S3 Select** | ✅ Available | **Event Notifications** | ✅ Available |
|
||||
| **S3 Tables (Iceberg REST)** | 🧪 Preview | **Audit Logging** | ✅ Available |
|
||||
| **IAM / Policies** | ✅ Available | **Logging & Observability** | ✅ Available |
|
||||
| **OIDC / SSO** | ✅ Available | **Web Console** | ✅ Available |
|
||||
| **Keystone Auth** | ✅ Available | **K8s Helm Charts** | ✅ Available |
|
||||
| **Swift API** | ✅ Available | **FTPS / WebDAV** | ✅ Available |
|
||||
| **Multi-Tenancy** | ✅ Available | **SFTP** | ✅ Available |
|
||||
| **MinIO On-Disk Compatibility** | 🧪 Preview | | |
|
||||
|
||||
Notes:
|
||||
|
||||
- **RustFS KMS**: Vault (KV2 / Transit) and AWS KMS backends are supported for production. The `Local` and `Static` backends are for development and testing only. See [KMS backend security properties](docs/operations/kms-backend-security.md).
|
||||
- **Swift API / SFTP**: opt-in cargo features (`--features swift`, `--features sftp`, or `full`). FTPS and WebDAV are enabled in the default build.
|
||||
- **S3 Tables**: ships as an Iceberg REST Catalog with automated PyIceberg and DuckDB coverage; other engines and vendor profiles carry bounded claims listed in the [S3 Tables support matrix](docs/architecture/s3-tables-support-matrix.md).
|
||||
- **MinIO On-Disk Compatibility**: gated behind the `rio-v2` feature and not part of the default build. Objects MinIO encrypted are not readable by RustFS. See [MinIO file-format interoperability](docs/architecture/minio-file-format-compat.md).
|
||||
|
||||
## RustFS vs MinIO Performance
|
||||
|
||||
|
||||
@@ -197,6 +197,27 @@ pub const DEFAULT_POOL_META_V3_FLEET_CONFIRMED: bool = false;
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V3_WRITE);
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V3_FLEET_CONFIRMED);
|
||||
|
||||
/// Maximum unpacked size accepted for one Snowball archive member.
|
||||
///
|
||||
/// The value is expressed in bytes. Invalid values use the default, while
|
||||
/// valid values are clamped to [`MAX_SNOWBALL_ENTRY_BYTES`].
|
||||
pub const ENV_SNOWBALL_MAX_ENTRY_BYTES: &str = "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES";
|
||||
pub const DEFAULT_SNOWBALL_MAX_ENTRY_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
pub const MAX_SNOWBALL_ENTRY_BYTES: u64 = 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
|
||||
|
||||
/// Maximum cumulative unpacked object bytes accepted from one Snowball
|
||||
/// archive request.
|
||||
///
|
||||
/// This does not include tar headers or bounded PAX metadata. The value is
|
||||
/// expressed in bytes and is clamped to
|
||||
/// [`MAX_SNOWBALL_UNPACKED_BYTES`].
|
||||
pub const ENV_SNOWBALL_MAX_UNPACKED_BYTES: &str = "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES";
|
||||
pub const DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES: u64 = 10 * 1024 * 1024 * 1024;
|
||||
pub const MAX_SNOWBALL_UNPACKED_BYTES: u64 = 10 * 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
|
||||
|
||||
const _: () = assert!(DEFAULT_SNOWBALL_MAX_ENTRY_BYTES <= MAX_SNOWBALL_ENTRY_BYTES);
|
||||
const _: () = assert!(DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES <= MAX_SNOWBALL_UNPACKED_BYTES);
|
||||
|
||||
// =============================================================================
|
||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||
// =============================================================================
|
||||
@@ -820,4 +841,10 @@ mod remote_version_state_tests {
|
||||
assert_eq!(super::ENV_POOL_META_V3_WRITE, "RUSTFS_POOL_META_V3_WRITE");
|
||||
assert_eq!(super::ENV_POOL_META_V3_FLEET_CONFIRMED, "RUSTFS_POOL_META_V3_FLEET_CONFIRMED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowball_limit_environment_names_are_stable() {
|
||||
assert_eq!(super::ENV_SNOWBALL_MAX_ENTRY_BYTES, "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES");
|
||||
assert_eq!(super::ENV_SNOWBALL_MAX_UNPACKED_BYTES, "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES");
|
||||
}
|
||||
}
|
||||
|
||||
+41
-51
@@ -1,7 +1,7 @@
|
||||
# e2e_test
|
||||
|
||||
End-to-end test suite for RustFS. Each test spawns a **real `rustfs` binary**
|
||||
(built on demand from the workspace) and drives it over the network with the
|
||||
(built and identified before the test invocation) and drives it over the network with the
|
||||
AWS SDK (`aws-sdk-s3`), raw HTTP (`reqwest` / `awscurl`), or a protocol client
|
||||
(FTPS / WebDAV / SFTP). This is the black-box integration layer: exhaustive
|
||||
end-to-end behavior lives here, unit behavior stays in the source crates
|
||||
@@ -25,38 +25,34 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
||||
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
|
||||
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
|
||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||
|
||||
## How to run
|
||||
|
||||
All commands assume repo root. `cargo test` triggers an on-demand build of the
|
||||
`rustfs` binary from [`src/common.rs`](src/common.rs) (`rustfs_binary_path`) on
|
||||
first use — the first invocation is slow, later ones reuse the binary.
|
||||
All commands assume repo root and Python 3.9 or newer on Linux or macOS. Build the server once through the provenance entry point, then run the test command through the same script:
|
||||
|
||||
```bash
|
||||
# Whole crate (default = ignored tests skipped)
|
||||
cargo nextest run -p e2e_test
|
||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||
|
||||
# Whole crate (ignored tests remain skipped)
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test
|
||||
|
||||
# One module
|
||||
cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
||||
|
||||
# PR smoke subset (see "CI smoke subset" below)
|
||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
|
||||
# ILM serial lane — ignored lifecycle tests, single-threaded (mirrors CI)
|
||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
||||
|
||||
# PR smoke subset
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
```
|
||||
|
||||
The protocols suite has its own contract (fixed bind ports 9022–9301,
|
||||
single-worker execution, feature-gated scheduling) documented in
|
||||
[`src/protocols/README.md`](src/protocols/README.md). `RUSTFS_BUILD_FEATURES`
|
||||
selects which features the spawned binary is built with; leave it unset to run
|
||||
every protocol entry. Use the exact profile command under
|
||||
[Troubleshooting](#troubleshooting) for CI-equivalent execution.
|
||||
`build` records the source contents, HEAD, resolved Cargo features, profile, toolchain, and binary SHA-256 beside the executable in `rustfs.e2e.json`. `run` validates that identity before and after the command, preserves command failures, and removes its temporary run receipt on completion. The Rust harness checks that receipt before starting each server; it never compiles a server inside a test process. Source or binary changes during a run invalidate the result, even when the test command succeeds. Use an isolated worktree and keep it unchanged until the command finishes.
|
||||
|
||||
The additional `--features` arguments must match between `build` and `run`; Cargo defaults remain enabled. The wrapper supplies `RUSTFS_BUILD_FEATURES` from Cargo's resolved feature list, including features enabled by `full`. Protocol helpers require a subset of that list. `CARGO_TARGET_DIR` and `--profile release` are supported. An in-workspace target directory must be Git-ignored; tracked files are always included in the source identity. `build --bins` preserves CI lanes that compile all RustFS binary targets. For a downloaded artifact, copy both the executable and its sidecar, then use `run`; do not generate a new identity for an arbitrary prebuilt binary. `CARGO_BIN_EXE_rustfs` cannot override the verified executable.
|
||||
|
||||
Each build/run holds an exclusive `rustfs.e2e.lock` marker beside the binary; concurrent wrappers fail immediately. Use a private target directory and do not run ordinary Cargo builds against it while tests are active: Cargo does not honor this marker. Interrupted runs fail and terminate their command group. After an uncatchable kill, inspect the PID recorded in a leftover marker and remove it only after confirming its owner has stopped. Embedded file symlinks are hashed through their target; embedded directory symlinks are rejected because their contents cannot be enumerated safely by this entry point.
|
||||
|
||||
The protocols suite has its own fixed-port and single-worker contract in [`src/protocols/README.md`](src/protocols/README.md). Use its command under [Troubleshooting](#troubleshooting).
|
||||
|
||||
### `#[ignore]` semantics
|
||||
|
||||
@@ -122,7 +118,7 @@ via `create_s3_client(idx)` / `create_all_clients()`. See
|
||||
| `wait_for_server_ready` | Poll readiness before issuing requests |
|
||||
| `create_s3_client` / `create_test_bucket` / `delete_test_bucket` | aws-sdk-s3 client + bucket lifecycle |
|
||||
| `find_available_port` | Random free port (isolation primitive) |
|
||||
| `rustfs_binary_path` / `_with_features` | Locate/build the binary; honors `RUSTFS_BUILD_FEATURES` |
|
||||
| `rustfs_binary_path` / `_with_features` | Verify this run's binary receipt and required feature subset |
|
||||
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
|
||||
| `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl`; missing binaries are test failures |
|
||||
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
|
||||
@@ -185,32 +181,26 @@ the wiring source of truth. Committed test-ID digests under
|
||||
**Reproduce a CI failure locally** — run the exact profile/lane:
|
||||
|
||||
```bash
|
||||
# Smoke (e2e-tests job) — includes the 20 fast replication tests
|
||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
# Full single-node merge/main lane
|
||||
cargo nextest run --profile e2e-full -p e2e_test
|
||||
# Cluster fault nightly lane
|
||||
cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
# Replication nightly lane; awscurl is required for STS paths
|
||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
# Fixed-port protocol nightly lane
|
||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp \
|
||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||
# ILM serial lane
|
||||
# Smoke, full, and cluster lanes share a server with fault-test hooks.
|
||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
|
||||
# Replication nightly uses the default server; awscurl is required for STS.
|
||||
python3 scripts/e2e_binary.py build
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
|
||||
# Protocol nightly owns fixed ports.
|
||||
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
||||
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||
|
||||
# The ILM serial lane does not use this server harness.
|
||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||
# s3s-e2e black box
|
||||
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs-e2e-data
|
||||
```
|
||||
|
||||
**Stale binary.** Tests build the `rustfs` binary once and reuse it. To avoid
|
||||
rebuilding while iterating on tests, `common.rs` reuses an existing binary when
|
||||
running *inside* the e2e test process even if sources changed
|
||||
(`can_reuse_inside_e2e`, [`src/common.rs`](src/common.rs) line 98). Downside: if
|
||||
you changed **server** code, force a rebuild with
|
||||
`cargo build -p rustfs` (or `touch` a source file outside the reuse window)
|
||||
before re-running, or CI's freshly built artifact will diverge from your local
|
||||
one.
|
||||
**Stale or unverified binary.** Re-run the matching `build` command after changing source or features, then invoke tests through `run`. A missing receipt, copied old executable, or mismatched build identity is a prerequisite failure. Bare Cargo invocations that start a server deliberately fail; unit tests that do not start a server can still run directly.
|
||||
|
||||
**Port already in use / orphan processes.** A hard-killed run can leak a
|
||||
`rustfs` child holding its port. Find and kill it:
|
||||
@@ -233,8 +223,8 @@ spawn error. Install the pinned CI version before running their profiles.
|
||||
[`src/policy/README.md`](src/policy/README.md),
|
||||
[`src/protocols/README.md`](src/protocols/README.md),
|
||||
[`src/reliant/README.md`](src/reliant/README.md)
|
||||
- Authoritative per-module counts:
|
||||
[`docs/testing/e2e-suite-inventory.md`](../../docs/testing/e2e-suite-inventory.md)
|
||||
- Per-module counts: `cargo nextest list -p e2e_test --profile <profile>`
|
||||
(one-liner in [`docs/testing/README.md`](../../docs/testing/README.md))
|
||||
- Test pyramid & flake policy: [`docs/testing/README.md`](../../docs/testing/README.md)
|
||||
|
||||
## CI smoke subset (`--profile e2e-smoke`)
|
||||
@@ -271,12 +261,12 @@ Note on `#[serial]`: nextest runs each test in its own process, so
|
||||
parallel-safe by construction (random port + isolated temp dir), which the
|
||||
current subset is.
|
||||
|
||||
### Authoritative test inventory
|
||||
### Test inventory
|
||||
|
||||
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
|
||||
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
|
||||
moving e2e tests so acceptance numbers in the test-strategy issues
|
||||
(backlog#1147–#1155) stay auditable. When a profile membership change is
|
||||
Per-module counts are not committed; list them with
|
||||
`cargo nextest list -p e2e_test --profile <profile>` (the result is
|
||||
platform-dependent because some modules are linux-only; the `jq` one-liner is
|
||||
in `docs/testing/README.md`). When a profile membership change is
|
||||
intentional, review its JSON listing before updating the matching
|
||||
`.config/e2e-*-selection.txt` test-ID digest. Update only the platform that
|
||||
produced the listing:
|
||||
|
||||
@@ -22,7 +22,10 @@ mod tests {
|
||||
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ServerSideEncryption};
|
||||
use aws_sdk_s3::types::{
|
||||
ChecksumAlgorithm, ChecksumMode, ChecksumType as SdkChecksumType, CompletedMultipartUpload, CompletedPart,
|
||||
ServerSideEncryption,
|
||||
};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||
@@ -596,6 +599,222 @@ mod tests {
|
||||
Some(full_checksum.as_str()),
|
||||
"Multipart object should report the same full-object CRC64NVME as direct upload"
|
||||
);
|
||||
assert_eq!(
|
||||
multipart_head.checksum_type(),
|
||||
Some(&SdkChecksumType::FullObject),
|
||||
"Multipart object with a full-object checksum must report FULL_OBJECT"
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a CRC32 FULL_OBJECT multipart upload and upload every part, returning
|
||||
/// the upload id and the `CompletedPart` list ready for CompleteMultipartUpload.
|
||||
async fn start_full_object_crc32_upload(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
parts: &[&Vec<u8>],
|
||||
) -> (String, Vec<CompletedPart>) {
|
||||
let create_result = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.checksum_type(SdkChecksumType::FullObject)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
|
||||
|
||||
let mut completed_parts = Vec::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let part_number = index as i32 + 1;
|
||||
let uploaded = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from((*part).clone()))
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Failed to upload part {part_number}: {e:?}"));
|
||||
completed_parts.push(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.e_tag(uploaded.e_tag().expect("No etag for part"))
|
||||
.checksum_crc32(uploaded.checksum_crc32().expect("No CRC32 for part"))
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
(upload_id, completed_parts)
|
||||
}
|
||||
|
||||
/// A multipart upload completed with a **full-object** checksum must report
|
||||
/// `x-amz-checksum-type: FULL_OBJECT` on both GET and HEAD, the way AWS does.
|
||||
///
|
||||
/// `complete_multipart_upload` used to persist the object-level checksum
|
||||
/// record with the pre-merge checksum type, so the MULTIPART /
|
||||
/// INCLUDES_MULTIPART flags never reached disk. `rustfs_rio::read_checksums`
|
||||
/// only emits the FULL_OBJECT entry inside its MULTIPART branch, so these
|
||||
/// objects came back from GET and HEAD with no checksum-type header at all.
|
||||
/// Found while root-causing rustfs#6825.
|
||||
#[tokio::test]
|
||||
async fn test_full_object_multipart_reports_full_object_checksum_type() {
|
||||
init_logging();
|
||||
info!("TEST: full-object multipart upload round-trips x-amz-checksum-type: FULL_OBJECT");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-full-object-checksum-type";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 241) as u8).collect();
|
||||
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 29) % 241) as u8).collect();
|
||||
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
||||
|
||||
// CRC32 with an explicit FULL_OBJECT type: the object checksum is the
|
||||
// CRC32 of the whole object, not the composite hash of the part digests.
|
||||
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
|
||||
.expect("crc32 checksum")
|
||||
.encoded;
|
||||
|
||||
let key = "full-object-multipart.bin";
|
||||
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
// Restate the full-object intent and value on CompleteMultipartUpload,
|
||||
// exactly as an AWS SDK client does: `x-amz-checksum-type: FULL_OBJECT`
|
||||
// plus `x-amz-checksum-crc32`, with no `x-amz-checksum-algorithm`
|
||||
// header (CompleteMultipartUpload has no such member).
|
||||
.checksum_type(SdkChecksumType::FullObject)
|
||||
.checksum_crc32(full_object_crc32.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to complete multipart upload");
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to head object");
|
||||
assert_eq!(
|
||||
head.checksum_type(),
|
||||
Some(&SdkChecksumType::FullObject),
|
||||
"HeadObject must report x-amz-checksum-type: FULL_OBJECT"
|
||||
);
|
||||
assert_eq!(
|
||||
head.checksum_crc32(),
|
||||
Some(full_object_crc32.as_str()),
|
||||
"HeadObject must report the full-object CRC32, with no -<parts> suffix"
|
||||
);
|
||||
|
||||
let get = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to get object");
|
||||
assert_eq!(
|
||||
get.checksum_type(),
|
||||
Some(&SdkChecksumType::FullObject),
|
||||
"GetObject must report x-amz-checksum-type: FULL_OBJECT"
|
||||
);
|
||||
assert_eq!(
|
||||
get.checksum_crc32(),
|
||||
Some(full_object_crc32.as_str()),
|
||||
"GetObject must report the full-object CRC32, with no -<parts> suffix"
|
||||
);
|
||||
|
||||
let body = get.body.collect().await.expect("Failed to read body").into_bytes();
|
||||
assert_eq!(body.as_ref(), content.as_slice(), "GetObject body must match the uploaded content");
|
||||
|
||||
info!("PASSED: full-object multipart reports FULL_OBJECT on GET and HEAD");
|
||||
}
|
||||
|
||||
/// Declaring a checksum type on CompleteMultipartUpload that contradicts the
|
||||
/// one recorded at CreateMultipartUpload must be rejected, and rejected as a
|
||||
/// client error (4xx), not a server error.
|
||||
#[tokio::test]
|
||||
async fn test_complete_multipart_rejects_contradicting_checksum_type() {
|
||||
init_logging();
|
||||
info!("TEST: CompleteMultipartUpload rejects a checksum type that contradicts the upload");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-checksum-type-mismatch";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 239) as u8).collect();
|
||||
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 31) % 239) as u8).collect();
|
||||
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
||||
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
|
||||
.expect("crc32 checksum")
|
||||
.encoded;
|
||||
|
||||
let key = "checksum-type-mismatch.bin";
|
||||
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
|
||||
|
||||
let err = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
// The upload was created as FULL_OBJECT; claiming COMPOSITE here
|
||||
// contradicts it.
|
||||
.checksum_type(SdkChecksumType::Composite)
|
||||
.checksum_crc32(full_object_crc32.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect_err("COMPOSITE on a FULL_OBJECT upload must be rejected");
|
||||
|
||||
let service_err = err.into_service_error();
|
||||
let code = service_err.meta().code().unwrap_or("<no code>").to_string();
|
||||
let message = service_err.meta().message().unwrap_or_default().to_string();
|
||||
|
||||
// Before the fix the storage layer refused the combination with a generic
|
||||
// error and the caller got `500 InternalError` -- "please try again" for a
|
||||
// request that can only ever fail.
|
||||
assert_eq!(
|
||||
code, "InvalidRequest",
|
||||
"a contradicting checksum type is a client error, got {code}: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("FULL_OBJECT") && message.contains("COMPOSITE"),
|
||||
"the message must name the recorded and requested types, got {message}"
|
||||
);
|
||||
|
||||
// The upload is untouched by the rejected completion, so a well-formed
|
||||
// retry on the same upload id still succeeds.
|
||||
let listed = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("upload must survive the rejected completion");
|
||||
assert_eq!(listed.parts().len(), 2, "both parts must still be listed after the rejection");
|
||||
|
||||
info!("PASSED: contradicting checksum type rejected as InvalidRequest");
|
||||
}
|
||||
|
||||
/// Integration test for the AWS 2026-04 additional checksum algorithms
|
||||
|
||||
+123
-149
@@ -31,7 +31,6 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::sign_v4;
|
||||
use s3s::Body;
|
||||
use serde_json;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs as stdfs;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::SocketAddr;
|
||||
@@ -44,7 +43,6 @@ use tokio::net::TcpStream;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// Common constants for all E2E tests
|
||||
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||
@@ -365,59 +363,75 @@ fn resolve_rustfs_binary_path(workspace: &Path, configured_target_dir: Option<&P
|
||||
path
|
||||
}
|
||||
|
||||
/// Resolve the RustFS binary relative to the workspace, optionally requesting build features.
|
||||
/// Resolve the server verified by `scripts/e2e_binary.py run` for this test invocation.
|
||||
/// Requested features are a required subset of the server's resolved Cargo features.
|
||||
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rustfs") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
let requested_features = requested_features.and_then(normalize_rustfs_build_features);
|
||||
|
||||
let workspace = workspace_root();
|
||||
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
||||
let binary_path = resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref());
|
||||
let binary_path = std::env::var_os("CARGO_BIN_EXE_rustfs")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref()));
|
||||
let receipt_path = std::env::var_os("RUSTFS_E2E_BINARY_RECEIPT").map(PathBuf::from);
|
||||
receipt_path
|
||||
.ok_or_else(|| std::io::Error::new(ErrorKind::NotFound, "missing E2E run receipt"))
|
||||
.and_then(|receipt| verify_e2e_binary_receipt(&receipt, &workspace, &binary_path, requested_features))
|
||||
.unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"E2E server prerequisite failed: {error}. Build with `python3 scripts/e2e_binary.py build --features <features>` and run tests with `python3 scripts/e2e_binary.py run --features <features> -- cargo nextest run ...`"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
let features_match = binary_features_match(&binary_path, requested_features.as_deref());
|
||||
let source_is_newer = workspace_sources_newer_than_binary(&binary_path);
|
||||
let can_reuse_inside_e2e = running_inside_e2e_test_binary() && requested_features.is_none() && features_match;
|
||||
if binary_path.is_file() && features_match && (!source_is_newer || can_reuse_inside_e2e) {
|
||||
if source_is_newer {
|
||||
warn!(
|
||||
"RustFS binary at {:?} appears older than workspace sources; reusing it inside cargo test to avoid nested builds",
|
||||
binary_path
|
||||
);
|
||||
}
|
||||
info!("Using existing RustFS binary at {:?}", binary_path);
|
||||
return binary_path;
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct E2eBinaryReceipt {
|
||||
schema: u32,
|
||||
workspace: PathBuf,
|
||||
binary: PathBuf,
|
||||
size: u64,
|
||||
modified_ns: u128,
|
||||
features: Vec<String>,
|
||||
}
|
||||
|
||||
fn verify_e2e_binary_receipt(
|
||||
receipt_path: &Path,
|
||||
workspace: &Path,
|
||||
binary_path: &Path,
|
||||
requested_features: Option<&str>,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let receipt: E2eBinaryReceipt = serde_json::from_slice(&stdfs::read(receipt_path)?)?;
|
||||
let binary = binary_path.canonicalize()?;
|
||||
let metadata = binary.metadata()?;
|
||||
let modified_ns = metadata
|
||||
.modified()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(std::io::Error::other)?
|
||||
.as_nanos();
|
||||
// The runner hashes source and binary before/after the entire suite. Each
|
||||
// nextest process checks only this invocation's path, features, and file stat.
|
||||
if receipt.schema != 1
|
||||
|| receipt.workspace != workspace.canonicalize()?
|
||||
|| receipt.binary != binary
|
||||
|| !metadata.is_file()
|
||||
|| receipt.size != metadata.len()
|
||||
|| receipt.modified_ns != modified_ns
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
"E2E server differs from this run's verified binary",
|
||||
));
|
||||
}
|
||||
|
||||
info!("Building RustFS binary to ensure it's up to date...");
|
||||
build_rustfs_binary(requested_features.as_deref(), &binary_path);
|
||||
|
||||
info!("Using RustFS binary at {:?}", binary_path);
|
||||
binary_path
|
||||
}
|
||||
|
||||
fn workspace_sources_newer_than_binary(binary_path: &PathBuf) -> bool {
|
||||
let Ok(binary_meta) = std::fs::metadata(binary_path) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(binary_modified) = binary_meta.modified() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let workspace = workspace_root();
|
||||
let watch_roots = [
|
||||
workspace.join("Cargo.toml"),
|
||||
workspace.join("Cargo.lock"),
|
||||
workspace.join("rustfs"),
|
||||
workspace.join("crates"),
|
||||
];
|
||||
|
||||
watch_roots.iter().any(|path| path_is_newer_than(binary_modified, path))
|
||||
}
|
||||
|
||||
fn running_inside_e2e_test_binary() -> bool {
|
||||
std::env::var("CARGO_PKG_NAME").is_ok_and(|value| value == "e2e_test")
|
||||
if let Some(requested) = requested_features.and_then(normalize_rustfs_build_features)
|
||||
&& requested
|
||||
.split(',')
|
||||
.any(|feature| !receipt.features.iter().any(|actual| actual == feature))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"E2E server is missing a requested build feature",
|
||||
));
|
||||
}
|
||||
Ok(binary)
|
||||
}
|
||||
|
||||
pub fn requested_rustfs_build_features() -> Option<String> {
|
||||
@@ -447,96 +461,6 @@ pub fn rustfs_build_feature_enabled(requested_features: Option<&str>, required_f
|
||||
.any(|feature| feature.eq_ignore_ascii_case(RUSTFS_FULL_FEATURE) || feature.eq_ignore_ascii_case(required_feature))
|
||||
}
|
||||
|
||||
fn rustfs_binary_features_stamp_path(binary_path: &Path) -> PathBuf {
|
||||
binary_path.with_extension("features")
|
||||
}
|
||||
|
||||
fn binary_features_match(binary_path: &Path, requested_features: Option<&str>) -> bool {
|
||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
||||
let recorded = stdfs::read_to_string(stamp_path)
|
||||
.ok()
|
||||
.and_then(|value| normalize_rustfs_build_features(&value));
|
||||
let requested = requested_features.and_then(normalize_rustfs_build_features);
|
||||
|
||||
match requested.as_deref() {
|
||||
Some(features) => recorded.as_deref() == Some(features),
|
||||
None => recorded.is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_is_newer_than(binary_modified: std::time::SystemTime, path: &Path) -> bool {
|
||||
if path.is_file() {
|
||||
return std::fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.map(|modified| modified > binary_modified)
|
||||
.unwrap_or(false);
|
||||
}
|
||||
|
||||
if !path.is_dir() {
|
||||
return false;
|
||||
}
|
||||
|
||||
WalkDir::new(path)
|
||||
.into_iter()
|
||||
.filter_entry(|entry| {
|
||||
let name = entry.file_name();
|
||||
name != OsStr::new("target") && name != OsStr::new(".git")
|
||||
})
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| entry.file_type().is_file())
|
||||
.any(|entry| {
|
||||
std::fs::metadata(entry.path())
|
||||
.and_then(|meta| meta.modified())
|
||||
.map(|modified| modified > binary_modified)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the RustFS binary using cargo
|
||||
fn build_rustfs_binary(requested_features: Option<&str>, binary_path: &Path) {
|
||||
let workspace = workspace_root();
|
||||
info!("Building RustFS binary from workspace: {:?}", workspace);
|
||||
|
||||
let _profile = if cfg!(debug_assertions) {
|
||||
info!("Building in debug mode");
|
||||
"dev"
|
||||
} else {
|
||||
info!("Building in release mode");
|
||||
"release"
|
||||
};
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(&workspace).args(["build", "--bin", "rustfs"]);
|
||||
|
||||
if let Some(features) = requested_features {
|
||||
cmd.arg("--features").arg(features);
|
||||
info!("Building with features: {}", features);
|
||||
}
|
||||
|
||||
if !cfg!(debug_assertions) {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
|
||||
info!(
|
||||
"Executing: cargo build --bin rustfs {}",
|
||||
if cfg!(debug_assertions) { "" } else { "--release" }
|
||||
);
|
||||
|
||||
let output = cmd.output().expect("Failed to execute cargo build command");
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
panic!("Failed to build RustFS binary. Error: {stderr}");
|
||||
}
|
||||
|
||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
||||
if let Err(err) = stdfs::write(&stamp_path, requested_features.unwrap_or_default()) {
|
||||
warn!("Failed to write RustFS feature stamp {:?}: {}", stamp_path, err);
|
||||
}
|
||||
|
||||
info!("✅ RustFS binary built successfully");
|
||||
}
|
||||
|
||||
fn awscurl_binary_path() -> PathBuf {
|
||||
std::env::var_os("AWSCURL_PATH")
|
||||
.map(PathBuf::from)
|
||||
@@ -2073,16 +1997,66 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_feature_stamp_matching_uses_normalized_features() {
|
||||
let binary_path = std::env::temp_dir().join(format!("rustfs-feature-stamp-test-{}", Uuid::new_v4()));
|
||||
let stamp_path = rustfs_binary_features_stamp_path(&binary_path);
|
||||
fn explicit_binary_without_run_receipt_is_rejected() {
|
||||
const CHILD_ENV: &str = "RUSTFS_E2E_RECEIPT_TEST_CHILD";
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
rustfs_binary_path_with_features(None);
|
||||
return;
|
||||
}
|
||||
let executable = std::env::current_exe().expect("locate isolated test process");
|
||||
let output = Command::new(&executable)
|
||||
.args([
|
||||
"--exact",
|
||||
"common::tests::explicit_binary_without_run_receipt_is_rejected",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.env("CARGO_BIN_EXE_rustfs", &executable)
|
||||
.env_remove("RUSTFS_E2E_BINARY_RECEIPT")
|
||||
.output()
|
||||
.expect("run the missing-receipt scenario with isolated environment variables");
|
||||
assert!(!output.status.success(), "an explicit binary must not bypass run verification");
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("missing E2E run receipt"));
|
||||
}
|
||||
|
||||
stdfs::write(&stamp_path, " SFTP, ftps ").expect("write feature stamp");
|
||||
assert!(binary_features_match(&binary_path, Some("sftp,ftps")));
|
||||
assert!(binary_features_match(&binary_path, Some(" SFTP, FTPS ")));
|
||||
assert!(!binary_features_match(&binary_path, Some("sftp")));
|
||||
|
||||
stdfs::remove_file(stamp_path).ok();
|
||||
#[test]
|
||||
fn e2e_run_receipt_rejects_replaced_binary_and_missing_features() {
|
||||
let directory = std::env::temp_dir().join(format!("rustfs-e2e-receipt-test-{}", Uuid::new_v4()));
|
||||
stdfs::create_dir(&directory).expect("create receipt fixture");
|
||||
let binary = directory.join("rustfs");
|
||||
let receipt = directory.join("receipt.json");
|
||||
stdfs::write(&binary, "server").expect("write fixture binary");
|
||||
let metadata = binary.metadata().expect("stat fixture binary");
|
||||
let record = serde_json::json!({
|
||||
"schema": 1,
|
||||
"workspace": directory.canonicalize().expect("canonical workspace"),
|
||||
"binary": binary.canonicalize().expect("canonical binary"),
|
||||
"size": metadata.len(),
|
||||
"modified_ns": metadata.modified().expect("modified time").duration_since(std::time::UNIX_EPOCH).expect("positive timestamp").as_nanos(),
|
||||
"features": ["default", "full", "ftps", "webdav", "sftp"]
|
||||
});
|
||||
stdfs::write(&receipt, serde_json::to_vec(&record).expect("serialize receipt")).expect("write receipt");
|
||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("sftp,webdav")).expect("resolved feature subset");
|
||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("full")).expect("full was actually requested");
|
||||
assert_eq!(
|
||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("rio-v2"))
|
||||
.expect_err("full does not enable rio-v2")
|
||||
.kind(),
|
||||
ErrorKind::InvalidInput
|
||||
);
|
||||
let other = directory.join("old-server");
|
||||
stdfs::write(&other, "server").expect("write alternate binary");
|
||||
assert!(verify_e2e_binary_receipt(&receipt, &directory, &other, None).is_err());
|
||||
stdfs::write(&binary, "different server").expect("replace fixture binary");
|
||||
assert!(verify_e2e_binary_receipt(&receipt, &directory, &binary, None).is_err());
|
||||
stdfs::remove_file(&receipt).expect("remove expired receipt");
|
||||
assert_eq!(
|
||||
verify_e2e_binary_receipt(&receipt, &directory, &binary, None)
|
||||
.expect_err("expired receipt")
|
||||
.kind(),
|
||||
ErrorKind::NotFound
|
||||
);
|
||||
stdfs::remove_dir_all(directory).expect("remove receipt fixture");
|
||||
}
|
||||
|
||||
/// Build a cluster environment struct in-memory (no ports, no processes) so
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
# Programmable fake S3 target
|
||||
|
||||
This module is the shared failure-injection boundary for replication end-to-end tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
|
||||
This module is the shared failure-injection boundary for replication end-to-end tests and the programmable external source for on-demand-migration (ODM) tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
|
||||
|
||||
`FakeS3Target::start()` creates the listener. Add target buckets with `create_bucket`, point a RustFS remote target at `address()`, use `FAKE_ACCESS_KEY` / `FAKE_SECRET_KEY`, then enqueue per-operation faults with `inject`. Faults for one operation are consumed in FIFO order and do not consume faults queued for another operation. A fault is consumed only after `s3s` verifies the full request signature, so anonymous, other-access-key, and bad-signature traffic cannot disturb a script.
|
||||
|
||||
Supported data operations are HeadBucket, GetBucketVersioning, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
|
||||
Supported data operations are HeadBucket, GetBucketVersioning, ListObjectsV2, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets created with `create_bucket` are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
|
||||
|
||||
Fault actions cover HTTP 401/403/503 responses, pre-dispatch delay, connection abort when a logical request-body threshold is reached, streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions. Each record also journals a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
|
||||
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Three switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
|
||||
|
||||
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type at 1 KiB. A PUT or uploaded part is capped at 64 MiB; a completed multipart object and all stored object/part data are capped at 128 MiB. Body drain, body-permit waits, delay, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
|
||||
`create_bucket_with_mode(name, BucketMode::Unversioned)` models a plain migration source: PUT overwrites in place, DELETE removes the key without a delete marker, GetBucketVersioning reports no status, and no `x-amz-version-id` is returned by PUT, GET, HEAD, tagging, or multipart completion. The only `versionId` such a bucket accepts is `null`; any other value is rejected with `InvalidArgument`. The mode is fixed at creation.
|
||||
|
||||
ListObjectsV2 lists current versions only (a key whose newest version is a delete marker is hidden) in byte order and supports `prefix`, `delimiter`, `max-keys` (clamped to 1000), `start-after`, and `continuation-token`; common prefixes count toward `max-keys`, `IsTruncated` / `NextContinuationToken` / `KeyCount` follow S3, and continuation tokens are opaque. `encoding-type` and `fetch-owner` are accepted but ignored, and ListObjects (v1) is not implemented. GET and HEAD honor `Range` in the `bytes=first-last`, `bytes=first-`, and `bytes=-suffix` forms with a 206 status, exact `Content-Range`, and `Accept-Ranges: bytes`; unsatisfiable ranges answer 416 `InvalidRange` with `Content-Range: bytes */<length>`. PUT and CreateMultipartUpload accept `Content-Type`, `Content-Encoding`, `Content-Disposition`, `Content-Language`, `Cache-Control`, `Expires`, and `x-amz-meta-*` (names stored lowercased), and HEAD/GET replay them verbatim together with `Last-Modified` and the ETag (hex MD5 for single PUTs, `<md5-of-part-md5s>-<parts>` for multipart objects). `put_seed_object` stores an object directly, bypassing the wire, the fault script, and the journal, so a source can be seeded without polluting the assertions a scenario later makes.
|
||||
|
||||
Fault actions cover HTTP 401/403/503 responses (`Status`), any 4xx/5xx status paired with the matching S3 error code (`ResponseStatus`), pre-dispatch delay, holding a fully computed successful response before its first byte (`Stall`), connection abort when a logical request-body threshold is reached, GetObject bodies cut off after N bytes while `Content-Length` announces the full size (`TruncateBodyAt`), GetObject bodies delivered in fixed slices with a pause between them (`SlowSendBody`, a mid-body stall rather than a first-byte one), streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions and `count_requests(operation, key)` counts entries for one exact key. Each record journals the `Range` and `User-Agent` request headers, the ListObjectsV2 `prefix` and `continuation-token` query values, a `TransportSnapshot` — whether the body was announced as `aws-chunked`, the verbatim `Content-MD5`, the sorted `x-amz-checksum-*` / `x-amz-sdk-checksum-algorithm` header names, and whether any `x-amz-object-lock-*` header was present — and a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
|
||||
|
||||
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type and each standard object header at 1 KiB. By default a PUT or uploaded part is capped at 64 MiB and a completed multipart object and all stored object/part data are capped at 128 MiB; `FakeS3Target::start_with_options(FakeS3TargetOptions { max_object_bytes })` raises the object cap up to 256 MiB, and the total budget then becomes twice the object cap (never below 128 MiB). Body drain, body-permit waits, delay, stall, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,15 +17,115 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
|
||||
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging};
|
||||
use crate::common::{
|
||||
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
|
||||
};
|
||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use http::Method;
|
||||
use std::collections::HashSet;
|
||||
use std::error::Error;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{Duration, Instant, sleep, timeout};
|
||||
use tracing::info;
|
||||
|
||||
const POOL_METADATA_OBJECT: &str = "pool.bin";
|
||||
|
||||
struct TcpPortBlackhole {
|
||||
port: u16,
|
||||
comment: String,
|
||||
use_sudo: bool,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl TcpPortBlackhole {
|
||||
fn install(address: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
let address = address.parse::<SocketAddr>()?;
|
||||
if !address.ip().is_loopback() {
|
||||
return Err(format!("refusing to install a test firewall rule for non-loopback address {address}").into());
|
||||
}
|
||||
|
||||
let id = Command::new("id").arg("-u").output()?;
|
||||
if !id.status.success() {
|
||||
return Err(format!("failed to determine the test process uid: {}", String::from_utf8_lossy(&id.stderr)).into());
|
||||
}
|
||||
let use_sudo = String::from_utf8_lossy(&id.stdout).trim() != "0";
|
||||
let mut blackhole = Self {
|
||||
port: address.port(),
|
||||
comment: format!("rustfs-e2e-{}", uuid::Uuid::new_v4()),
|
||||
use_sudo,
|
||||
active: false,
|
||||
};
|
||||
blackhole.run_iptables(true)?;
|
||||
blackhole.active = true;
|
||||
Ok(blackhole)
|
||||
}
|
||||
|
||||
fn restore(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if !self.active {
|
||||
return Ok(());
|
||||
}
|
||||
self.run_iptables(false)?;
|
||||
self.active = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_iptables(&self, insert: bool) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut command = if self.use_sudo {
|
||||
let mut command = Command::new("sudo");
|
||||
command.args(["-n", "iptables"]);
|
||||
command
|
||||
} else {
|
||||
Command::new("iptables")
|
||||
};
|
||||
command.args(["-w", "5"]);
|
||||
if insert {
|
||||
command.args(["-I", "OUTPUT", "1"]);
|
||||
} else {
|
||||
command.args(["-D", "OUTPUT"]);
|
||||
}
|
||||
let port = self.port.to_string();
|
||||
let output = command
|
||||
.args([
|
||||
"-p",
|
||||
"tcp",
|
||||
"-d",
|
||||
"127.0.0.1/32",
|
||||
"--dport",
|
||||
&port,
|
||||
"-m",
|
||||
"comment",
|
||||
"--comment",
|
||||
&self.comment,
|
||||
"-j",
|
||||
"DROP",
|
||||
])
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let action = if insert { "install" } else { "remove" };
|
||||
return Err(format!(
|
||||
"failed to {action} endpoint blackhole rule for port {}: stdout={}, stderr={}",
|
||||
self.port,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TcpPortBlackhole {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = self.restore() {
|
||||
eprintln!("failed to remove {} firewall rule during test cleanup: {error}", self.comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_file_under(path: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
return false;
|
||||
@@ -91,6 +191,62 @@ mod tests {
|
||||
.count()
|
||||
}
|
||||
|
||||
async fn assert_all_nodes_list_exact_keys(
|
||||
clients: &[aws_sdk_s3::Client],
|
||||
bucket: &str,
|
||||
expected_keys: &HashSet<String>,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
const PAGE_SIZE: i32 = 10;
|
||||
for (node_index, client) in clients.iter().enumerate() {
|
||||
let mut listed_keys = Vec::new();
|
||||
let mut continuation_token = None;
|
||||
let max_pages = expected_keys.len().div_ceil(PAGE_SIZE as usize) + 1;
|
||||
let mut page_count = 0;
|
||||
loop {
|
||||
page_count += 1;
|
||||
if page_count > max_pages {
|
||||
return Err(format!("node {node_index} listing exceeded the bounded {max_pages}-page budget").into());
|
||||
}
|
||||
let response = timeout(
|
||||
Duration::from_secs(15),
|
||||
client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(PAGE_SIZE)
|
||||
.set_continuation_token(continuation_token.clone())
|
||||
.send(),
|
||||
)
|
||||
.await??;
|
||||
listed_keys.extend(
|
||||
response
|
||||
.contents()
|
||||
.iter()
|
||||
.filter_map(|object| object.key().map(str::to_owned)),
|
||||
);
|
||||
if !response.is_truncated().unwrap_or(false) {
|
||||
break;
|
||||
}
|
||||
let next_token = response
|
||||
.next_continuation_token()
|
||||
.filter(|token| Some(*token) != continuation_token.as_deref())
|
||||
.ok_or_else(|| format!("node {node_index} returned a truncated listing without a new continuation token"))?;
|
||||
continuation_token = Some(next_token.to_owned());
|
||||
}
|
||||
|
||||
let listed_key_set = listed_keys.iter().cloned().collect::<HashSet<_>>();
|
||||
assert_eq!(
|
||||
listed_keys.len(),
|
||||
listed_key_set.len(),
|
||||
"node {node_index} returned duplicate keys after recovery: {listed_keys:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
&listed_key_set, expected_keys,
|
||||
"node {node_index} did not expose the complete recovered namespace"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn heal_task_status_diagnostic(body: &str) -> String {
|
||||
let Ok(status) = serde_json::from_str::<serde_json::Value>(body) else {
|
||||
return body.to_string();
|
||||
@@ -125,11 +281,12 @@ mod tests {
|
||||
&& operations["retryingTasks"].as_u64() == Some(0)
|
||||
}
|
||||
|
||||
// Queued low-priority repairs cannot execute while the single admin slot is
|
||||
// occupied; ownership is determined by active and retrying tasks only.
|
||||
fn only_admin_heal_is_active(status: &serde_json::Value) -> bool {
|
||||
let operations = &status["healOperations"];
|
||||
status["clusterStatusComplete"] == serde_json::Value::Bool(true)
|
||||
&& status["state"].as_str() == Some("active")
|
||||
&& operations["queueLength"].as_u64() == Some(0)
|
||||
&& operations["activeTasks"].as_u64() == Some(1)
|
||||
&& operations["retryingTasks"].as_u64() == Some(0)
|
||||
&& operations["activeBySource"]["admin"].as_u64() == Some(1)
|
||||
@@ -547,41 +704,156 @@ mod tests {
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn wait_for_scanner_cycle_after(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
previous_cycle_end: u64,
|
||||
) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let mut latest_cycle_end = 0;
|
||||
let mut versions_observed = false;
|
||||
let mut observations = Vec::with_capacity(cluster.nodes.len());
|
||||
for (node_index, node) in cluster.nodes.iter().enumerate() {
|
||||
let (status, body) = timeout(
|
||||
Duration::from_secs(5),
|
||||
admin_request(
|
||||
&node.url,
|
||||
Method::GET,
|
||||
"/rustfs/admin/v3/scanner/status",
|
||||
None,
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(status, 200, "scanner status must be available: {body}");
|
||||
let status: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(status["enabled"].as_bool(), Some(true), "scanner must stay enabled: {status}");
|
||||
let metrics = &status["metrics"];
|
||||
let cycle_end = metrics["last_cycle_end_unix_secs"]
|
||||
.as_u64()
|
||||
.ok_or("scanner status is missing its completed-cycle timestamp")?;
|
||||
let versions_scanned = metrics["versions_scanned"]
|
||||
.as_u64()
|
||||
.ok_or("scanner status is missing its version-coverage counter")?;
|
||||
latest_cycle_end = latest_cycle_end.max(cycle_end);
|
||||
versions_observed |= versions_scanned > 0;
|
||||
observations.push(format!(
|
||||
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
|
||||
metrics["current_cycle"],
|
||||
metrics["current_cycle_active"],
|
||||
metrics["leader_lock_state"],
|
||||
metrics["last_cycle_result"],
|
||||
));
|
||||
}
|
||||
// The coordinator records cycle completion, but remote workers
|
||||
// record scanned versions. Both witnesses need not share a node.
|
||||
if latest_cycle_end > previous_cycle_end && versions_observed {
|
||||
return Ok(latest_cycle_end);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the original unformatted-disk scenario above. This case retains the
|
||||
// format identity so only the explicit admin task can rebuild missing data.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_cluster_root_heal_resumes_missing_remote_shards_after_node_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::IsolatedTargetRestart).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
timeout(
|
||||
Duration::from_secs(420),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
timeout(
|
||||
Duration::from_secs(420),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_cluster_root_heal_recovers_after_target_endpoint_blackhole() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
timeout(
|
||||
Duration::from_secs(420),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::TargetEndpointBlackhole),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum InterruptionScenario {
|
||||
IsolatedTargetRestart,
|
||||
BackgroundTargetRestart,
|
||||
BackgroundCoordinatorRestart,
|
||||
TargetEndpointBlackhole,
|
||||
}
|
||||
|
||||
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let (background_enabled, interruption_node, interruption_kind) = match scenario {
|
||||
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
|
||||
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
|
||||
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
|
||||
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
|
||||
};
|
||||
init_logging();
|
||||
info!(
|
||||
event = "heal_restart_started",
|
||||
event = "heal_interruption_started",
|
||||
component = "e2e_test",
|
||||
subsystem = "heal",
|
||||
"Starting root-heal restart test"
|
||||
background_enabled,
|
||||
interruption_node,
|
||||
interruption_kind,
|
||||
"Starting root-heal interruption test"
|
||||
);
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
|
||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
|
||||
cluster.set_env("RUSTFS_HEAL_AUTO_HEAL_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_HEAL_MRF_ENABLE", "false");
|
||||
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
|
||||
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_HEALS", "1");
|
||||
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_PER_SET", "1");
|
||||
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
|
||||
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
|
||||
// Keep all storage nodes' Heal runtimes enabled so their disk services
|
||||
// complete normal registration after restart. Scanner, auto-heal and
|
||||
// MRF are disabled; the pre-root idle barrier below drains the direct
|
||||
// outage-object repair before the explicit admin task starts.
|
||||
// Heal control uses the first lexicographically sorted grid host.
|
||||
// Keep that coordinator distinct from the remote target at index 1.
|
||||
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
|
||||
cluster.set_env("RUSTFS_HEAL_AUTO_HEAL_ENABLE", background_enabled.to_string());
|
||||
cluster.set_env("RUSTFS_HEAL_MRF_ENABLE", background_enabled.to_string());
|
||||
cluster.set_env("RUSTFS_SCANNER_ENABLED", background_enabled.to_string());
|
||||
if background_enabled {
|
||||
// Only the scanner cadence is accelerated. Keep normal Heal
|
||||
// concurrency and every automatic recovery owner enabled.
|
||||
for &(key, value) in FAST_DATA_USAGE_SCANNER_ENV {
|
||||
cluster.set_env(key, value);
|
||||
}
|
||||
} else {
|
||||
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_HEALS", "1");
|
||||
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_PER_SET", "1");
|
||||
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
|
||||
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
|
||||
}
|
||||
// Keep every node's Heal runtime enabled for normal disk registration.
|
||||
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
|
||||
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
|
||||
cluster.set_env("RUST_LOG", server_rust_log);
|
||||
if let Ok(log_dir) = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR") {
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
for node_index in 0..cluster.nodes.len() {
|
||||
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
|
||||
}
|
||||
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
for node_index in 0..cluster.nodes.len() {
|
||||
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
|
||||
}
|
||||
cluster.start().await?;
|
||||
let clients = cluster.create_all_clients()?;
|
||||
@@ -634,6 +906,18 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
let expected_pool_metadata = if background_enabled {
|
||||
let census = census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||
assert!(
|
||||
census.is_complete(),
|
||||
"target must hold complete pool metadata before the fault: {census:?}"
|
||||
);
|
||||
wait_for_scanner_cycle_after(&cluster, 0).await?;
|
||||
Some(census)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
cluster.stop_node(1)?;
|
||||
std::fs::remove_dir_all(&replaced_disk)?;
|
||||
std::fs::create_dir_all(
|
||||
@@ -691,25 +975,25 @@ mod tests {
|
||||
.find(|index| !outage_peer_erasure_indices.contains(index))
|
||||
.ok_or("online outage-object shards leave no erasure index for the replacement target")?;
|
||||
|
||||
// The PUT path may have admitted a direct Internal object repair while
|
||||
// node 1 was offline. Cancel the isolated bucket path before the target
|
||||
// returns; otherwise it could rebuild the outage object and invalidate
|
||||
// the explicit-root ownership assertion below.
|
||||
let cancel_outage_heal_path = format!("/rustfs/admin/v3/heal/{bucket}?forceStop=true");
|
||||
let (cancel_status, cancel_body) = admin_request(
|
||||
&cluster.nodes[0].url,
|
||||
Method::POST,
|
||||
&cancel_outage_heal_path,
|
||||
Some(
|
||||
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#
|
||||
.to_string(),
|
||||
),
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
if !cancel_status.is_success() {
|
||||
return Err(format!("cancel outage heal failed: {cancel_status} {cancel_body}").into());
|
||||
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||
if !background_enabled {
|
||||
// The PUT path may have admitted a direct Internal object repair while
|
||||
// node 1 was offline. Cancel the isolated bucket path before the target
|
||||
// returns; otherwise it could rebuild the outage object and invalidate
|
||||
// the explicit-root ownership assertion below.
|
||||
let cancel_outage_heal_path = format!("/rustfs/admin/v3/heal/{bucket}?forceStop=true");
|
||||
let (cancel_status, cancel_body) = admin_request(
|
||||
&cluster.nodes[0].url,
|
||||
Method::POST,
|
||||
&cancel_outage_heal_path,
|
||||
Some(heal_body.to_string()),
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
if !cancel_status.is_success() {
|
||||
return Err(format!("cancel outage heal failed: {cancel_status} {cancel_body}").into());
|
||||
}
|
||||
}
|
||||
|
||||
cluster.start_node(1).await?;
|
||||
@@ -724,7 +1008,12 @@ mod tests {
|
||||
);
|
||||
let recovered: serde_json::Value = serde_json::from_str(&status_body)
|
||||
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
|
||||
if cluster_heal_is_idle(&recovered) {
|
||||
let ready = if background_enabled {
|
||||
recovered["clusterStatusComplete"] == serde_json::Value::Bool(true)
|
||||
} else {
|
||||
cluster_heal_is_idle(&recovered)
|
||||
};
|
||||
if ready {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= recovery_deadline {
|
||||
@@ -732,23 +1021,24 @@ mod tests {
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?,
|
||||
0,
|
||||
"non-admin Heal is disabled, so the replacement target must remain empty before the explicit root heal"
|
||||
);
|
||||
assert!(
|
||||
!census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?.has_xl_meta,
|
||||
"the object written during the outage must be absent before the explicit root heal"
|
||||
);
|
||||
let pre_heal_replacement = replacement_recovery_status(&cluster).await?;
|
||||
assert_eq!(
|
||||
pre_heal_replacement["cluster"]["records"].as_array().map(Vec::len),
|
||||
Some(0),
|
||||
"isolated target must not retain an automatic replacement generation: {pre_heal_replacement}"
|
||||
);
|
||||
if !background_enabled {
|
||||
assert_eq!(
|
||||
matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?,
|
||||
0,
|
||||
"non-admin Heal is disabled, so the replacement target must remain empty before the explicit root heal"
|
||||
);
|
||||
assert!(
|
||||
!census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?.has_xl_meta,
|
||||
"the object written during the outage must be absent before the explicit root heal"
|
||||
);
|
||||
assert_eq!(
|
||||
pre_heal_replacement["cluster"]["records"].as_array().map(Vec::len),
|
||||
Some(0),
|
||||
"isolated target must not retain an automatic replacement generation: {pre_heal_replacement}"
|
||||
);
|
||||
}
|
||||
|
||||
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||
let heal_url = format!("{}/rustfs/admin/v3/heal/?forceStart=true", cluster.nodes[0].url);
|
||||
let heal_start_body = signed_admin_post(&heal_url, Some(heal_body), &cluster.access_key, &cluster.secret_key).await?;
|
||||
let heal_start: serde_json::Value = serde_json::from_str(&heal_start_body)
|
||||
@@ -764,24 +1054,39 @@ mod tests {
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(60);
|
||||
let partial_deadline = Instant::now() + Duration::from_secs(partial_timeout_secs);
|
||||
let pre_interrupt_status = loop {
|
||||
loop {
|
||||
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||
let active_status: serde_json::Value = serde_json::from_str(&status_body)
|
||||
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
|
||||
if only_admin_heal_is_active(&active_status) {
|
||||
break active_status;
|
||||
let active = if background_enabled {
|
||||
active_status["state"].as_str() == Some("active")
|
||||
&& active_status["healOperations"]["activeBySource"]["admin"].as_u64() == Some(1)
|
||||
} else {
|
||||
only_admin_heal_is_active(&active_status)
|
||||
};
|
||||
if active {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= partial_deadline {
|
||||
return Err(format!("root heal never became active within {partial_timeout_secs}s: {active_status}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
let partial_count = loop {
|
||||
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
||||
if matching > 0 && matching < expected_manifests.len() {
|
||||
break matching;
|
||||
}
|
||||
let (partial_count, partial_manifest) = loop {
|
||||
// Hash one committed shard to prove progress without letting a
|
||||
// full-corpus hash pass consume the interruption window.
|
||||
let materialized = metadata_count(&replaced_disk, bucket, &expected_manifests);
|
||||
if materialized > 0
|
||||
&& materialized < expected_manifests.len()
|
||||
&& let Some(expected) = expected_manifests
|
||||
.iter()
|
||||
.find(|expected| object_metadata_exists_on_disk(&replaced_disk, bucket, &expected.key))
|
||||
&& census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?
|
||||
.matches_manifest(&expected.shard_census)
|
||||
{
|
||||
break (materialized, expected);
|
||||
}
|
||||
if matching == expected_manifests.len() {
|
||||
if materialized == expected_manifests.len() {
|
||||
return Err(format!(
|
||||
"root heal rebuilt all {} baseline objects before the target could be interrupted",
|
||||
expected_manifests.len()
|
||||
@@ -796,30 +1101,195 @@ mod tests {
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
};
|
||||
|
||||
let pre_interrupt_status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
|
||||
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
|
||||
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
|
||||
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
|
||||
assert!(
|
||||
coordinator_log
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
||||
.any(|event| {
|
||||
event["event"] == "heal_task_state"
|
||||
&& event["task_id"] == client_token
|
||||
&& event["heal_type"] == "cluster"
|
||||
&& event["state"] == "started"
|
||||
}),
|
||||
"node 0 must have started the exact admin task before interruption"
|
||||
);
|
||||
let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
|
||||
assert_eq!(
|
||||
pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
|
||||
Some(1),
|
||||
"interruption must occur while the single admin task is active: {pre_interrupt_status}"
|
||||
);
|
||||
if !background_enabled {
|
||||
assert!(
|
||||
only_admin_heal_is_active(&pre_interrupt_status),
|
||||
"isolated interruption must retain only the admin task: {pre_interrupt_status}"
|
||||
);
|
||||
assert_eq!(
|
||||
pre_interrupt_replacement["cluster"]["records"].as_array().map(Vec::len),
|
||||
Some(0),
|
||||
"root-heal interruption point must not retain an automatic replacement generation: {pre_interrupt_replacement}"
|
||||
);
|
||||
}
|
||||
info!(
|
||||
event = "heal_restart_checkpoint",
|
||||
event = "heal_interruption_checkpoint",
|
||||
component = "e2e_test",
|
||||
subsystem = "heal",
|
||||
partial_count,
|
||||
"Verified unique admin owner before target interruption"
|
||||
background_enabled,
|
||||
interruption_node,
|
||||
interruption_kind,
|
||||
partial_metadata_count = partial_count,
|
||||
verified_key = partial_manifest.key,
|
||||
"Observed partial rebuild before interruption"
|
||||
);
|
||||
|
||||
cluster.stop_node(1)?;
|
||||
let stopped_count = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
||||
assert!(
|
||||
stopped_count > 0 && stopped_count < expected_manifests.len(),
|
||||
"the target must stop after a partial rebuild, observed before stop={partial_count}, after stop={stopped_count}, total={}",
|
||||
expected_manifests.len()
|
||||
);
|
||||
let unclean_shutdown_marker = replaced_disk.join(".rustfs.sys").join("unclean-shutdown");
|
||||
match std::fs::remove_file(&unclean_shutdown_marker) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(format!("failed to isolate unclean recovery marker {unclean_shutdown_marker:?}: {error}").into());
|
||||
let target_pid = cluster.nodes[1].process.as_ref().ok_or("target process is not running")?.id();
|
||||
if scenario == InterruptionScenario::TargetEndpointBlackhole {
|
||||
let node_pids = cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| {
|
||||
node.process
|
||||
.as_ref()
|
||||
.ok_or("cluster process is not running")
|
||||
.map(std::process::Child::id)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
timeout(Duration::from_secs(2), TcpStream::connect(&cluster.nodes[1].address)).await??;
|
||||
let mut blackhole = TcpPortBlackhole::install(&cluster.nodes[1].address)?;
|
||||
let blocked_connect = timeout(Duration::from_millis(500), TcpStream::connect(&cluster.nodes[1].address)).await;
|
||||
assert!(
|
||||
blocked_connect.is_err(),
|
||||
"target endpoint connection must time out while the OUTPUT DROP rule is active: {blocked_connect:?}"
|
||||
);
|
||||
|
||||
let stable_window_secs = std::env::var("RUSTFS_HEAL_CHAOS_BLACKHOLE_STABLE_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(2)
|
||||
.clamp(1, 5);
|
||||
let blackhole_timeout_secs = std::env::var("RUSTFS_HEAL_CHAOS_BLACKHOLE_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(20)
|
||||
.clamp(stable_window_secs + 1, 60);
|
||||
let blackhole_deadline = Instant::now() + Duration::from_secs(blackhole_timeout_secs);
|
||||
let mut stable_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
|
||||
let mut stable_since = Instant::now();
|
||||
loop {
|
||||
for (node_index, (node, expected_pid)) in cluster.nodes.iter_mut().zip(&node_pids).enumerate() {
|
||||
let process = node
|
||||
.process
|
||||
.as_mut()
|
||||
.ok_or_else(|| format!("node {node_index} process disappeared"))?;
|
||||
assert_eq!(process.id(), *expected_pid, "node {node_index} PID changed during endpoint blackhole");
|
||||
assert!(process.try_wait()?.is_none(), "node {node_index} exited during endpoint blackhole");
|
||||
}
|
||||
|
||||
let current_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
|
||||
if current_count == expected_manifests.len() {
|
||||
return Err("root heal completed before the endpoint blackhole became observable".into());
|
||||
}
|
||||
if current_count != stable_count {
|
||||
stable_count = current_count;
|
||||
stable_since = Instant::now();
|
||||
}
|
||||
if stable_since.elapsed() >= Duration::from_secs(stable_window_secs) {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= blackhole_deadline {
|
||||
return Err(format!(
|
||||
"target rebuild never remained stable for {stable_window_secs}s during the endpoint blackhole: last_count={stable_count}, total={}",
|
||||
expected_manifests.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
let blocked_task_body = timeout(
|
||||
Duration::from_secs(5),
|
||||
signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key),
|
||||
)
|
||||
.await??;
|
||||
let blocked_task: serde_json::Value = serde_json::from_str(&blocked_task_body)
|
||||
.map_err(|err| format!("blackholed task status is not JSON ({err}): {blocked_task_body}"))?;
|
||||
assert_eq!(
|
||||
blocked_task["summary"].as_str(),
|
||||
Some("running"),
|
||||
"the original admin task must remain resumable during the endpoint blackhole: {blocked_task}"
|
||||
);
|
||||
assert!(
|
||||
census_object_version_on_disk(&replaced_disk, bucket, &partial_manifest.key, None)?
|
||||
.matches_manifest(&partial_manifest.shard_census),
|
||||
"the witnessed complete shard must survive the endpoint blackhole"
|
||||
);
|
||||
|
||||
blackhole.restore()?;
|
||||
timeout(Duration::from_secs(2), TcpStream::connect(&cluster.nodes[1].address)).await??;
|
||||
info!(
|
||||
event = "heal_endpoint_blackhole_restored",
|
||||
component = "e2e_test",
|
||||
subsystem = "heal",
|
||||
interruption_kind,
|
||||
stable_metadata_count = stable_count,
|
||||
stable_window_secs,
|
||||
"Restored target endpoint forwarding"
|
||||
);
|
||||
} else {
|
||||
cluster.stop_node(interruption_node)?;
|
||||
let stopped_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
|
||||
assert!(
|
||||
stopped_count > 0 && stopped_count < expected_manifests.len(),
|
||||
"node {interruption_node} must stop during a partial rebuild, observed before stop={partial_count}, after stop={stopped_count}, total={}",
|
||||
expected_manifests.len()
|
||||
);
|
||||
assert!(
|
||||
census_object_version_on_disk(&replaced_disk, bucket, &partial_manifest.key, None)?
|
||||
.matches_manifest(&partial_manifest.shard_census),
|
||||
"the witnessed complete shard must survive interruption"
|
||||
);
|
||||
let unclean_shutdown_marker = Path::new(&cluster.nodes[interruption_node].data_dir)
|
||||
.join(".rustfs.sys")
|
||||
.join("unclean-shutdown");
|
||||
if background_enabled {
|
||||
assert!(
|
||||
unclean_shutdown_marker.is_file(),
|
||||
"background restart must retain the real unclean-shutdown marker"
|
||||
);
|
||||
} else {
|
||||
match std::fs::remove_file(&unclean_shutdown_marker) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(
|
||||
format!("failed to isolate unclean recovery marker {unclean_shutdown_marker:?}: {error}").into()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
cluster.start_node(interruption_node).await?;
|
||||
if interruption_node == 0 {
|
||||
let target = cluster.nodes[1]
|
||||
.process
|
||||
.as_mut()
|
||||
.ok_or("target process disappeared during coordinator restart")?;
|
||||
assert_eq!(target.id(), target_pid, "coordinator restart must not replace the target process");
|
||||
assert!(target.try_wait()?.is_none(), "the target must remain alive during coordinator restart");
|
||||
}
|
||||
}
|
||||
cluster.start_node(1).await?;
|
||||
|
||||
let scanner_cycle_floor = if background_enabled {
|
||||
Some(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let heal_timeout_secs = std::env::var("RUSTFS_HEAL_REPLACED_DISK_TIMEOUT_SECS")
|
||||
.ok()
|
||||
@@ -832,13 +1302,22 @@ mod tests {
|
||||
{
|
||||
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
||||
if matching == expected_manifests.len() && outage_census.is_complete() {
|
||||
let pool_metadata_matches = match &expected_pool_metadata {
|
||||
Some(expected) => {
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
|
||||
.matches_manifest(expected)
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if matching == expected_manifests.len() && outage_census.is_complete() && pool_metadata_matches {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if Instant::now() >= heal_deadline {
|
||||
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
||||
let pool_metadata =
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
|
||||
.await
|
||||
.unwrap_or_else(|err| format!("status request failed: {err}"));
|
||||
@@ -858,7 +1337,7 @@ mod tests {
|
||||
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
|
||||
};
|
||||
return Err(format!(
|
||||
"root heal did not resume after target restart within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, replacement_status={replacement_status}",
|
||||
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
||||
expected_manifests.len()
|
||||
)
|
||||
.into());
|
||||
@@ -885,6 +1364,17 @@ mod tests {
|
||||
"the outage object must be rebuilt into its own missing erasure slot"
|
||||
);
|
||||
|
||||
if let Some(cycle_end) = scanner_cycle_floor {
|
||||
wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
|
||||
}
|
||||
|
||||
let mut expected_keys = expected_manifests
|
||||
.iter()
|
||||
.map(|manifest| manifest.key.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
assert!(expected_keys.insert(outage_key.to_string()));
|
||||
assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
|
||||
|
||||
let target_client = cluster.create_s3_client(1)?;
|
||||
for expected in &expected_manifests {
|
||||
let response = target_client.get_object().bucket(bucket).key(&expected.key).send().await?;
|
||||
@@ -914,6 +1404,30 @@ mod tests {
|
||||
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
|
||||
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
|
||||
if interruption_node == 0 {
|
||||
// Admin tasks are process-local. Physical and queue convergence
|
||||
// above establish recovery; a lost task must not report success.
|
||||
assert_eq!(
|
||||
task_status["summary"].as_str(),
|
||||
Some("notFound"),
|
||||
"interrupted task status: {task_status}"
|
||||
);
|
||||
assert_eq!(
|
||||
task_status["detail"].as_str(),
|
||||
Some("heal task not found or expired"),
|
||||
"interrupted admin task must be explicitly unavailable: {task_status}"
|
||||
);
|
||||
info!(
|
||||
event = "heal_interruption_recovered",
|
||||
component = "e2e_test",
|
||||
subsystem = "heal",
|
||||
interruption_node,
|
||||
interruption_kind,
|
||||
task_state = "not_found",
|
||||
"Physical recovery completed after coordinator restart"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if task_status["summary"].as_str() != Some("finished") {
|
||||
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
|
||||
}
|
||||
|
||||
@@ -23,10 +23,17 @@ pub mod common;
|
||||
#[cfg(test)]
|
||||
pub mod chaos;
|
||||
|
||||
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8).
|
||||
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
|
||||
// and on-demand-migration source scenarios (backlog#2151).
|
||||
#[cfg(test)]
|
||||
pub mod fake_s3_target;
|
||||
|
||||
// On-demand migration (backlog#2147): shared two-server environment, admin
|
||||
// wrappers, and the harness self-test (backlog#2151). Behavior scenarios are
|
||||
// added by later ODM tasks.
|
||||
#[cfg(test)]
|
||||
pub mod on_demand_migration;
|
||||
|
||||
// Socket-level network fault-injection proxy for black-box cluster tests
|
||||
// (backlog#1325 network fault-injection block): latency / blackhole / one-way
|
||||
// partition on the wire between nodes. Serves #1312/#1319 (lock-plane one-way
|
||||
@@ -73,6 +80,12 @@ mod upgrade_compatibility_test;
|
||||
#[cfg(test)]
|
||||
mod replication_lww_receiver_test;
|
||||
|
||||
// Outbound target matrix: every object shape against every remote-target
|
||||
// failure mode the fake target models (SOP:
|
||||
// docs/postmortems/2026-09-03-replication-checksum-default-regression.md).
|
||||
#[cfg(test)]
|
||||
mod replication_target_matrix_test;
|
||||
|
||||
// Data usage regression tests
|
||||
#[cfg(test)]
|
||||
mod data_usage_test;
|
||||
@@ -205,6 +218,10 @@ mod cluster_multidrive_pool_test;
|
||||
#[cfg(test)]
|
||||
mod inline_fast_path_cluster_test;
|
||||
|
||||
// backlog#2207: two-node gate for the cluster-authoritative tier stats contract.
|
||||
#[cfg(test)]
|
||||
mod tier_stats_cluster_test;
|
||||
|
||||
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
|
||||
#[cfg(test)]
|
||||
mod checksum_upload_test;
|
||||
|
||||
@@ -225,8 +225,8 @@ fn encode_unsigned_aws_chunked_with_sha256_trailer(decoded: &[u8]) -> Vec<u8> {
|
||||
let checksum = sha256_base64(decoded);
|
||||
let mut encoded = format!("{:x}\r\n", decoded.len()).into_bytes();
|
||||
encoded.extend_from_slice(decoded);
|
||||
encoded.extend_from_slice(b"\r\n0\r\n\r\n");
|
||||
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}").as_bytes());
|
||||
encoded.extend_from_slice(b"\r\n0\r\n");
|
||||
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}\r\n\r\n").as_bytes());
|
||||
encoded
|
||||
}
|
||||
|
||||
@@ -549,6 +549,68 @@ async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::e
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// s3s v0.16 validates the aws-chunked decoded length while RustFS consumes the
|
||||
/// body stream. Mismatches are client body errors and must not leak as 500s.
|
||||
#[tokio::test]
|
||||
async fn aws_chunked_decoded_length_mismatch_returns_incomplete_body() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
|
||||
for (key, declared_len) in [
|
||||
("decoded-length-overrun.bin", 3_usize),
|
||||
("decoded-length-shortfall.bin", 9_usize),
|
||||
] {
|
||||
let decoded = b"decoded";
|
||||
assert_ne!(declared_len, decoded.len(), "test case must exercise a mismatch");
|
||||
let encoded_body = encode_unsigned_aws_chunked_with_sha256_trailer(decoded);
|
||||
let decoded_content_length = declared_len.to_string();
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let signer = SigV4::new(&env);
|
||||
let extra_signed_headers = [
|
||||
("content-encoding", "aws-chunked"),
|
||||
("x-amz-decoded-content-length", decoded_content_length.as_str()),
|
||||
("x-amz-trailer", "x-amz-checksum-sha256"),
|
||||
];
|
||||
let headers = signer.sign_with_extra_headers("PUT", &path, "", UNSIGNED_PAYLOAD_TRAILER, &extra_signed_headers);
|
||||
|
||||
let response = local_http_client()
|
||||
.put(format!("{}{}", env.url, path))
|
||||
.header("authorization", &headers.authorization)
|
||||
.header("content-encoding", "aws-chunked")
|
||||
.header("x-amz-content-sha256", &headers.content_sha256)
|
||||
.header("x-amz-date", &headers.amz_date)
|
||||
.header("x-amz-decoded-content-length", &decoded_content_length)
|
||||
.header("x-amz-trailer", "x-amz-checksum-sha256")
|
||||
.body(encoded_body)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"decoded length mismatch must be a client error, body:\n{body}"
|
||||
);
|
||||
assert_error_code(&body, "IncompleteBody");
|
||||
|
||||
let absent = env
|
||||
.create_s3_client()
|
||||
.get_object()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("decoded length mismatch must not publish an object");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// (e) A request whose `x-amz-date` is skewed beyond the server's tolerance
|
||||
/// (s3s default 900s / 15 min) must be rejected with RequestTimeTooSkewed /
|
||||
/// 403. The signature is otherwise valid: the credential-scope date and
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// 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.
|
||||
|
||||
//! On-demand migration backfill job scenarios (ODM-12, rustfs/backlog#2159):
|
||||
//! a full backfill of a small-object source, cancellation, and resuming from
|
||||
//! the persisted continuation token after a server restart.
|
||||
|
||||
use super::common::{BackfillOp, BackfillRequest, ODM_SERVER_ENV, OdmSourceSpec, OdmTestEnv, SeedObject};
|
||||
use crate::fake_s3_target::Operation;
|
||||
use bytes::Bytes;
|
||||
use std::time::Duration;
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-backfill-source";
|
||||
const LOCAL_BUCKET: &str = "odm-backfill-local";
|
||||
const KEY_PREFIX: &str = "cold/";
|
||||
/// Keys per scenario. The fake source retains at most 4,096 object versions
|
||||
/// and 4,096 journal entries, and one pull is a HEAD plus a GET, so a
|
||||
/// scenario that asserts on the journal stays below ~2,000 keys. The job
|
||||
/// lists 1,000 keys per page, so this still spans several pages and exercises
|
||||
/// the continuation token, which is what the scenarios are about.
|
||||
const SEEDED_KEYS: usize = 1500;
|
||||
|
||||
fn key(i: usize) -> String {
|
||||
format!("{KEY_PREFIX}{i:05}")
|
||||
}
|
||||
|
||||
/// Content that identifies the key so a mis-stored object is caught.
|
||||
fn body(i: usize) -> Bytes {
|
||||
Bytes::from(format!("object-{i:05}-payload"))
|
||||
}
|
||||
|
||||
fn seed(env: &OdmTestEnv, count: usize) {
|
||||
let objects: Vec<SeedObject> = (0..count).map(|i| SeedObject::new(key(i), body(i))).collect();
|
||||
let etags = env.seed_source(SOURCE_BUCKET, &objects);
|
||||
assert_eq!(etags.len(), count);
|
||||
}
|
||||
|
||||
async fn configure(env: &OdmTestEnv, spec: &OdmSourceSpec) -> TestResult {
|
||||
let response = env.configure_source(LOCAL_BUCKET, spec).await?;
|
||||
assert_eq!(response.status, 200, "configure: {}", response.body);
|
||||
// The probe issued a one-key listing; count only the job's traffic.
|
||||
env.source.take_requests();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn source_lists(env: &OdmTestEnv) -> Vec<Option<String>> {
|
||||
env.source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|record| record.operation == Operation::ListObjectsV2)
|
||||
.map(|record| record.continuation_token)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_gets(env: &OdmTestEnv) -> usize {
|
||||
env.source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|record| record.operation == Operation::GetObject)
|
||||
.count()
|
||||
}
|
||||
|
||||
fn counter(job: &serde_json::Value, name: &str) -> u64 {
|
||||
job[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {job}"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_pulls_every_source_object_across_list_pages() -> TestResult {
|
||||
const COUNT: usize = SEEDED_KEYS;
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket(SOURCE_BUCKET);
|
||||
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
|
||||
seed(&env, COUNT);
|
||||
configure(&env, &env.fake_source_spec(SOURCE_BUCKET)).await?;
|
||||
|
||||
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
|
||||
assert_eq!(started.status, 200, "start: {}", started.body);
|
||||
let job = started.json()?["job"].clone();
|
||||
assert_eq!(job["state"], "running");
|
||||
assert_eq!(job["skip_existing"], "always");
|
||||
assert_eq!(job["dry_run"], false);
|
||||
let job_id = job["job_id"].as_str().expect("job id").to_string();
|
||||
|
||||
// A second start while the job holds its lease is a conflict.
|
||||
let again = env
|
||||
.backfill(LOCAL_BUCKET, BackfillOp::Start(BackfillRequest::default()))
|
||||
.await?;
|
||||
assert_eq!(again.status, 409, "second start: {}", again.body);
|
||||
assert!(again.body.contains("OnDemandMigrationBackfillRunning"), "{}", again.body);
|
||||
|
||||
let done = env
|
||||
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(240), |job| job["state"] == "completed")
|
||||
.await?;
|
||||
assert_eq!(done["job_id"], job_id.as_str());
|
||||
assert_eq!(counter(&done, "listed"), COUNT as u64);
|
||||
assert_eq!(counter(&done, "enqueued"), COUNT as u64);
|
||||
assert_eq!(counter(&done, "pulled"), COUNT as u64);
|
||||
assert_eq!(counter(&done, "failed"), 0);
|
||||
assert_eq!(counter(&done, "skipped_existing"), 0);
|
||||
assert!(done["continuation_token"].is_null(), "a finished job carries no cursor");
|
||||
assert_eq!(done["last_key"], key(COUNT - 1));
|
||||
assert!(done["failed_keys"].as_array().is_some_and(Vec::is_empty));
|
||||
let expected_bytes: u64 = (0..COUNT).map(|i| body(i).len() as u64).sum();
|
||||
assert_eq!(counter(&done, "bytes"), expected_bytes);
|
||||
|
||||
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
|
||||
for i in [0, 999, 1000, 1200, COUNT - 1] {
|
||||
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
|
||||
}
|
||||
|
||||
let lists = source_lists(&env);
|
||||
assert_eq!(lists.len(), COUNT.div_ceil(1000), "{COUNT} keys at 1000 per page: {lists:?}");
|
||||
assert!(lists[0].is_none(), "the first page starts without a cursor");
|
||||
assert!(lists[1..].iter().all(Option::is_some), "every later page carries the cursor");
|
||||
assert_eq!(source_gets(&env), COUNT, "every object is fetched exactly once");
|
||||
|
||||
// The status endpoint summarises the same job.
|
||||
let status = env.status(LOCAL_BUCKET).await?;
|
||||
assert_eq!(status.status, 200);
|
||||
let summary = status.json()?["backfill"].clone();
|
||||
assert_eq!(summary["job_id"], job_id.as_str());
|
||||
assert_eq!(summary["state"], "completed");
|
||||
assert_eq!(counter(&summary, "pulled"), COUNT as u64);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_cancel_stops_enqueueing_and_persists_cancelled() -> TestResult {
|
||||
const COUNT: usize = SEEDED_KEYS;
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket(SOURCE_BUCKET);
|
||||
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
|
||||
seed(&env, COUNT);
|
||||
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
spec.policy.max_concurrent_pulls = 1;
|
||||
configure(&env, &spec).await?;
|
||||
|
||||
// Cancelling before any job exists is a 404, not a silent success.
|
||||
let nothing = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
|
||||
assert_eq!(nothing.status, 404, "cancel without a job: {}", nothing.body);
|
||||
assert!(nothing.body.contains("NoSuchBackfillJob"), "{}", nothing.body);
|
||||
let unread = env.backfill(LOCAL_BUCKET, BackfillOp::Status).await?;
|
||||
assert_eq!(unread.status, 404, "status without a job: {}", unread.body);
|
||||
|
||||
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
|
||||
assert_eq!(started.status, 200, "start: {}", started.body);
|
||||
env.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(60), |job| {
|
||||
job["state"] == "running" && counter(job, "enqueued") > 0
|
||||
})
|
||||
.await?;
|
||||
|
||||
let cancelled = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
|
||||
assert_eq!(cancelled.status, 200, "cancel: {}", cancelled.body);
|
||||
let job = cancelled.json()?["job"].clone();
|
||||
assert_eq!(job["state"], "cancelled");
|
||||
let enqueued_at_cancel = counter(&job, "enqueued");
|
||||
assert!(enqueued_at_cancel < COUNT as u64, "the job was cancelled mid-way: {job}");
|
||||
|
||||
// Nothing is queued after the cancel: the checkpoint and the source
|
||||
// traffic both stop moving once the few in-flight pulls drain.
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let persisted = env.backfill_job(LOCAL_BUCKET).await?.expect("checkpoint kept for inspection");
|
||||
assert_eq!(persisted["state"], "cancelled");
|
||||
assert_eq!(counter(&persisted, "enqueued"), enqueued_at_cancel);
|
||||
let gets_after_drain = source_gets(&env);
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
assert_eq!(source_gets(&env), gets_after_drain, "no source GET after the cancel drained");
|
||||
assert!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await? < COUNT);
|
||||
|
||||
// Cancel is idempotent and the status endpoint reports the final state.
|
||||
let again = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
|
||||
assert_eq!(again.status, 200, "second cancel: {}", again.body);
|
||||
assert_eq!(again.json()?["job"]["state"], "cancelled");
|
||||
let status = env.status(LOCAL_BUCKET).await?;
|
||||
assert_eq!(status.json()?["backfill"]["state"], "cancelled");
|
||||
|
||||
// A cancelled job releases the bucket: a new job can start.
|
||||
let restarted = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
|
||||
assert_eq!(restarted.status, 200, "restart after cancel: {}", restarted.body);
|
||||
assert_ne!(restarted.json()?["job"]["job_id"], job["job_id"]);
|
||||
let _ = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_resumes_from_continuation_token_after_restart() -> TestResult {
|
||||
const COUNT: usize = SEEDED_KEYS;
|
||||
let mut env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket(SOURCE_BUCKET);
|
||||
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
|
||||
seed(&env, COUNT);
|
||||
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
spec.policy.max_concurrent_pulls = 2;
|
||||
configure(&env, &spec).await?;
|
||||
|
||||
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
|
||||
assert_eq!(started.status, 200, "start: {}", started.body);
|
||||
let job_id = started.json()?["job"]["job_id"].as_str().expect("job id").to_string();
|
||||
|
||||
// Wait for the first page to be committed (cursor persisted), then kill
|
||||
// the server while the job is still running.
|
||||
let mid = env
|
||||
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(120), |job| {
|
||||
job["state"] == "running" && job["continuation_token"].is_string()
|
||||
})
|
||||
.await?;
|
||||
assert!(counter(&mid, "listed") >= 1000 && counter(&mid, "listed") < COUNT as u64, "{mid}");
|
||||
env.source.take_requests();
|
||||
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||
|
||||
let done = env
|
||||
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(300), |job| job["state"] == "completed")
|
||||
.await?;
|
||||
assert_eq!(done["job_id"], job_id.as_str(), "the same job continues after the restart");
|
||||
assert_eq!(counter(&done, "failed"), 0);
|
||||
assert!(
|
||||
counter(&done, "listed") >= COUNT as u64,
|
||||
"the resumed job listed the rest (the interrupted page is listed twice): {done}"
|
||||
);
|
||||
// Keys pulled before the crash are re-listed and skipped, never re-pulled;
|
||||
// a pull whose report died with the old process is counted neither way,
|
||||
// so only the lower bound and the queue accounting are exact.
|
||||
assert!(counter(&done, "pulled") + counter(&done, "skipped_existing") >= COUNT as u64, "{done}");
|
||||
assert!(counter(&done, "pulled") <= counter(&done, "enqueued"), "{done}");
|
||||
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
|
||||
for i in [0, 500, 999, 1000, COUNT - 1] {
|
||||
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
|
||||
}
|
||||
|
||||
let lists = source_lists(&env);
|
||||
assert!(!lists.is_empty(), "the resumed job listed the source");
|
||||
assert!(
|
||||
lists.iter().all(Option::is_some),
|
||||
"after the restart every source listing carries a continuation-token: {lists:?}"
|
||||
);
|
||||
assert!(
|
||||
lists.len() <= COUNT.div_ceil(1000),
|
||||
"the listing did not start over from the first page: {lists:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
// 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.
|
||||
|
||||
//! Concurrency limits of on-demand migration (rustfs/backlog#2158):
|
||||
//! single-flight on one key, the `max_concurrent_pulls` ceiling, and a full
|
||||
//! background pull queue.
|
||||
//!
|
||||
//! The point of each case is what the source is spared, so the source
|
||||
//! journal (`count_requests`) carries the assertion in every one of them.
|
||||
|
||||
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
|
||||
use crate::fake_s3_target::Operation;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use bytes::Bytes;
|
||||
use std::time::Duration;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-concurrency-source";
|
||||
/// Background pulls land after the response that queued them.
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
fn source_get_count(env: &OdmTestEnv, key: &str) -> usize {
|
||||
env.source.count_requests(Operation::GetObject, key)
|
||||
}
|
||||
|
||||
/// Case 9: 32 concurrent misses on one key coalesce into a single-flight
|
||||
/// pull. At most two source GETs are allowed: the leader plus one follower
|
||||
/// that gave up waiting and streamed through.
|
||||
#[tokio::test]
|
||||
async fn test_odm_concurrent_misses_on_one_key_coalesce() -> TestResult {
|
||||
let bucket = "odm-concurrency-singleflight";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
env.client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let key = "singleflight/asset.bin";
|
||||
let body = payload(512 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let responses: Vec<RawResponse> = futures::future::try_join_all((0..32).map(|_| env.raw_get(bucket, key))).await?;
|
||||
for (index, response) in responses.iter().enumerate() {
|
||||
assert_eq!(response.status, 200, "reader {index}: {}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body, "reader {index} received different bytes");
|
||||
}
|
||||
|
||||
let source_gets = source_get_count(&env, key);
|
||||
assert!(
|
||||
(1..=2).contains(&source_gets),
|
||||
"32 concurrent misses must not become {source_gets} source GETs"
|
||||
);
|
||||
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the leader stores the object");
|
||||
env.assert_local_present(bucket, key, &body).await;
|
||||
let versions = env.client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
||||
assert_eq!(
|
||||
versions.versions().len(),
|
||||
1,
|
||||
"the coalesced pull commits exactly one version: {:?}",
|
||||
versions.versions()
|
||||
);
|
||||
assert_eq!(
|
||||
source_get_count(&env, key),
|
||||
source_gets,
|
||||
"nothing pulls the object again once it is local"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 10: 64 misses on distinct keys never exceed `max_concurrent_pulls`
|
||||
/// in flight, and all of them eventually land.
|
||||
#[tokio::test]
|
||||
async fn test_odm_concurrent_pulls_respect_the_configured_ceiling() -> TestResult {
|
||||
let bucket = "odm-concurrency-ceiling";
|
||||
const MAX_CONCURRENT_PULLS: u32 = 4;
|
||||
const KEYS: usize = 64;
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.max_concurrent_pulls = MAX_CONCURRENT_PULLS;
|
||||
})
|
||||
.await?;
|
||||
|
||||
let body = payload(256 * 1024);
|
||||
let keys: Vec<String> = (0..KEYS).map(|index| format!("ceiling/object-{index:03}.bin")).collect();
|
||||
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
|
||||
env.seed_source(SOURCE_BUCKET, &seeds);
|
||||
|
||||
let reads = futures::future::try_join_all(keys.iter().map(|key| env.raw_get(bucket, key)));
|
||||
let (responses, peak_inflight) = env.peak_inflight_pulls(bucket, reads).await?;
|
||||
let responses = responses?;
|
||||
for (key, response) in keys.iter().zip(&responses) {
|
||||
assert_eq!(response.status, 200, "{key}: {}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body, "{key} received different bytes");
|
||||
}
|
||||
assert!(
|
||||
peak_inflight <= u64::from(MAX_CONCURRENT_PULLS),
|
||||
"in-flight pulls peaked at {peak_inflight}, above the configured {MAX_CONCURRENT_PULLS}"
|
||||
);
|
||||
assert!(
|
||||
peak_inflight >= 1,
|
||||
"the poll never observed a pull in flight, so the ceiling assertion proves nothing"
|
||||
);
|
||||
|
||||
for key in &keys {
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{key} must be stored locally");
|
||||
assert_eq!(source_get_count(&env, key), 1, "{key} is pulled exactly once");
|
||||
}
|
||||
assert_eq!(env.status_counter(bucket, "/inflight_pulls").await?, 0, "every pull slot is released");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 11: with a small background queue, a burst of Range reads overflows
|
||||
/// it. The overflow is counted and dropped, never turned into a client
|
||||
/// failure: every reader still gets its 206 from the source.
|
||||
#[tokio::test]
|
||||
async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients() -> TestResult {
|
||||
let bucket = "odm-concurrency-queue-full";
|
||||
const REQUESTS: usize = 100;
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.pull_queue_capacity = 8;
|
||||
spec.policy.max_concurrent_pulls = 1;
|
||||
})
|
||||
.await?;
|
||||
|
||||
let body = payload(128 * 1024);
|
||||
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
|
||||
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
|
||||
env.seed_source(SOURCE_BUCKET, &seeds);
|
||||
|
||||
let responses: Vec<RawResponse> = futures::future::try_join_all(
|
||||
keys.iter()
|
||||
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
|
||||
)
|
||||
.await?;
|
||||
for (key, response) in keys.iter().zip(&responses) {
|
||||
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body.slice(0..1024), "{key} served the wrong range");
|
||||
assert_eq!(
|
||||
response.header("content-range"),
|
||||
Some(format!("bytes 0-1023/{}", body.len()).as_str()),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
|
||||
let queue_full = env
|
||||
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
|
||||
.await?;
|
||||
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
|
||||
|
||||
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
|
||||
assert!(
|
||||
ranged_reads >= REQUESTS,
|
||||
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
|
||||
);
|
||||
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
|
||||
assert!(
|
||||
dropped > 0,
|
||||
"the overflowed keys are the ones with no backfill GET, but every key got one"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Source-failure scenarios for on-demand migration (rustfs/backlog#2158):
|
||||
//! access denied, the circuit breaker, first-byte and mid-body stream
|
||||
//! failures (a cut body and a stalled one), ETag integrity, the negative
|
||||
//! cache, and an unsupported (SSE-C) source object.
|
||||
//!
|
||||
//! Every case asserts what the source was asked for, not only what the
|
||||
//! client received: a fault that silently turned into a second source
|
||||
//! request would otherwise pass.
|
||||
|
||||
use super::common::{BoxError, OdmTestEnv, SeedObject, start_configured_env};
|
||||
use crate::fake_s3_target::{FaultAction, Operation};
|
||||
use bytes::Bytes;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-fault-source";
|
||||
/// Header the GET/HEAD paths add when the answer came from the source.
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// Status of the `SourceUnavailable` error the `propagate` policy returns.
|
||||
const SOURCE_UNAVAILABLE_STATUS: u16 = 424;
|
||||
/// Background pulls and their counters land after the response.
|
||||
const SETTLE: Duration = Duration::from_secs(60);
|
||||
/// Consecutive counted source failures that open the breaker
|
||||
/// (`BREAKER_FAILURE_THRESHOLD` in ecstore).
|
||||
const BREAKER_FAILURE_THRESHOLD: usize = 5;
|
||||
|
||||
/// Position-dependent payload so a misaligned or truncated copy is caught.
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
/// A source object with a well-formed but deliberately wrong single-part
|
||||
/// ETag: the fake source retains `x-rustfs-source-etag` verbatim, so HEAD
|
||||
/// and GET advertise an MD5 the body does not have.
|
||||
async fn seed_with_etag(env: &OdmTestEnv, key: &str, body: Bytes, etag: &str) -> TestResult {
|
||||
let response = env
|
||||
.source_client()
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(body))
|
||||
.customize()
|
||||
.mutate_request({
|
||||
let etag = etag.to_string();
|
||||
move |request| {
|
||||
request.headers_mut().insert("x-rustfs-source-etag", etag.clone());
|
||||
}
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
response.e_tag(),
|
||||
Some(format!("\"{etag}\"").as_str()),
|
||||
"the fake source stores the announced ETag"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A source object that reports SSE-C: the fake source echoes the customer
|
||||
/// algorithm it captured from the replication passthrough transport header.
|
||||
async fn seed_with_ssec(env: &OdmTestEnv, key: &str, body: Bytes) -> TestResult {
|
||||
env.source_client()
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(body))
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("x-rustfs-replication-ssec-algorithm", "AES256");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 1: a 403 from the source is a configuration error, not a health
|
||||
/// signal. `propagate` answers 424 and records the class; `not_found` hides
|
||||
/// it as a 404. Neither counts toward the breaker.
|
||||
#[tokio::test]
|
||||
async fn test_odm_source_access_denied_propagates_without_opening_the_breaker() -> TestResult {
|
||||
let propagating = "odm-fault-denied-propagate";
|
||||
let hiding = "odm-fault-denied-notfound";
|
||||
let env = start_configured_env(propagating, SOURCE_BUCKET, |_| {}).await?;
|
||||
let mut hiding_spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
hiding_spec.policy.source_error = "not_found".to_string();
|
||||
env.configure_and_wait(hiding, &hiding_spec).await?;
|
||||
|
||||
let propagate_key = "denied/propagate.bin";
|
||||
let hidden_key = "denied/hidden.bin";
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new(propagate_key, payload(4096)),
|
||||
SeedObject::new(hidden_key, payload(4096)),
|
||||
],
|
||||
);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::HeadObject, propagate_key, FaultAction::ResponseStatus(403), 1);
|
||||
let denied = env.raw_get(propagating, propagate_key).await?;
|
||||
assert_eq!(denied.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&denied.body));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&denied.body).contains("SourceUnavailable"),
|
||||
"the propagated error names the ODM source code: {}",
|
||||
String::from_utf8_lossy(&denied.body)
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, propagate_key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, propagate_key),
|
||||
0,
|
||||
"a denied HEAD never reaches the body"
|
||||
);
|
||||
|
||||
let status = env.status_json(propagating).await?;
|
||||
assert_eq!(
|
||||
status.pointer("/last_source_error/class").and_then(|v| v.as_str()),
|
||||
Some("access_denied"),
|
||||
"{status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status.pointer("/breaker/state").and_then(|v| v.as_str()),
|
||||
Some("closed"),
|
||||
"a configuration error must not open the breaker: {status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/requests_total/get/source_error")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(1),
|
||||
"{status}"
|
||||
);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::HeadObject, hidden_key, FaultAction::ResponseStatus(403), 1);
|
||||
let hidden = env.raw_get(hiding, hidden_key).await?;
|
||||
assert_eq!(hidden.status, 404, "{}", String::from_utf8_lossy(&hidden.body));
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, hidden_key), 1);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, hidden_key), 0);
|
||||
|
||||
env.assert_local_absent(propagating, propagate_key).await;
|
||||
env.assert_local_absent(hiding, hidden_key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 2: repeated transport failures open the breaker; while it is open
|
||||
/// the source is not touched at all, and the half-open probe after the open
|
||||
/// window closes it again. The open window is a compiled-in 30 s constant
|
||||
/// (`BREAKER_OPEN_DURATION`), so this case waits in real time.
|
||||
///
|
||||
/// The source client disables SDK retries, so one logical source call is
|
||||
/// exactly one wire request: the script is exactly as deep as the number of
|
||||
/// breaker failures it has to produce, and the scripted fault count and the
|
||||
/// observed source request count must agree.
|
||||
#[tokio::test]
|
||||
async fn test_odm_repeated_source_errors_open_the_breaker_and_recover() -> TestResult {
|
||||
let bucket = "odm-fault-breaker";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let key = "breaker/doc.bin";
|
||||
let body = payload(8192);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::HeadObject, key, FaultAction::ResponseStatus(503), BREAKER_FAILURE_THRESHOLD);
|
||||
for attempt in 1..=BREAKER_FAILURE_THRESHOLD {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(
|
||||
response.status,
|
||||
SOURCE_UNAVAILABLE_STATUS,
|
||||
"attempt {attempt}: {}",
|
||||
String::from_utf8_lossy(&response.body)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
env.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/breaker/state")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("open"),
|
||||
"the threshold of consecutive source failures must open the breaker"
|
||||
);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
BREAKER_FAILURE_THRESHOLD,
|
||||
"every counted failure is exactly one source request"
|
||||
);
|
||||
|
||||
// With the script cleared, the only thing that can still fail a read is
|
||||
// the open breaker itself.
|
||||
env.source.clear_faults();
|
||||
let source_requests = env.source.count_requests(Operation::HeadObject, key);
|
||||
let rejected = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(rejected.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&rejected.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
source_requests,
|
||||
"an open breaker never touches the source"
|
||||
);
|
||||
assert!(
|
||||
env.status_counter(bucket, "/counters/requests_total/get/breaker_open")
|
||||
.await?
|
||||
>= 1,
|
||||
"the rejected request is counted as breaker_open"
|
||||
);
|
||||
|
||||
// Half-open admits exactly one probe once the open window elapses.
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
let recovered = loop {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
if response.status == 200 {
|
||||
break response;
|
||||
}
|
||||
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS);
|
||||
assert!(Instant::now() < deadline, "the breaker never left the open state");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
};
|
||||
assert_eq!(recovered.body, body, "the recovered read serves the source bytes");
|
||||
assert_eq!(recovered.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
source_requests + 1,
|
||||
"only the half-open probe reached the source"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
|
||||
assert_eq!(
|
||||
env.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/breaker/state")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("closed"),
|
||||
"a successful probe closes the breaker"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 3: a source that holds the response past `first_byte_ms` is a
|
||||
/// timeout, and the client never sees a 200 head. One logical source call is
|
||||
/// one wire request, so a single scripted stall is enough to fail the read.
|
||||
#[tokio::test]
|
||||
async fn test_odm_source_stall_times_out_before_the_first_byte() -> TestResult {
|
||||
let bucket = "odm-fault-stall";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.source_timeout.first_byte_ms = 500;
|
||||
})
|
||||
.await?;
|
||||
let key = "stall/doc.bin";
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(4096))]);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::HeadObject, key, FaultAction::Stall(Duration::from_secs(5)), 1);
|
||||
let started = Instant::now();
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
let elapsed = started.elapsed();
|
||||
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
1,
|
||||
"the stalled HEAD is the only source request"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"the read timeout must cut the attempt short, took {elapsed:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
0,
|
||||
"a timed-out HEAD never starts a body read"
|
||||
);
|
||||
assert_eq!(
|
||||
env.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/last_source_error/class")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("timeout"),
|
||||
);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 4: the source cuts the body of an inline pull. The client sees a
|
||||
/// short read, nothing is stored, and no multipart upload is left behind.
|
||||
#[tokio::test]
|
||||
async fn test_odm_inline_pull_aborts_when_the_source_body_is_cut() -> TestResult {
|
||||
let bucket = "odm-fault-inline-cut";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let key = "cut/inline.bin";
|
||||
let body = payload(256 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(1024), 1);
|
||||
// The client sees a transport failure while reading the body: the
|
||||
// announced Content-Length is never delivered.
|
||||
env.raw_get(bucket, key)
|
||||
.await
|
||||
.expect_err("a cut source body must not read back as a complete object");
|
||||
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"an aborted inline pull is not retried on the same request"
|
||||
);
|
||||
// Give a stray background pull time to appear before asserting absence.
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
|
||||
assert!(
|
||||
uploads.uploads().is_empty(),
|
||||
"an aborted pull leaves no multipart upload: {:?}",
|
||||
uploads.uploads()
|
||||
);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"nothing re-reads the source afterwards"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 5: the source answers, sends part of the body and then goes quiet
|
||||
/// for longer than `source_timeout.idle_ms`. The inline tee must end both
|
||||
/// ends: the client gets a short read rather than a silently truncated 200,
|
||||
/// the pull is counted as a source timeout, and nothing (object or multipart
|
||||
/// upload) is left behind locally.
|
||||
#[tokio::test]
|
||||
async fn test_odm_inline_pull_aborts_when_the_source_body_stalls() -> TestResult {
|
||||
let bucket = "odm-fault-inline-stall";
|
||||
const IDLE_MS: u64 = 1_000;
|
||||
let idle = Duration::from_millis(IDLE_MS);
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.source_timeout.idle_ms = IDLE_MS;
|
||||
})
|
||||
.await?;
|
||||
let key = "stall/inline.bin";
|
||||
let body = payload(256 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
// The head and the first slice arrive at once; the source then pauses for
|
||||
// four times the idle budget, which is what a stalled source looks like.
|
||||
env.source.inject_for_key(
|
||||
Operation::GetObject,
|
||||
key,
|
||||
FaultAction::SlowSendBody {
|
||||
chunk_bytes: 32 * 1024,
|
||||
delay: idle * 4,
|
||||
},
|
||||
1,
|
||||
);
|
||||
let started = Instant::now();
|
||||
env.raw_get(bucket, key)
|
||||
.await
|
||||
.expect_err("a stalled source body must not read back as a complete object");
|
||||
let elapsed = started.elapsed();
|
||||
assert!(
|
||||
elapsed < idle * 4,
|
||||
"the idle budget, not the source's own pause, must end the read (took {elapsed:?})"
|
||||
);
|
||||
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"an aborted inline pull is not retried on the same request"
|
||||
);
|
||||
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/source_timeout", 1, SETTLE)
|
||||
.await?;
|
||||
// The leader releases its slot just after it records the failure.
|
||||
let deadline = Instant::now() + SETTLE;
|
||||
loop {
|
||||
let inflight = env
|
||||
.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/inflight_pulls")
|
||||
.and_then(|value| value.as_u64());
|
||||
if inflight == Some(0) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "the aborted pull never released its slot: {inflight:?}");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
|
||||
assert!(
|
||||
uploads.uploads().is_empty(),
|
||||
"a stalled pull leaves no multipart upload: {:?}",
|
||||
uploads.uploads()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 6: the background pull of a large object hits a cut body, counts the
|
||||
/// failure, and the retry stores the object.
|
||||
#[tokio::test]
|
||||
async fn test_odm_background_pull_retries_a_truncated_source_body() -> TestResult {
|
||||
let bucket = "odm-fault-background-cut";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| spec.policy.inline_max_bytes = 4096).await?;
|
||||
let key = "cut/background.bin";
|
||||
let body = payload(512 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
// The faults are consumed in order by the two GETs the large-object path
|
||||
// makes: the passthrough that answers the client (unaffected), then the
|
||||
// background pull (cut).
|
||||
env.source
|
||||
.inject_for_key(Operation::GetObject, key, FaultAction::Delay(Duration::ZERO), 1);
|
||||
env.source
|
||||
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(2048), 1);
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body, "the passthrough is unaffected by the pull's fault");
|
||||
|
||||
// The cut body ends the pull attempt as a retryable source transport
|
||||
// failure; the retry stores the object, so the pull as a whole succeeds
|
||||
// and no failure is counted (only a pull that gives up is).
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the retry must store the object");
|
||||
env.assert_local_present(bucket, key, &body).await;
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
3,
|
||||
"one passthrough, one cut pull, one successful retry"
|
||||
);
|
||||
let status = env.status_json(bucket).await?;
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/pulled_objects_total/background")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(1),
|
||||
"{status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/pull_failures_total")
|
||||
.and_then(|failures| failures.as_object())
|
||||
.map(|failures| failures.values().filter_map(serde_json::Value::as_u64).sum::<u64>()),
|
||||
Some(0),
|
||||
"a retried attempt is not a failed pull: {status}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 7: the source advertises an ETag its bytes do not match. The client
|
||||
/// still gets every byte; the write-back is discarded as an integrity
|
||||
/// failure and nothing is stored.
|
||||
#[tokio::test]
|
||||
async fn test_odm_wrong_source_etag_discards_the_write_back() -> TestResult {
|
||||
let bucket = "odm-fault-etag";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let key = "etag/mismatch.bin";
|
||||
let body = payload(64 * 1024);
|
||||
seed_with_etag(&env, key, body.clone(), "0123456789abcdef0123456789abcdef").await?;
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(response.body, body, "the client receives the complete source bytes");
|
||||
|
||||
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/etag_mismatch", 1, SETTLE)
|
||||
.await?;
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"a discarded write-back is not re-read"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 8: a source miss is remembered for `negative_cache_ttl_secs`, and
|
||||
/// re-checked once the entry expires.
|
||||
#[tokio::test]
|
||||
async fn test_odm_source_not_found_is_negative_cached_for_the_ttl() -> TestResult {
|
||||
let bucket = "odm-fault-negative-cache";
|
||||
let ttl = Duration::from_secs(3);
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.negative_cache_ttl_secs = ttl.as_secs();
|
||||
})
|
||||
.await?;
|
||||
let key = "negative/nowhere.bin";
|
||||
|
||||
for attempt in 1..=10 {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 404, "attempt {attempt}: {}", String::from_utf8_lossy(&response.body));
|
||||
}
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
1,
|
||||
"nine of the ten misses stop at the negative cache"
|
||||
);
|
||||
assert!(
|
||||
env.status_counter(bucket, "/counters/requests_total/get/negative_cached")
|
||||
.await?
|
||||
>= 9,
|
||||
"the cached misses are counted"
|
||||
);
|
||||
|
||||
tokio::time::sleep(ttl + Duration::from_secs(2)).await;
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 404);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
2,
|
||||
"an expired entry re-checks the source once"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 9: an SSE-C source object cannot be migrated (the key belongs to the
|
||||
/// source's client), so the read fails as unsupported without a body read.
|
||||
#[tokio::test]
|
||||
async fn test_odm_ssec_source_object_is_unsupported() -> TestResult {
|
||||
let bucket = "odm-fault-ssec";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let key = "ssec/secret.bin";
|
||||
seed_with_ssec(&env, key, payload(4096)).await?;
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
0,
|
||||
"an unsupported object is rejected on the HEAD"
|
||||
);
|
||||
assert_eq!(
|
||||
env.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/counters/requests_total/get/unsupported")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(1),
|
||||
);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// 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.
|
||||
|
||||
//! Basic read-through scenarios (rustfs/backlog#2156): inline pull and
|
||||
//! local persistence, large-object passthrough with background backfill,
|
||||
//! Range passthrough, source 404, `versionId` reads, a disabled bucket, and
|
||||
//! the HEAD passthrough that stores nothing (rustfs/backlog#2155).
|
||||
//! Every source-side expectation is asserted on the fake source's journal.
|
||||
|
||||
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject};
|
||||
use crate::fake_s3_target::{BucketMode, Operation};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use bytes::Bytes;
|
||||
use std::time::Duration;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-get-source";
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// Background pulls run after the response; generous for a loaded CI host.
|
||||
const BACKFILL_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Position-dependent payload so a misaligned or truncated copy is caught.
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
/// RustFS with `local_bucket` migrating from `SOURCE_BUCKET` on the fake
|
||||
/// source (unversioned, like a plain migration source); `adjust` tweaks the
|
||||
/// policy before it is installed. Returns once the runtime consults the
|
||||
/// source.
|
||||
async fn configured_env(local_bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
|
||||
env.rustfs.create_test_bucket(local_bucket).await?;
|
||||
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
adjust(&mut spec);
|
||||
let response = env.configure_source(local_bucket, &spec).await?;
|
||||
assert_eq!(response.status, 200, "configure on-demand migration: {}", response.body);
|
||||
env.wait_until_source_consulted(local_bucket).await?;
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
fn source_get_ranges(env: &OdmTestEnv, key: &str) -> Vec<Option<String>> {
|
||||
env.source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|record| record.operation == Operation::GetObject && record.key.as_deref() == Some(key))
|
||||
.map(|record| record.range)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_miss_pulls_inline_and_serves_locally_afterwards() -> TestResult {
|
||||
let bucket = "odm-get-inline";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "inline/report.bin";
|
||||
let body = payload(200 * 1024);
|
||||
let etag = env
|
||||
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
|
||||
.remove(0);
|
||||
let quoted_etag = format!("\"{etag}\"");
|
||||
|
||||
let first = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
|
||||
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
|
||||
assert_eq!(first.header("etag"), Some(quoted_etag.as_str()), "inline answers carry the source ETag");
|
||||
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert_eq!(first.header("accept-ranges"), Some("bytes"));
|
||||
assert_eq!(first.body, body, "the client receives the source bytes");
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "exactly one source GET");
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
|
||||
assert!(
|
||||
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
|
||||
"the inline pull must store the object locally"
|
||||
);
|
||||
let second = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(second.status, 200);
|
||||
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
|
||||
assert_eq!(second.body, body, "the local copy is the source bytes");
|
||||
assert_eq!(second.header("etag"), Some(quoted_etag.as_str()), "preserve_etag keeps the source ETag");
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"the second GET is served locally"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_large_object_streams_through_and_backfills_in_background() -> TestResult {
|
||||
let bucket = "odm-get-large";
|
||||
let env = configured_env(bucket, |spec| spec.policy.inline_max_bytes = 4096).await?;
|
||||
let key = "large/archive.bin";
|
||||
let body = payload(512 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(response.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert_eq!(response.body, body, "the passthrough streams the whole object");
|
||||
|
||||
assert!(
|
||||
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
|
||||
"the background pull must store the object locally"
|
||||
);
|
||||
env.assert_local_present(bucket, key, &body).await;
|
||||
assert_eq!(
|
||||
source_get_ranges(&env, key),
|
||||
vec![None, None],
|
||||
"one passthrough GET plus one background pull, both unranged"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_range_streams_206_and_backfills_the_whole_object() -> TestResult {
|
||||
let bucket = "odm-get-range";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "range/video.bin";
|
||||
let body = payload(100_000);
|
||||
let etag = env
|
||||
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
|
||||
.remove(0);
|
||||
|
||||
let response = env
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.range("bytes=10-19")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.content_range(), Some("bytes 10-19/100000"), "the source's 206 is passed through");
|
||||
assert_eq!(response.content_length(), Some(10));
|
||||
assert_eq!(response.e_tag(), Some(format!("\"{etag}\"").as_str()));
|
||||
assert_eq!(response.body.collect().await?.into_bytes(), body.slice(10..20));
|
||||
assert_eq!(
|
||||
source_get_ranges(&env, key),
|
||||
vec![Some("bytes=10-19".to_string())],
|
||||
"the Range is forwarded"
|
||||
);
|
||||
|
||||
assert!(
|
||||
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
|
||||
"serve_and_backfill must pull the whole object"
|
||||
);
|
||||
env.assert_local_present(bucket, key, &body).await;
|
||||
assert_eq!(
|
||||
source_get_ranges(&env, key),
|
||||
vec![Some("bytes=10-19".to_string()), None],
|
||||
"the background pull fetches the whole object"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_source_not_found_is_404_and_negative_cached() -> TestResult {
|
||||
let bucket = "odm-get-missing";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "missing/nowhere.bin";
|
||||
|
||||
for attempt in 1..=2 {
|
||||
let err = env
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a key missing on both sides is 404");
|
||||
assert_eq!(err.code(), Some("NoSuchKey"), "attempt {attempt}: {err:?}");
|
||||
}
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a source miss never pulls");
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
1,
|
||||
"the second miss stops at the negative cache"
|
||||
);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_with_version_id_does_not_consult_the_source() -> TestResult {
|
||||
let bucket = "odm-get-versioned";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
env.client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let key = "versioned/doc.bin";
|
||||
let body = payload(1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let err = env
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id("11111111-2222-4333-8444-555555555555")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a version read cannot be answered by the source");
|
||||
assert!(
|
||||
matches!(err.code(), Some("NoSuchVersion") | Some("NoSuchKey")),
|
||||
"unexpected error: {err:?}"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
|
||||
// The same key without versionId is still migrated: the gate is per request.
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(response.body, body);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_after_disable_does_not_consult_the_source() -> TestResult {
|
||||
let bucket = "odm-get-disabled";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "disabled/doc.bin";
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(1024))]);
|
||||
|
||||
let response = env.disable(bucket).await?;
|
||||
assert_eq!(response.status, 204, "{}", response.body);
|
||||
|
||||
let err = env
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a disabled bucket answers locally");
|
||||
assert_eq!(err.code(), Some("NoSuchKey"), "{err:?}");
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A HEAD miss is answered from the source but must not store anything: the
|
||||
/// key stays absent locally, so a second HEAD consults the source again. This
|
||||
/// is the smoke-lane guard for the HEAD passthrough (rustfs/backlog#2155).
|
||||
#[tokio::test]
|
||||
async fn head_miss_answers_from_the_source_without_persisting() -> TestResult {
|
||||
let bucket = "odm-head-passthrough";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "head/report.bin";
|
||||
let body = payload(32 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let head = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
|
||||
assert_eq!(head.status, 200, "{}", String::from_utf8_lossy(&head.body));
|
||||
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
|
||||
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert!(head.body.is_empty(), "a HEAD answer carries no body");
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a HEAD must never pull the body");
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
|
||||
let again = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
|
||||
assert_eq!(again.status, 200, "{}", String::from_utf8_lossy(&again.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
2,
|
||||
"nothing was written back, so the second HEAD consults the source again"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// 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.
|
||||
|
||||
//! Self-test of the ODM harness (rustfs/backlog#2151): the fake source's
|
||||
//! migration-facing surface (ListObjectsV2 paging, `Range`, unversioned
|
||||
//! buckets, metadata replay, fault actions) and the two-server environment.
|
||||
//! No ODM behavior is exercised here.
|
||||
|
||||
use super::common::{OdmTestEnv, SeedObject, fake_source_client, start_source_rustfs};
|
||||
use crate::fake_s3_target::{BucketMode, FakeS3Target, FakeS3TargetOptions, FaultAction, Operation, SeedMetadata};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTime};
|
||||
use bytes::Bytes;
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-source";
|
||||
|
||||
/// Position-dependent payload so a misaligned range read is caught.
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
async fn fake_source() -> Result<(FakeS3Target, Client), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let source = FakeS3Target::start().await?;
|
||||
source.create_bucket(SOURCE_BUCKET);
|
||||
let client = fake_source_client(&source);
|
||||
Ok((source, client))
|
||||
}
|
||||
|
||||
/// Full ListObjectsV2 traversal. Returns `(keys, common prefixes, pages)` and
|
||||
/// checks the page shape on the way: every page except the last is full and
|
||||
/// truncated, the last carries no continuation token.
|
||||
async fn list_all(
|
||||
client: &Client,
|
||||
prefix: Option<&str>,
|
||||
delimiter: Option<&str>,
|
||||
start_after: Option<&str>,
|
||||
max_keys: i32,
|
||||
) -> Result<(Vec<String>, Vec<String>, usize), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut keys = Vec::new();
|
||||
let mut prefixes = Vec::new();
|
||||
let mut pages = 0usize;
|
||||
let mut token: Option<String> = None;
|
||||
loop {
|
||||
let page = client
|
||||
.list_objects_v2()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.set_prefix(prefix.map(str::to_string))
|
||||
.set_delimiter(delimiter.map(str::to_string))
|
||||
.set_start_after(start_after.map(str::to_string))
|
||||
.max_keys(max_keys)
|
||||
.set_continuation_token(token.clone())
|
||||
.send()
|
||||
.await?;
|
||||
pages += 1;
|
||||
let page_keys: Vec<String> = page
|
||||
.contents()
|
||||
.iter()
|
||||
.filter_map(|object| object.key().map(str::to_string))
|
||||
.collect();
|
||||
let page_prefixes: Vec<String> = page
|
||||
.common_prefixes()
|
||||
.iter()
|
||||
.filter_map(|common| common.prefix().map(str::to_string))
|
||||
.collect();
|
||||
let entries = page_keys.len() + page_prefixes.len();
|
||||
assert_eq!(page.key_count(), Some(entries as i32), "KeyCount must count keys and prefixes");
|
||||
assert_eq!(page.continuation_token(), token.as_deref(), "the request token must be echoed");
|
||||
keys.extend(page_keys);
|
||||
prefixes.extend(page_prefixes);
|
||||
if page.is_truncated() == Some(true) {
|
||||
assert_eq!(entries as i32, max_keys, "every truncated page must be full");
|
||||
token = Some(
|
||||
page.next_continuation_token()
|
||||
.expect("truncated page must carry a continuation token")
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
assert!(page.next_continuation_token().is_none(), "final page must not carry a token");
|
||||
return Ok((keys, prefixes, pages));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_list_objects_v2_paginates_with_delimiter() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
let mut expected_keys = BTreeSet::new();
|
||||
for directory in 0..30 {
|
||||
for file in 0..30 {
|
||||
expected_keys.insert(format!("d{directory:02}/k{file:03}"));
|
||||
}
|
||||
}
|
||||
for index in 0..100 {
|
||||
expected_keys.insert(format!("top-{index:03}"));
|
||||
}
|
||||
assert_eq!(expected_keys.len(), 1000);
|
||||
for key in &expected_keys {
|
||||
source.put_seed_object(SOURCE_BUCKET, key.clone(), Bytes::from(key.clone()), &SeedMetadata::new());
|
||||
}
|
||||
// A key whose current version is a delete marker must stay hidden.
|
||||
client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("hidden/marker")
|
||||
.body(ByteStream::from_static(b"gone"))
|
||||
.send()
|
||||
.await?;
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("hidden/marker")
|
||||
.send()
|
||||
.await?;
|
||||
let expected_sorted: Vec<String> = expected_keys.iter().cloned().collect();
|
||||
let expected_prefixes: Vec<String> = (0..30).map(|directory| format!("d{directory:02}/")).collect();
|
||||
let expected_top: Vec<String> = (0..100).map(|index| format!("top-{index:03}")).collect();
|
||||
|
||||
// Flat traversal in byte order, 1000 keys in pages of 7.
|
||||
let (keys, prefixes, pages) = list_all(&client, None, None, None, 7).await?;
|
||||
assert_eq!(keys, expected_sorted);
|
||||
assert!(prefixes.is_empty());
|
||||
assert_eq!(pages, 143);
|
||||
|
||||
// Delimiter folding: 30 common prefixes then 100 top-level keys, pages of 7.
|
||||
let (keys, prefixes, pages) = list_all(&client, None, Some("/"), None, 7).await?;
|
||||
assert_eq!(prefixes, expected_prefixes);
|
||||
assert_eq!(keys, expected_top);
|
||||
assert_eq!(pages, 19);
|
||||
|
||||
// Empty prefix equals no prefix.
|
||||
let (keys, _, _) = list_all(&client, Some(""), None, None, 1000).await?;
|
||||
assert_eq!(keys, expected_sorted);
|
||||
|
||||
// No match: empty, not truncated, no token.
|
||||
let (keys, prefixes, pages) = list_all(&client, Some("zzz/"), Some("/"), None, 7).await?;
|
||||
assert!(keys.is_empty() && prefixes.is_empty());
|
||||
assert_eq!(pages, 1);
|
||||
let (keys, _, _) = list_all(&client, Some("hidden/"), None, None, 7).await?;
|
||||
assert!(keys.is_empty(), "a current delete marker must hide its key");
|
||||
|
||||
// Exact page boundary: 30 keys under one directory, max-keys=30 -> one
|
||||
// untruncated page.
|
||||
let (keys, prefixes, pages) = list_all(&client, Some("d05/"), Some("/"), None, 30).await?;
|
||||
assert_eq!(keys.len(), 30);
|
||||
assert!(prefixes.is_empty());
|
||||
assert_eq!(pages, 1);
|
||||
|
||||
// start-after skips keys at or before the marker.
|
||||
let (keys, _, _) = list_all(&client, None, None, Some("top-097"), 1000).await?;
|
||||
assert_eq!(keys, ["top-098", "top-099"]);
|
||||
|
||||
// max-keys is clamped to 1000; exactly 1000 keys fit in one page.
|
||||
let (keys, _, pages) = list_all(&client, None, None, None, 5000).await?;
|
||||
assert_eq!(keys.len(), 1000);
|
||||
assert_eq!(pages, 1);
|
||||
|
||||
let listings: Vec<_> = source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|record| record.operation == Operation::ListObjectsV2)
|
||||
.collect();
|
||||
assert!(listings.len() >= 143 + 19);
|
||||
assert!(listings.iter().any(|record| record.prefix.as_deref() == Some("d05/")));
|
||||
assert!(
|
||||
listings.iter().any(|record| record.continuation_token.is_some()),
|
||||
"resumed pages must journal their continuation token"
|
||||
);
|
||||
assert!(listings.iter().all(|record| record.user_agent.is_some()));
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_range_get_variants_and_416() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
let body = payload(1000);
|
||||
source.put_seed_object(SOURCE_BUCKET, "ranged", body.clone(), &SeedMetadata::new());
|
||||
|
||||
for (range, expected_range, expected_slice) in [
|
||||
("bytes=10-19", "bytes 10-19/1000", &body[10..20]),
|
||||
("bytes=990-", "bytes 990-999/1000", &body[990..]),
|
||||
("bytes=-5", "bytes 995-999/1000", &body[995..]),
|
||||
("bytes=0-5000", "bytes 0-999/1000", &body[..]),
|
||||
] {
|
||||
let output = client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("ranged")
|
||||
.range(range)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(output.content_range(), Some(expected_range), "{range}");
|
||||
assert_eq!(output.accept_ranges(), Some("bytes"), "{range}");
|
||||
assert_eq!(output.content_length(), Some(expected_slice.len() as i64), "{range}");
|
||||
let collected = output.body.collect().await?.into_bytes();
|
||||
assert_eq!(collected.as_ref(), expected_slice, "{range}");
|
||||
}
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("ranged")
|
||||
.range("bytes=10-19")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(head.content_range(), Some("bytes 10-19/1000"));
|
||||
assert_eq!(head.content_length(), Some(10));
|
||||
|
||||
for range in ["bytes=1000-", "bytes=-0"] {
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("ranged")
|
||||
.range(range)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("unsatisfiable range must fail");
|
||||
let response = error.raw_response().expect("416 must retain the raw response");
|
||||
assert_eq!(response.status().as_u16(), 416, "{range}");
|
||||
assert_eq!(response.headers().get("content-range"), Some("bytes */1000"), "{range}");
|
||||
assert_eq!(error.code(), Some("InvalidRange"), "{range}");
|
||||
}
|
||||
|
||||
let ranged = source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.operation == Operation::GetObject && record.range.as_deref() == Some("bytes=10-19"))
|
||||
.expect("the Range header must be journaled verbatim");
|
||||
assert_eq!(ranged.key.as_deref(), Some("ranged"));
|
||||
assert!(source.count_requests(Operation::GetObject, "ranged") >= 6);
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_unversioned_bucket_overwrites_and_deletes() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
source.create_bucket_with_mode("plain-source", BucketMode::Unversioned);
|
||||
let versioning = client.get_bucket_versioning().bucket("plain-source").send().await?;
|
||||
assert!(versioning.status().is_none(), "unversioned bucket must report no versioning status");
|
||||
|
||||
let first = client
|
||||
.put_object()
|
||||
.bucket("plain-source")
|
||||
.key("doc")
|
||||
.body(ByteStream::from_static(b"first"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(first.version_id().is_none());
|
||||
let second = client
|
||||
.put_object()
|
||||
.bucket("plain-source")
|
||||
.key("doc")
|
||||
.body(ByteStream::from_static(b"second"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(second.version_id().is_none());
|
||||
let get = client.get_object().bucket("plain-source").key("doc").send().await?;
|
||||
assert!(get.version_id().is_none(), "GET must not return x-amz-version-id");
|
||||
assert_eq!(get.body.collect().await?.into_bytes().as_ref(), b"second");
|
||||
let head = client.head_object().bucket("plain-source").key("doc").send().await?;
|
||||
assert!(head.version_id().is_none(), "HEAD must not return x-amz-version-id");
|
||||
assert_eq!(source.stored_versions("plain-source", "doc").len(), 1, "overwrite must replace in place");
|
||||
|
||||
let deleted = client.delete_object().bucket("plain-source").key("doc").send().await?;
|
||||
assert!(deleted.delete_marker().is_none() && deleted.version_id().is_none());
|
||||
let missing = client
|
||||
.get_object()
|
||||
.bucket("plain-source")
|
||||
.key("doc")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("deleted object must be gone");
|
||||
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(missing.code(), Some("NoSuchKey"));
|
||||
let missing_head = client
|
||||
.head_object()
|
||||
.bucket("plain-source")
|
||||
.key("doc")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("deleted object must fail HEAD");
|
||||
assert_eq!(missing_head.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert!(source.stored_versions("plain-source", "doc").is_empty(), "DELETE must not leave a marker");
|
||||
|
||||
// The versioned bucket on the same target keeps its version ids.
|
||||
let versioned = client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("doc")
|
||||
.body(ByteStream::from_static(b"versioned"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(versioned.version_id().is_some());
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_replays_standard_and_user_metadata() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
let body = payload(4096);
|
||||
let expected_etag = format!("\"{}\"", {
|
||||
use md5::Digest as _;
|
||||
hex_simd::encode_to_string(md5::Md5::digest(&body), hex_simd::AsciiCase::Lower)
|
||||
});
|
||||
// 2026-01-01T00:00:00Z rendered as an HTTP date by the SDK.
|
||||
let expires = DateTime::from_secs(1_767_225_600);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("meta")
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.content_type("application/x-odm")
|
||||
.content_encoding("gzip")
|
||||
.content_disposition("attachment; filename=\"meta.bin\"")
|
||||
.content_language("en-US")
|
||||
.cache_control("max-age=60")
|
||||
.expires(expires)
|
||||
.metadata("Foo-Bar", "mixed case name")
|
||||
.metadata("UPPER", "upper name")
|
||||
.metadata("already-lower", "lower name")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let head = client.head_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
|
||||
let get = client.get_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
|
||||
for (label, content_type, content_encoding, content_disposition, content_language, cache_control, expires_string, e_tag) in [
|
||||
(
|
||||
"HEAD",
|
||||
head.content_type(),
|
||||
head.content_encoding(),
|
||||
head.content_disposition(),
|
||||
head.content_language(),
|
||||
head.cache_control(),
|
||||
head.expires_string(),
|
||||
head.e_tag(),
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
get.content_type(),
|
||||
get.content_encoding(),
|
||||
get.content_disposition(),
|
||||
get.content_language(),
|
||||
get.cache_control(),
|
||||
get.expires_string(),
|
||||
get.e_tag(),
|
||||
),
|
||||
] {
|
||||
assert_eq!(content_type, Some("application/x-odm"), "{label}");
|
||||
assert_eq!(content_encoding, Some("gzip"), "{label}");
|
||||
assert_eq!(content_disposition, Some("attachment; filename=\"meta.bin\""), "{label}");
|
||||
assert_eq!(content_language, Some("en-US"), "{label}");
|
||||
assert_eq!(cache_control, Some("max-age=60"), "{label}");
|
||||
assert_eq!(expires_string, Some("Thu, 01 Jan 2026 00:00:00 GMT"), "{label}");
|
||||
assert_eq!(e_tag, Some(expected_etag.as_str()), "{label}");
|
||||
}
|
||||
for metadata in [head.metadata(), get.metadata()] {
|
||||
let metadata = metadata.expect("user metadata must be replayed");
|
||||
assert_eq!(metadata.get("foo-bar").map(String::as_str), Some("mixed case name"));
|
||||
assert_eq!(metadata.get("upper").map(String::as_str), Some("upper name"));
|
||||
assert_eq!(metadata.get("already-lower").map(String::as_str), Some("lower name"));
|
||||
assert!(!metadata.contains_key("Foo-Bar") && !metadata.contains_key("UPPER"));
|
||||
}
|
||||
assert!(head.last_modified().is_some());
|
||||
assert_eq!(head.last_modified(), get.last_modified());
|
||||
assert_eq!(head.content_length(), Some(4096));
|
||||
assert_eq!(get.body.collect().await?.into_bytes(), body);
|
||||
|
||||
// Seeded objects replay the same way.
|
||||
let seeded_etag = source.put_seed_object(
|
||||
SOURCE_BUCKET,
|
||||
"seeded",
|
||||
Bytes::from_static(b"seeded"),
|
||||
&SeedMetadata::new()
|
||||
.content_type("text/plain")
|
||||
.content_encoding("identity")
|
||||
.cache_control("no-store")
|
||||
.user_metadata("Origin", "seed"),
|
||||
);
|
||||
let seeded = client.head_object().bucket(SOURCE_BUCKET).key("seeded").send().await?;
|
||||
assert_eq!(seeded.e_tag(), Some(format!("\"{seeded_etag}\"").as_str()));
|
||||
assert_eq!(seeded.content_type(), Some("text/plain"));
|
||||
assert_eq!(seeded.content_encoding(), Some("identity"));
|
||||
assert_eq!(seeded.cache_control(), Some("no-store"));
|
||||
assert_eq!(
|
||||
seeded
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.get("origin"))
|
||||
.map(String::as_str),
|
||||
Some("seed")
|
||||
);
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_fault_actions_truncate_stall_and_status() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
let body = payload(4096);
|
||||
source.put_seed_object(SOURCE_BUCKET, "faulty", body.clone(), &SeedMetadata::new());
|
||||
|
||||
// TruncateBodyAt: headers promise 4096 bytes, the body ends after 100.
|
||||
source.inject_for_key(Operation::GetObject, "faulty", FaultAction::TruncateBodyAt(100), 1);
|
||||
let truncated = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
|
||||
assert_eq!(truncated.content_length(), Some(4096));
|
||||
let short_read = truncated
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect_err("a truncated body must fail to collect");
|
||||
let short_read = short_read.to_string();
|
||||
assert!(!short_read.is_empty());
|
||||
|
||||
// ResponseStatus: arbitrary status with the matching S3 error code.
|
||||
for (code, expected_code) in [
|
||||
(429u16, "SlowDown"),
|
||||
(404, "NoSuchKey"),
|
||||
(500, "InternalError"),
|
||||
(503, "ServiceUnavailable"),
|
||||
] {
|
||||
source.inject(Operation::GetObject, FaultAction::ResponseStatus(code), 1);
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("faulty")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("scripted status must fail");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(code));
|
||||
assert_eq!(error.code(), Some(expected_code));
|
||||
}
|
||||
|
||||
// Stall: the fully computed response is held before its first byte.
|
||||
source.inject(Operation::HeadObject, FaultAction::Stall(Duration::from_millis(400)), 1);
|
||||
let started = Instant::now();
|
||||
let stalled = client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
|
||||
assert!(started.elapsed() >= Duration::from_millis(350), "stall must delay the first byte");
|
||||
assert_eq!(stalled.content_length(), Some(4096));
|
||||
let post_stall_started = Instant::now();
|
||||
client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
|
||||
assert!(post_stall_started.elapsed() < Duration::from_millis(350), "stall is consumed once");
|
||||
|
||||
// The object is intact once the script is drained.
|
||||
let intact = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
|
||||
assert_eq!(intact.body.collect().await?.into_bytes(), body);
|
||||
|
||||
assert_eq!(source.count_requests(Operation::GetObject, "faulty"), 6);
|
||||
assert_eq!(source.count_requests(Operation::HeadObject, "faulty"), 2);
|
||||
assert_eq!(source.count_requests(Operation::GetObject, "other"), 0);
|
||||
let records = source.requests();
|
||||
assert!(
|
||||
records.iter().all(|record| record
|
||||
.user_agent
|
||||
.as_deref()
|
||||
.is_some_and(|agent| agent.contains("aws-sdk-rust"))),
|
||||
"the SDK user agent must be journaled"
|
||||
);
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.any(|record| record.fault == Some(FaultAction::TruncateBodyAt(100)))
|
||||
);
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.any(|record| record.fault == Some(FaultAction::Stall(Duration::from_millis(400))))
|
||||
);
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_raised_object_cap_accepts_large_put() -> TestResult {
|
||||
let source = FakeS3Target::start_with_options(FakeS3TargetOptions {
|
||||
max_object_bytes: 96 * 1024 * 1024,
|
||||
})
|
||||
.await?;
|
||||
source.create_bucket(SOURCE_BUCKET);
|
||||
let client = fake_source_client(&source);
|
||||
let len = 64 * 1024 * 1024 + 1;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("large")
|
||||
.body(ByteStream::from(vec![7u8; len]))
|
||||
.send()
|
||||
.await?;
|
||||
let head = client.head_object().bucket(SOURCE_BUCKET).key("large").send().await?;
|
||||
assert_eq!(head.content_length(), Some(len as i64));
|
||||
let tail = client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("large")
|
||||
.range("bytes=-1")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(tail.content_range(), Some(format!("bytes {}-{}/{len}", len - 1, len - 1).as_str()));
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn odm_env_starts_rustfs_and_fake_source() -> TestResult {
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket(SOURCE_BUCKET);
|
||||
let local_bucket = "odm-local";
|
||||
env.rustfs.create_test_bucket(local_bucket).await?;
|
||||
|
||||
let etags = env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("seed/a", Bytes::from_static(b"alpha")),
|
||||
SeedObject::new("seed/b", Bytes::from_static(b"beta"))
|
||||
.with_metadata(SeedMetadata::new().content_type("text/plain").user_metadata("Kind", "seed")),
|
||||
],
|
||||
);
|
||||
assert_eq!(etags.len(), 2);
|
||||
assert!(env.source.requests().is_empty(), "seeding must not touch the journal");
|
||||
let source_client = env.source_client();
|
||||
let seeded = source_client.head_object().bucket(SOURCE_BUCKET).key("seed/b").send().await?;
|
||||
assert_eq!(seeded.content_type(), Some("text/plain"));
|
||||
assert_eq!(seeded.e_tag(), Some(format!("\"{}\"", etags[1]).as_str()));
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, "seed/b"), 1);
|
||||
|
||||
env.assert_local_absent(local_bucket, "seed/a").await;
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(local_bucket)
|
||||
.key("seed/a")
|
||||
.body(ByteStream::from_static(b"alpha"))
|
||||
.send()
|
||||
.await?;
|
||||
env.assert_local_present(local_bucket, "seed/a", b"alpha").await;
|
||||
env.assert_local_absent(local_bucket, "seed/b").await;
|
||||
|
||||
let spec = env.fake_source_spec(SOURCE_BUCKET).to_json();
|
||||
assert_eq!(spec["version"], 1);
|
||||
assert_eq!(spec["enabled"], true);
|
||||
assert_eq!(spec["source"]["provider"], "s3");
|
||||
assert_eq!(spec["source"]["endpoint"], env.source.endpoint());
|
||||
assert_eq!(spec["source"]["bucket"], SOURCE_BUCKET);
|
||||
assert_eq!(spec["source"]["credentials"]["secret_key"], "fake-secret");
|
||||
assert_eq!(spec["policy"]["source_timeout"]["first_byte_ms"], 15_000);
|
||||
assert!(spec["policy"]["bandwidth_limit_bytes_per_sec"].is_null());
|
||||
let debug = format!("{:?}", env.fake_source_spec(SOURCE_BUCKET));
|
||||
assert!(!debug.contains("fake-secret"), "Debug output must redact the secret");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_source_rustfs_round_trips_put_get() -> TestResult {
|
||||
let env = OdmTestEnv::start().await?;
|
||||
let source = start_source_rustfs().await?;
|
||||
assert_ne!(source.url, env.rustfs.url, "the source must be a separate instance");
|
||||
|
||||
source.create_test_bucket(SOURCE_BUCKET).await?;
|
||||
let source_client = source.create_s3_client();
|
||||
let body = payload(70_000);
|
||||
let put = source_client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("real/object")
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.content_type("application/octet-stream")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(put.e_tag().is_some());
|
||||
let get = source_client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("real/object")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(get.content_type(), Some("application/octet-stream"));
|
||||
assert_eq!(get.body.collect().await?.into_bytes(), body);
|
||||
|
||||
let visible_to_primary = env
|
||||
.client
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await?
|
||||
.buckets()
|
||||
.iter()
|
||||
.any(|bucket| bucket.name() == Some(SOURCE_BUCKET));
|
||||
assert!(!visible_to_primary, "the two servers must not share state");
|
||||
let spec = super::common::OdmSourceSpec::for_rustfs_source(&source, SOURCE_BUCKET).to_json();
|
||||
assert_eq!(spec["source"]["provider"], "rustfs");
|
||||
assert_eq!(spec["source"]["endpoint"], source.url);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
// 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.
|
||||
|
||||
//! How on-demand migration composes with the rest of the bucket surface
|
||||
//! (rustfs/backlog#2158): default encryption, Object Lock, quota,
|
||||
//! notifications, replication, versioning and delete markers, the disable
|
||||
//! switch, and the admin view.
|
||||
//!
|
||||
//! A pulled object goes through the internal put path, so it must be
|
||||
//! indistinguishable from a client PUT. Each case pins both the resulting
|
||||
//! local object and what the source was asked for.
|
||||
|
||||
use super::common::{
|
||||
AdminResponse, BoxError, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env,
|
||||
start_configured_env_with,
|
||||
};
|
||||
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
|
||||
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
|
||||
use crate::object_lock::common::put_object_lock_configuration;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
|
||||
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault,
|
||||
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, VersioningConfiguration,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use local_ip_address::local_ip;
|
||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-interaction-source";
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// `userIdentity.principalId` every write-back event carries.
|
||||
const ODM_PRINCIPAL_ID: &str = "rustfs-on-demand-migration";
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
async fn admin(
|
||||
env: &RustFSTestEnvironment,
|
||||
method: http::Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> Result<AdminResponse, BoxError> {
|
||||
let url = format!("{}{path}", env.url);
|
||||
let body = body.map(|value| serde_json::to_vec(&value)).transpose()?;
|
||||
let content_type = body.is_some().then_some("application/json");
|
||||
let response = signed_request(method, &url, &env.access_key, &env.secret_key, body, content_type).await?;
|
||||
Ok(AdminResponse {
|
||||
status: response.status().as_u16(),
|
||||
body: response.text().await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn enable_versioning(env: &OdmTestEnv, bucket: &str) -> TestResult {
|
||||
env.client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 12: a bucket that encrypts by default stores the pulled object
|
||||
/// encrypted, and it reads back as plaintext afterwards without touching the
|
||||
/// source again.
|
||||
#[tokio::test]
|
||||
async fn test_odm_pulled_object_uses_bucket_default_encryption() -> TestResult {
|
||||
let bucket = "odm-interaction-sse";
|
||||
let env = start_configured_env_with(
|
||||
OdmEnvOptions {
|
||||
local_kms: true,
|
||||
..OdmEnvOptions::default()
|
||||
},
|
||||
bucket,
|
||||
SOURCE_BUCKET,
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
env.client
|
||||
.put_bucket_encryption()
|
||||
.bucket(bucket)
|
||||
.server_side_encryption_configuration(
|
||||
ServerSideEncryptionConfiguration::builder()
|
||||
.rules(
|
||||
ServerSideEncryptionRule::builder()
|
||||
.apply_server_side_encryption_by_default(
|
||||
ServerSideEncryptionByDefault::builder()
|
||||
.sse_algorithm(ServerSideEncryption::Aes256)
|
||||
.build()?,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let key = "sse/report.bin";
|
||||
let body = payload(128 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let first = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
|
||||
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(first.body, body);
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
|
||||
|
||||
let second = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
|
||||
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "the second read is local");
|
||||
assert_eq!(
|
||||
second.header("x-amz-server-side-encryption"),
|
||||
Some("AES256"),
|
||||
"the write-back honours the bucket default encryption"
|
||||
);
|
||||
assert_eq!(second.body, body, "the encrypted copy reads back as the source bytes");
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"the encrypted local copy serves the second read"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 13: a pulled object inherits the bucket's default Object Lock
|
||||
/// retention, so it cannot be deleted while the retention holds.
|
||||
#[tokio::test]
|
||||
async fn test_odm_pulled_object_inherits_object_lock_retention() -> TestResult {
|
||||
let bucket = "odm-interaction-object-lock";
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
|
||||
env.client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
put_object_lock_configuration(&env.client, bucket, ObjectLockRetentionMode::Compliance, Some(1), None).await?;
|
||||
let spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
env.configure_and_wait(bucket, &spec).await?;
|
||||
|
||||
let key = "locked/record.bin";
|
||||
let body = payload(32 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let pulled = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
|
||||
assert_eq!(pulled.body, body);
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
|
||||
|
||||
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(
|
||||
head.object_lock_mode().map(|mode| mode.as_str()),
|
||||
Some("COMPLIANCE"),
|
||||
"the default retention mode is applied to the pulled object"
|
||||
);
|
||||
assert!(head.object_lock_retain_until_date().is_some(), "a retain-until date is set");
|
||||
|
||||
let version_id = head.version_id().ok_or("an Object Lock bucket is versioned")?.to_string();
|
||||
let error = env
|
||||
.client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(&version_id)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a COMPLIANCE-retained version cannot be deleted");
|
||||
assert_eq!(error.code(), Some("AccessDenied"), "{error:?}");
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"the rejected delete never consults the source"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 14: the write-back obeys the bucket quota. The client is still
|
||||
/// served from the source, but nothing is stored and the failure is counted.
|
||||
#[tokio::test]
|
||||
async fn test_odm_write_back_respects_the_bucket_quota() -> TestResult {
|
||||
let bucket = "odm-interaction-quota";
|
||||
let env = start_configured_env_with(
|
||||
OdmEnvOptions {
|
||||
env: vec![("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")],
|
||||
..OdmEnvOptions::default()
|
||||
},
|
||||
bucket,
|
||||
SOURCE_BUCKET,
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Fill the bucket past the quota it is about to get, so the write-back's
|
||||
// admission check has to reject it.
|
||||
let filler = payload(2 * 1024 * 1024);
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("quota/filler.bin")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(filler.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
wait_for_bucket_usage(&env, bucket, filler.len() as u64).await?;
|
||||
set_bucket_quota(&env, bucket, 1024 * 1024).await?;
|
||||
|
||||
let key = "quota/oversized.bin";
|
||||
let body = payload(2 * 1024 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(response.body, body, "a full bucket still serves the client from the source");
|
||||
|
||||
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/quota", 1, SETTLE)
|
||||
.await?;
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"the rejected write-back is not retried against the source"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The quota route answers 503 until the durable-quota capability is
|
||||
/// confirmed on the fresh single-node deployment, so the write is retried.
|
||||
async fn set_bucket_quota(env: &OdmTestEnv, bucket: &str, quota_bytes: u64) -> TestResult {
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let response = admin(
|
||||
&env.rustfs,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/quota/{bucket}"),
|
||||
Some(serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" })),
|
||||
)
|
||||
.await?;
|
||||
if response.status < 300 {
|
||||
return Ok(());
|
||||
}
|
||||
if response.status != 503 || Instant::now() >= deadline {
|
||||
return Err(format!("set quota for {bucket}: {} {}", response.status, response.body).into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_bucket_usage(env: &OdmTestEnv, bucket: &str, at_least: u64) -> TestResult {
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let response = admin(&env.rustfs, http::Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
|
||||
if response.status == 200 {
|
||||
let usage = serde_json::from_str::<Value>(&response.body)?
|
||||
.get("current_usage")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
if usage >= at_least {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("bucket usage for {bucket} did not reach {at_least} bytes: {}", response.body).into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Case 15: a pull emits an ordinary creation event attributed to the
|
||||
/// migration principal, and `emit_events=false` silences it.
|
||||
#[tokio::test]
|
||||
async fn test_odm_pull_emits_object_created_events_unless_disabled() -> TestResult {
|
||||
let emitting = "odm-interaction-events";
|
||||
let silent = "odm-interaction-events-off";
|
||||
// The collector binds first: the outbound guard rejects a webhook
|
||||
// endpoint on a private address unless its origin is allowed at startup.
|
||||
let (endpoint, mut events) = spawn_event_collector().await?;
|
||||
let allowed_origin = reqwest::Url::parse(&endpoint)?.origin().ascii_serialization();
|
||||
let env = start_configured_env_with(
|
||||
OdmEnvOptions {
|
||||
env: vec![(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())],
|
||||
..OdmEnvOptions::default()
|
||||
},
|
||||
emitting,
|
||||
SOURCE_BUCKET,
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
let mut silent_spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
silent_spec.policy.emit_events = false;
|
||||
env.configure_and_wait(silent, &silent_spec).await?;
|
||||
|
||||
let target = "odm-events";
|
||||
let switches = admin(
|
||||
&env.rustfs,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/module-switches",
|
||||
Some(serde_json::json!({ "notify_enabled": true, "audit_enabled": false })),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(switches.status, 200, "{}", switches.body);
|
||||
let queue_dir = format!("{}/notify-queue-{target}", env.rustfs.temp_dir);
|
||||
tokio::fs::create_dir_all(&queue_dir).await?;
|
||||
let configured = admin(
|
||||
&env.rustfs,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/target/notify_webhook/{target}"),
|
||||
Some(serde_json::json!({
|
||||
"key_values": [
|
||||
{ "key": "endpoint", "value": endpoint },
|
||||
{ "key": "queue_dir", "value": queue_dir },
|
||||
]
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
wait_for_target_online(&env.rustfs, target).await?;
|
||||
for bucket in [emitting, silent] {
|
||||
put_notification_config(&env, bucket, target).await?;
|
||||
}
|
||||
|
||||
// Control: an ordinary client PUT must produce an event, so a missing
|
||||
// one below is about the write-back and not about the pipeline.
|
||||
let control_key = "events/control.bin";
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(emitting)
|
||||
.key(control_key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(payload(1024)))
|
||||
.send()
|
||||
.await?;
|
||||
let control = wait_for_event(&mut events, emitting, control_key, Duration::from_secs(60))
|
||||
.await
|
||||
.ok_or("the notification pipeline delivered no event for a plain PUT")?;
|
||||
assert_eq!(
|
||||
control.pointer("/eventName").and_then(Value::as_str),
|
||||
Some("s3:ObjectCreated:Put"),
|
||||
"{control}"
|
||||
);
|
||||
|
||||
let emitting_key = "events/pulled.bin";
|
||||
let silent_key = "events/quiet.bin";
|
||||
let body = payload(16 * 1024);
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new(emitting_key, body.clone()),
|
||||
SeedObject::new(silent_key, body.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
for (bucket, key) in [(emitting, emitting_key), (silent, silent_key)] {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{bucket}: {}", String::from_utf8_lossy(&response.body));
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{bucket}/{key} must be stored");
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "{bucket}/{key}");
|
||||
}
|
||||
|
||||
let record = wait_for_event(&mut events, emitting, emitting_key, Duration::from_secs(60))
|
||||
.await
|
||||
.ok_or("no creation event for the pulled object")?;
|
||||
assert_eq!(
|
||||
record.pointer("/eventName").and_then(Value::as_str),
|
||||
Some("s3:ObjectCreated:Put"),
|
||||
"{record}"
|
||||
);
|
||||
assert_eq!(
|
||||
record.pointer("/userIdentity/principalId").and_then(Value::as_str),
|
||||
Some(ODM_PRINCIPAL_ID),
|
||||
"{record}"
|
||||
);
|
||||
|
||||
// The silent bucket's object landed before the event above was observed,
|
||||
// so a missing event here is a decision, not a race.
|
||||
assert!(
|
||||
wait_for_event(&mut events, silent, silent_key, Duration::from_secs(5))
|
||||
.await
|
||||
.is_none(),
|
||||
"emit_events=false must not publish a creation event"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn put_notification_config(env: &OdmTestEnv, bucket: &str, target: &str) -> TestResult {
|
||||
let queue = QueueConfiguration::builder()
|
||||
.id(format!("{bucket}-rule"))
|
||||
.queue_arn(format!("arn:rustfs:sqs:us-east-1:{target}:webhook"))
|
||||
.events(Event::from("s3:ObjectCreated:*"))
|
||||
.filter(
|
||||
NotificationConfigurationFilter::builder()
|
||||
.key(
|
||||
S3KeyFilter::builder()
|
||||
.filter_rules(FilterRule::builder().name(FilterRuleName::Prefix).value("events/").build())
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build()?;
|
||||
env.client
|
||||
.put_bucket_notification_configuration()
|
||||
.bucket(bucket)
|
||||
.notification_configuration(NotificationConfiguration::builder().queue_configurations(queue).build())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_target_online(env: &RustFSTestEnvironment, target: &str) -> TestResult {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let response = admin(env, http::Method::GET, "/rustfs/admin/v3/target/list", None).await?;
|
||||
if response.status == 200 {
|
||||
let body: Value = serde_json::from_str(&response.body)?;
|
||||
let online = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
|
||||
endpoints.iter().any(|endpoint| {
|
||||
endpoint["account_id"].as_str() == Some(target) && endpoint["status"].as_str() == Some("online")
|
||||
})
|
||||
});
|
||||
if online {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("webhook target {target} did not come online: {}", response.body).into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal HTTP receiver: answers everything 200 (so the target's
|
||||
/// reachability probe reports online) and forwards parsed POST bodies.
|
||||
async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Value>), BoxError> {
|
||||
let listener = TcpListener::bind("0.0.0.0:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let endpoint = format!("http://{}/events", std::net::SocketAddr::new(local_ip()?, port));
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut stream, _)) = listener.accept().await {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 4096];
|
||||
let mut content_length = 0usize;
|
||||
let mut header_end = None;
|
||||
while header_end.is_none() {
|
||||
match stream.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
|
||||
}
|
||||
header_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
|
||||
}
|
||||
let header_end = header_end.expect("loop exits only with a header end");
|
||||
let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
|
||||
for line in headers.split("\r\n").skip(1) {
|
||||
if let Some((name, value)) = line.split_once(':')
|
||||
&& name.trim().eq_ignore_ascii_case("content-length")
|
||||
{
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let body_offset = header_end + 4;
|
||||
while buffer.len() - body_offset < content_length {
|
||||
match stream.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
|
||||
}
|
||||
}
|
||||
let _ = stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||
.await;
|
||||
let _ = stream.shutdown().await;
|
||||
if let Ok(value) = serde_json::from_slice::<Value>(&buffer[body_offset..body_offset + content_length]) {
|
||||
let _ = tx.send(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok((endpoint, rx))
|
||||
}
|
||||
|
||||
/// The first delivered record for `bucket`/`key`, or `None` on timeout.
|
||||
async fn wait_for_event(
|
||||
events: &mut mpsc::UnboundedReceiver<Value>,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
timeout: Duration,
|
||||
) -> Option<Value> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.checked_duration_since(Instant::now())?;
|
||||
let envelope = tokio::time::timeout(remaining, events.recv()).await.ok()??;
|
||||
for record in envelope["Records"].as_array().into_iter().flatten() {
|
||||
// S3 event notifications URL-encode the object key.
|
||||
let record_key = record.pointer("/s3/object/key").and_then(Value::as_str).map(|raw| {
|
||||
urlencoding::decode(raw)
|
||||
.map(|decoded| decoded.into_owned())
|
||||
.unwrap_or_else(|_| raw.to_string())
|
||||
});
|
||||
if record.pointer("/s3/bucket/name").and_then(Value::as_str) == Some(bucket) && record_key.as_deref() == Some(key) {
|
||||
return Some(record.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Case 16: a pulled object enters the replication pipeline like any other
|
||||
/// write, and a configuration whose source is one of the bucket's own
|
||||
/// replication targets is rejected.
|
||||
#[tokio::test]
|
||||
async fn test_odm_pulled_object_replicates_and_target_as_source_is_rejected() -> TestResult {
|
||||
let bucket = "odm-interaction-replication";
|
||||
let replica_bucket = "odm-replica";
|
||||
let fast_env = replication_fast_env();
|
||||
let env = start_configured_env_with(
|
||||
OdmEnvOptions {
|
||||
env: fast_env.clone(),
|
||||
..OdmEnvOptions::default()
|
||||
},
|
||||
bucket,
|
||||
SOURCE_BUCKET,
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
let replica = FakeS3Target::start().await?;
|
||||
replica.create_bucket(replica_bucket);
|
||||
|
||||
enable_versioning(&env, bucket).await?;
|
||||
let arn = set_remote_target(&env.rustfs, bucket, &replica.address(), replica_bucket).await?;
|
||||
put_bucket_replication(&env.rustfs, bucket, &arn).await?;
|
||||
|
||||
let key = "replicated/asset.bin";
|
||||
let body = payload(64 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body);
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
|
||||
|
||||
let deadline = Instant::now() + SETTLE;
|
||||
while !replica.has_object(replica_bucket, key) {
|
||||
assert!(Instant::now() < deadline, "the pulled object was never replicated to the target");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"replication reads the local copy, never the migration source"
|
||||
);
|
||||
|
||||
let looping = OdmSourceSpec::for_fake_source(&replica, replica_bucket);
|
||||
let rejected = env.configure_source(bucket, &looping).await?;
|
||||
assert_eq!(
|
||||
rejected.status, 400,
|
||||
"a bucket may not migrate from its own replication target: {}",
|
||||
rejected.body
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_remote_target(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
endpoint: &str,
|
||||
target_bucket: &str,
|
||||
) -> Result<String, BoxError> {
|
||||
let response = admin(
|
||||
env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/set-remote-target?bucket={}", urlencoding::encode(bucket)),
|
||||
Some(serde_json::json!({
|
||||
"endpoint": endpoint,
|
||||
"credentials": { "accessKey": FAKE_ACCESS_KEY, "secretKey": FAKE_SECRET_KEY },
|
||||
"targetbucket": target_bucket,
|
||||
"secure": false,
|
||||
"skipTlsVerify": false,
|
||||
"type": "replication"
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
if response.status != 200 {
|
||||
return Err(format!("set remote target: {} {}", response.status, response.body).into());
|
||||
}
|
||||
Ok(serde_json::from_str(&response.body)?)
|
||||
}
|
||||
|
||||
async fn put_bucket_replication(env: &RustFSTestEnvironment, bucket: &str, arn: &str) -> TestResult {
|
||||
let body = format!(
|
||||
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Role></Role>
|
||||
<Rule>
|
||||
<ID>odm-rule</ID>
|
||||
<Priority>1</Priority>
|
||||
<Status>Enabled</Status>
|
||||
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
|
||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||
<Destination><Bucket>{arn}</Bucket></Destination>
|
||||
</Rule>
|
||||
</ReplicationConfiguration>"#
|
||||
);
|
||||
let url = format!("{}/{bucket}?replication", env.url);
|
||||
let response = signed_request(
|
||||
http::Method::PUT,
|
||||
&url,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
Some(body.into_bytes()),
|
||||
Some("application/xml"),
|
||||
)
|
||||
.await?;
|
||||
if response.status() != 200 {
|
||||
let status = response.status();
|
||||
return Err(format!("put bucket replication: {status} {}", response.text().await.unwrap_or_default()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 17: a local delete marker is the authoritative answer in a versioned
|
||||
/// bucket, while an unversioned delete leaves nothing behind and the key is
|
||||
/// migrated again.
|
||||
#[tokio::test]
|
||||
async fn test_odm_delete_marker_shadows_the_source_but_a_plain_delete_does_not() -> TestResult {
|
||||
let versioned = "odm-interaction-delete-marker";
|
||||
let unversioned = "odm-interaction-plain-delete";
|
||||
let env = start_configured_env(versioned, SOURCE_BUCKET, |_| {}).await?;
|
||||
let spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
env.configure_and_wait(unversioned, &spec).await?;
|
||||
enable_versioning(&env, versioned).await?;
|
||||
|
||||
let key = "deleted/doc.bin";
|
||||
let source_body = payload(8 * 1024);
|
||||
let local_body = payload(4 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, source_body.clone())]);
|
||||
|
||||
for bucket in [versioned, unversioned] {
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(local_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
env.client.delete_object().bucket(bucket).key(key).send().await?;
|
||||
}
|
||||
|
||||
let shadowed = env.raw_get(versioned, key).await?;
|
||||
assert_eq!(shadowed.status, 404, "{}", String::from_utf8_lossy(&shadowed.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
0,
|
||||
"a local delete marker answers without the source"
|
||||
);
|
||||
|
||||
let migrated = env.raw_get(unversioned, key).await?;
|
||||
assert_eq!(migrated.status, 200, "{}", String::from_utf8_lossy(&migrated.body));
|
||||
assert_eq!(migrated.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(migrated.body, source_body, "an unversioned delete leaves the source authoritative");
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 18: deleting the configuration stops all source traffic without
|
||||
/// touching what was already migrated, and reinstalling it resumes.
|
||||
#[tokio::test]
|
||||
async fn test_odm_disable_keeps_pulled_objects_and_stops_source_traffic() -> TestResult {
|
||||
let bucket = "odm-interaction-disable";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let pulled_key = "disable/pulled.bin";
|
||||
let untouched_key = "disable/untouched.bin";
|
||||
let body = payload(32 * 1024);
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new(pulled_key, body.clone()),
|
||||
SeedObject::new(untouched_key, body.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let pulled = env.raw_get(bucket, pulled_key).await?;
|
||||
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
|
||||
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?);
|
||||
|
||||
let disabled = env.disable(bucket).await?;
|
||||
assert_eq!(disabled.status, 204, "{}", disabled.body);
|
||||
|
||||
let still_readable = env.raw_get(bucket, pulled_key).await?;
|
||||
assert_eq!(still_readable.status, 200, "{}", String::from_utf8_lossy(&still_readable.body));
|
||||
assert_eq!(still_readable.body, body, "a migrated object survives the disable");
|
||||
assert_eq!(still_readable.header(ODM_RESPONSE_HEADER), None);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, pulled_key), 1);
|
||||
|
||||
let missing = env.raw_get(bucket, untouched_key).await?;
|
||||
assert_eq!(missing.status, 404, "{}", String::from_utf8_lossy(&missing.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, untouched_key),
|
||||
0,
|
||||
"a disabled bucket never reaches the source"
|
||||
);
|
||||
|
||||
let spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
env.configure_and_wait(bucket, &spec).await?;
|
||||
let resumed = env.raw_get(bucket, untouched_key).await?;
|
||||
assert_eq!(resumed.status, 200, "{}", String::from_utf8_lossy(&resumed.body));
|
||||
assert_eq!(resumed.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(resumed.body, body);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, untouched_key), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 19: the admin surface an operator sees — the configuration read back
|
||||
/// without its secret, and a status document whose counters match the source
|
||||
/// journal exactly.
|
||||
#[tokio::test]
|
||||
async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source() -> TestResult {
|
||||
let bucket = "odm-interaction-admin";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let hit_key = "admin/present.bin";
|
||||
let miss_key = "admin/absent.bin";
|
||||
let body = payload(16 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(hit_key, body.clone())]);
|
||||
|
||||
let config = env.get_config(bucket).await?;
|
||||
assert_eq!(config.status, 200, "{}", config.body);
|
||||
let config = config.json()?;
|
||||
assert_eq!(
|
||||
config
|
||||
.pointer("/config/source/credentials/secret_key")
|
||||
.and_then(Value::as_str),
|
||||
Some("REDACTED"),
|
||||
"{config}"
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.pointer("/config/source/credentials/access_key")
|
||||
.and_then(Value::as_str),
|
||||
Some(FAKE_ACCESS_KEY),
|
||||
"the access key stays readable: {config}"
|
||||
);
|
||||
assert!(
|
||||
!config.to_string().contains(FAKE_SECRET_KEY),
|
||||
"the secret must not appear anywhere in the response"
|
||||
);
|
||||
|
||||
let hit = env.raw_get(bucket, hit_key).await?;
|
||||
assert_eq!(hit.status, 200, "{}", String::from_utf8_lossy(&hit.body));
|
||||
for _ in 0..2 {
|
||||
let miss = env.raw_get(bucket, miss_key).await?;
|
||||
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
||||
}
|
||||
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
|
||||
|
||||
let status = env.status_json(bucket).await?;
|
||||
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
||||
assert_eq!(status.pointer("/enabled").and_then(Value::as_bool), Some(true), "{status}");
|
||||
assert_eq!(status.pointer("/module_enabled").and_then(Value::as_bool), Some(true), "{status}");
|
||||
assert_eq!(status.pointer("/provider").and_then(Value::as_str), Some("s3"), "{status}");
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/requests_total/get/source_hit")
|
||||
.and_then(Value::as_u64),
|
||||
Some(1),
|
||||
"one source hit, matching the one source GET: {status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/requests_total/get/source_miss")
|
||||
.and_then(Value::as_u64),
|
||||
Some(1),
|
||||
"only the first miss reached the source: {status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/requests_total/get/negative_cached")
|
||||
.and_then(Value::as_u64),
|
||||
Some(1),
|
||||
"the second miss stopped at the negative cache: {status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/pulled_objects_total/inline")
|
||||
.and_then(Value::as_u64),
|
||||
Some(1),
|
||||
"{status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status.pointer("/counters/pulled_bytes_total").and_then(Value::as_u64),
|
||||
Some(body.len() as u64),
|
||||
"{status}"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, hit_key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, miss_key),
|
||||
1,
|
||||
"the status counters and the source journal agree"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// 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.
|
||||
|
||||
//! Provider interoperability cases (ODM-20, rustfs/backlog#2167).
|
||||
//!
|
||||
//! One body per case, run against whichever source the environment names:
|
||||
//! the in-process fake source locally, a MinIO container or a real cloud
|
||||
//! provider under `.github/workflows/on-demand-migration-interop.yml`. The
|
||||
//! source is resolved by [`OdmInteropEnv`], so a provider difference in
|
||||
//! path-style addressing, region handling, ETag shape or list pagination
|
||||
//! shows up as one of these assertions failing rather than as a second,
|
||||
//! drifting copy of the suite.
|
||||
//!
|
||||
//! Consequently these cases assert only on what every S3 implementation has
|
||||
//! to agree on — what the client receives and what RustFS stored — never on
|
||||
//! the fake source's request journal, which a real provider does not have.
|
||||
//! The journal-backed expectations stay in `get_basic_test.rs` and
|
||||
//! `interaction_test.rs`.
|
||||
//!
|
||||
//! The first three cases are the minimum a cloud provider is asked for (GET
|
||||
//! miss, HEAD miss, merged list pagination); the backfill case runs against
|
||||
//! the MinIO container, whose object count the lane raises well past the fake
|
||||
//! source's caps.
|
||||
|
||||
use super::common::{BackfillRequest, BoxError, OdmInteropEnv, SeedObject, interop_backfill_objects};
|
||||
use bytes::Bytes;
|
||||
use std::time::Duration;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// Background pulls land after the response that triggered them; generous for
|
||||
/// a loaded runner talking to a container.
|
||||
const SETTLE: Duration = Duration::from_secs(90);
|
||||
|
||||
/// Position-dependent payload so a misaligned or truncated copy is caught.
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
/// A GET miss is answered from the source with the source's own ETag, and the
|
||||
/// object it stored serves every later read locally.
|
||||
#[tokio::test]
|
||||
async fn interop_get_miss_pulls_from_the_source_and_serves_locally() -> TestResult {
|
||||
let case =
|
||||
OdmInteropEnv::start("interop_get_miss_pulls_from_the_source_and_serves_locally", "odm-interop-get", |_| {}).await?;
|
||||
let key = "interop/report.bin";
|
||||
let body = payload(200 * 1024);
|
||||
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
|
||||
let quoted_etag = format!("\"{etag}\"");
|
||||
|
||||
let first = case.env.raw_get(&case.bucket, key).await?;
|
||||
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
|
||||
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
|
||||
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert_eq!(
|
||||
first.header("etag"),
|
||||
Some(quoted_etag.as_str()),
|
||||
"the source ETag is passed through unchanged"
|
||||
);
|
||||
assert_eq!(first.body, body, "the client receives the source bytes");
|
||||
|
||||
assert!(
|
||||
case.env.wait_local_listed(&case.bucket, key, SETTLE).await?,
|
||||
"the inline pull must store the object locally"
|
||||
);
|
||||
let second = case.env.raw_get(&case.bucket, key).await?;
|
||||
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
|
||||
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
|
||||
assert_eq!(second.body, body, "the local copy is the source bytes");
|
||||
assert_eq!(
|
||||
second.header("etag"),
|
||||
Some(quoted_etag.as_str()),
|
||||
"preserve_etag keeps the source ETag on the stored object"
|
||||
);
|
||||
case.finish().await
|
||||
}
|
||||
|
||||
/// A HEAD miss is proxied with the source's size and ETag and stores nothing.
|
||||
#[tokio::test]
|
||||
async fn interop_head_miss_answers_from_the_source_without_persisting() -> TestResult {
|
||||
let case =
|
||||
OdmInteropEnv::start("interop_head_miss_answers_from_the_source_without_persisting", "odm-interop-head", |_| {}).await?;
|
||||
let key = "interop/head-only.bin";
|
||||
let body = payload(9_000);
|
||||
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
|
||||
|
||||
let head = case
|
||||
.env
|
||||
.raw_object_request(http::Method::HEAD, &case.bucket, key, &[])
|
||||
.await?;
|
||||
assert_eq!(head.status, 200, "HEAD must be answered from the source");
|
||||
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert_eq!(head.header("etag"), Some(format!("\"{etag}\"").as_str()));
|
||||
assert!(head.body.is_empty(), "a HEAD carries no body");
|
||||
case.env.assert_local_absent(&case.bucket, key).await;
|
||||
|
||||
// A key the source does not hold is a plain 404, not a source error.
|
||||
let missing = case
|
||||
.env
|
||||
.raw_object_request(http::Method::HEAD, &case.bucket, "interop/absent.bin", &[])
|
||||
.await?;
|
||||
assert_eq!(missing.status, 404, "a source miss is a 404");
|
||||
case.finish().await
|
||||
}
|
||||
|
||||
/// The merged `ListObjectsV2` pages the source namespace in byte order, keeps
|
||||
/// every page within `max_keys`, and lets a local object win a shared key.
|
||||
#[tokio::test]
|
||||
async fn interop_list_through_pages_the_source_namespace() -> TestResult {
|
||||
const SOURCE_KEYS: usize = 120;
|
||||
const PAGE_SIZE: i32 = 50;
|
||||
const SOURCE_BODY_LEN: usize = 3;
|
||||
const LOCAL_BODY_LEN: usize = 11;
|
||||
|
||||
let case = OdmInteropEnv::start("interop_list_through_pages_the_source_namespace", "odm-interop-list", |spec| {
|
||||
spec.policy.list_through = true
|
||||
})
|
||||
.await?;
|
||||
let keys: Vec<String> = (0..SOURCE_KEYS).map(|index| format!("page/obj-{index:05}")).collect();
|
||||
let seeds: Vec<SeedObject> = keys
|
||||
.iter()
|
||||
.map(|key| SeedObject::new(key.clone(), payload(SOURCE_BODY_LEN)))
|
||||
.collect();
|
||||
case.seed(&seeds).await?;
|
||||
|
||||
// Five keys the local bucket also holds, with a body length that tells the
|
||||
// two sides apart in the listing.
|
||||
let shared: Vec<String> = keys.iter().step_by(25).cloned().collect();
|
||||
for key in &shared {
|
||||
case.env
|
||||
.client
|
||||
.put_object()
|
||||
.bucket(&case.bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(payload(LOCAL_BODY_LEN)))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut listed: Vec<(String, i64)> = Vec::new();
|
||||
let mut token: Option<String> = None;
|
||||
let mut completed = false;
|
||||
for _ in 0..SOURCE_KEYS {
|
||||
let page = case
|
||||
.env
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(&case.bucket)
|
||||
.prefix("page/")
|
||||
.max_keys(PAGE_SIZE)
|
||||
.set_continuation_token(token.take())
|
||||
.send()
|
||||
.await?;
|
||||
assert!(page.contents().len() <= PAGE_SIZE as usize, "a merged page must not exceed max_keys");
|
||||
for object in page.contents() {
|
||||
listed.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
|
||||
}
|
||||
if !page.is_truncated().unwrap_or(false) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
token = Some(
|
||||
page.next_continuation_token()
|
||||
.ok_or("truncated merged page without a continuation token")?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
assert!(completed, "the merged listing did not terminate");
|
||||
|
||||
let listed_keys: Vec<String> = listed.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(listed_keys, keys, "the merged listing is the source namespace in byte order");
|
||||
for (key, size) in &listed {
|
||||
let expected = if shared.contains(key) {
|
||||
LOCAL_BODY_LEN
|
||||
} else {
|
||||
SOURCE_BODY_LEN
|
||||
};
|
||||
assert_eq!(*size, expected as i64, "{key} must be reported by the side that wins it");
|
||||
}
|
||||
case.finish().await
|
||||
}
|
||||
|
||||
/// A backfill pulls every object under the run's source prefix. The count
|
||||
/// comes from the environment: the fake source caps out around 4,096 stored
|
||||
/// versions, while the MinIO lane runs the full production-shaped batch.
|
||||
#[tokio::test]
|
||||
async fn interop_backfill_pulls_every_source_object() -> TestResult {
|
||||
const KEY_PREFIX: &str = "cold/";
|
||||
let count = interop_backfill_objects()?;
|
||||
assert!(count > 0, "the backfill case needs at least one source object");
|
||||
let case = OdmInteropEnv::start("interop_backfill_pulls_every_source_object", "odm-interop-backfill", |_| {}).await?;
|
||||
|
||||
let objects: Vec<SeedObject> = (0..count)
|
||||
.map(|index| SeedObject::new(format!("{KEY_PREFIX}{index:06}"), Bytes::from(format!("object-{index:06}"))))
|
||||
.collect();
|
||||
case.seed(&objects).await?;
|
||||
|
||||
let started = case.env.start_backfill(&case.bucket, BackfillRequest::default()).await?;
|
||||
assert_eq!(started.status, 200, "start backfill: {}", started.body);
|
||||
|
||||
// One pull is a HEAD plus a GET plus a local write; the ceiling scales
|
||||
// with the object count so raising it in the workflow does not need a
|
||||
// second knob here.
|
||||
let timeout = Duration::from_secs(180 + count as u64 / 5);
|
||||
let done = case
|
||||
.env
|
||||
.wait_for_backfill(&case.bucket, timeout, |job| job["state"] == "completed")
|
||||
.await?;
|
||||
for (name, expected) in [
|
||||
("listed", count as u64),
|
||||
("enqueued", count as u64),
|
||||
("pulled", count as u64),
|
||||
("failed", 0),
|
||||
] {
|
||||
assert_eq!(
|
||||
done[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {done}")),
|
||||
expected,
|
||||
"backfill {name}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
case.env.local_key_count(&case.bucket, KEY_PREFIX).await?,
|
||||
count,
|
||||
"every source object must be stored locally"
|
||||
);
|
||||
case.env
|
||||
.assert_local_present(&case.bucket, &objects[count - 1].key, &objects[count - 1].body)
|
||||
.await;
|
||||
case.finish().await
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// 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.
|
||||
|
||||
//! Optional merged `ListObjectsV2` (`policy.list_through`, ODM-17,
|
||||
//! rustfs/backlog#2164): full pagination over a source and a local namespace,
|
||||
//! common-prefix union under a delimiter, the continuation-token contract, and
|
||||
//! the two `source_error` behaviours when the source listing fails.
|
||||
|
||||
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env};
|
||||
use crate::fake_s3_target::{FaultAction, Operation};
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use bytes::Bytes;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-list-source";
|
||||
const LIST_HEADER: &str = "x-rustfs-on-demand-migration-list";
|
||||
|
||||
/// Byte lengths that tell a local object from a source one in a listing.
|
||||
const SOURCE_BODY_LEN: usize = 3;
|
||||
const LOCAL_BODY_LEN: usize = 11;
|
||||
|
||||
fn body(len: usize) -> Bytes {
|
||||
vec![b'x'; len].into()
|
||||
}
|
||||
|
||||
/// RustFS migrating `bucket` from `SOURCE_BUCKET` with `list_through` on.
|
||||
async fn list_through_env(bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
|
||||
start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.list_through = true;
|
||||
adjust(spec);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Every key the bucket lists, walked through the merged continuation token.
|
||||
/// Also returns the size each page reported per key and the page sizes, so a
|
||||
/// caller can assert who won a shared key and that no page exceeded `max_keys`.
|
||||
async fn walk_listing(
|
||||
env: &OdmTestEnv,
|
||||
bucket: &str,
|
||||
delimiter: Option<&str>,
|
||||
max_keys: i32,
|
||||
) -> Result<(Vec<(String, i64)>, Vec<String>, Vec<usize>), BoxError> {
|
||||
let mut objects = Vec::new();
|
||||
let mut prefixes = Vec::new();
|
||||
let mut page_sizes = Vec::new();
|
||||
let mut token: Option<String> = None;
|
||||
for _ in 0..1000 {
|
||||
let page = env
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(max_keys)
|
||||
.set_delimiter(delimiter.map(str::to_string))
|
||||
.set_continuation_token(token.take())
|
||||
.send()
|
||||
.await?;
|
||||
let listed = page.contents().len() + page.common_prefixes().len();
|
||||
page_sizes.push(listed);
|
||||
for object in page.contents() {
|
||||
objects.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
|
||||
}
|
||||
for prefix in page.common_prefixes() {
|
||||
prefixes.push(prefix.prefix().unwrap_or_default().to_string());
|
||||
}
|
||||
if !page.is_truncated().unwrap_or(false) {
|
||||
return Ok((objects, prefixes, page_sizes));
|
||||
}
|
||||
token = Some(
|
||||
page.next_continuation_token()
|
||||
.ok_or("truncated page without a continuation token")?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Err("merged listing did not terminate".into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_merges_the_whole_namespace_across_full_pagination() -> TestResult {
|
||||
let bucket = "odm-list-merge";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
|
||||
// 2000 source keys, 80 of them also local, plus 10 local-only keys that
|
||||
// interleave between source keys ("obj-00010x" sorts after "obj-00010").
|
||||
let source_keys: Vec<String> = (0..2000).map(|index| format!("obj-{index:05}")).collect();
|
||||
let seeds: Vec<SeedObject> = source_keys
|
||||
.iter()
|
||||
.map(|key| SeedObject::new(key.clone(), body(SOURCE_BODY_LEN)))
|
||||
.collect();
|
||||
env.seed_source(SOURCE_BUCKET, &seeds);
|
||||
|
||||
let shared: Vec<String> = source_keys.iter().step_by(25).cloned().collect();
|
||||
let local_only: Vec<String> = (0..10).map(|index| format!("obj-{:05}x", index * 7)).collect();
|
||||
for key in shared.iter().chain(local_only.iter()) {
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
let max_keys = 97;
|
||||
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, None, max_keys).await?;
|
||||
assert!(prefixes.is_empty(), "no delimiter means no common prefixes");
|
||||
|
||||
let mut expected: Vec<String> = source_keys.iter().chain(local_only.iter()).cloned().collect();
|
||||
expected.sort();
|
||||
expected.dedup();
|
||||
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(listed, expected, "the merged listing is the sorted, deduplicated union");
|
||||
assert!(
|
||||
page_sizes.iter().all(|size| *size <= max_keys as usize),
|
||||
"no page may exceed max_keys: {page_sizes:?}"
|
||||
);
|
||||
|
||||
let shared_sizes: Vec<i64> = objects
|
||||
.iter()
|
||||
.filter(|(key, _)| shared.contains(key))
|
||||
.map(|(_, size)| *size)
|
||||
.collect();
|
||||
assert_eq!(shared_sizes.len(), shared.len(), "every shared key is listed exactly once");
|
||||
assert!(
|
||||
shared_sizes.iter().all(|size| *size == LOCAL_BODY_LEN as i64),
|
||||
"the local object wins a key both sides hold"
|
||||
);
|
||||
let source_sizes: Vec<i64> = objects
|
||||
.iter()
|
||||
.filter(|(key, _)| !shared.contains(key) && !local_only.contains(key))
|
||||
.map(|(_, size)| *size)
|
||||
.collect();
|
||||
assert!(
|
||||
source_sizes.iter().all(|size| *size == SOURCE_BODY_LEN as i64),
|
||||
"source-only keys report the source's own size"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_unions_common_prefixes_under_a_delimiter() -> TestResult {
|
||||
let bucket = "odm-list-delimiter";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("p1/a", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("p1/b", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("p2/a", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("top-s", body(SOURCE_BODY_LEN)),
|
||||
],
|
||||
);
|
||||
for key in ["p1/c", "p3/a", "top-l"] {
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
// A page size of two forces the prefix union to survive page boundaries.
|
||||
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, Some("/"), 2).await?;
|
||||
assert_eq!(prefixes, vec!["p1/", "p2/", "p3/"], "prefixes are unioned and deduplicated");
|
||||
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(listed, vec!["top-l", "top-s"]);
|
||||
assert!(page_sizes.iter().all(|size| *size <= 2), "{page_sizes:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_propagates_a_source_listing_failure() -> TestResult {
|
||||
let bucket = "odm-list-propagate";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("local")
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
env.source
|
||||
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
|
||||
let failure = env
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("propagate must surface the source failure");
|
||||
let failure = failure.into_service_error();
|
||||
assert_eq!(failure.meta().code(), Some("SourceUnavailable"), "{failure:?}");
|
||||
|
||||
// The next listing sees a healthy source again and merges both sides.
|
||||
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
|
||||
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(listed, vec!["local", "remote"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_degrades_to_local_only_under_the_not_found_policy() -> TestResult {
|
||||
let bucket = "odm-list-degrade";
|
||||
let env = list_through_env(bucket, |spec| spec.policy.source_error = "not_found".to_string()).await?;
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("local")
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
env.source
|
||||
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
|
||||
let degraded = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
|
||||
assert_eq!(degraded.status, 200, "{}", String::from_utf8_lossy(°raded.body));
|
||||
assert_eq!(
|
||||
degraded.header(LIST_HEADER),
|
||||
Some("local_only"),
|
||||
"a degraded listing must say so in the response header"
|
||||
);
|
||||
let xml = String::from_utf8_lossy(°raded.body).to_string();
|
||||
assert!(xml.contains("<Key>local</Key>"), "{xml}");
|
||||
assert!(!xml.contains("<Key>remote</Key>"), "a degraded listing shows local state only: {xml}");
|
||||
|
||||
let healthy = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
|
||||
assert_eq!(healthy.status, 200);
|
||||
assert_eq!(healthy.header(LIST_HEADER), None, "a healthy merge carries no degradation marker");
|
||||
assert!(String::from_utf8_lossy(&healthy.body).contains("<Key>remote</Key>"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
|
||||
let bucket = "odm-list-token";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("a", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("b", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("c", body(SOURCE_BODY_LEN)),
|
||||
],
|
||||
);
|
||||
|
||||
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
|
||||
let token = page.next_continuation_token().ok_or("first page must be truncated")?;
|
||||
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
|
||||
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
|
||||
|
||||
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":2").as_bytes());
|
||||
let rejected = env
|
||||
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}"))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
rejected.status,
|
||||
400,
|
||||
"a bumped token version is a client error: {}",
|
||||
String::from_utf8_lossy(&rejected.body)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_merged_token_keeps_paginating_after_list_through_is_turned_off() -> TestResult {
|
||||
let bucket = "odm-list-token-off";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("s1", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("s2", body(SOURCE_BODY_LEN)),
|
||||
],
|
||||
);
|
||||
for key in ["l1", "l2"] {
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
|
||||
assert_eq!(page.contents()[0].key(), Some("l1"));
|
||||
let token = page
|
||||
.next_continuation_token()
|
||||
.ok_or("first page must be truncated")?
|
||||
.to_string();
|
||||
|
||||
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
spec.policy.list_through = false;
|
||||
env.configure_and_wait(bucket, &spec).await?;
|
||||
|
||||
let resumed = env
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(10)
|
||||
.continuation_token(token)
|
||||
.send()
|
||||
.await?;
|
||||
let listed: Vec<&str> = resumed.contents().iter().filter_map(|object| object.key()).collect();
|
||||
assert_eq!(listed, vec!["l2"], "a merged token falls back to its local cursor");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_local_delete_marker_hides_the_source_key_from_a_merged_listing() -> TestResult {
|
||||
let bucket = "odm-list-delete-marker";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
env.client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("kept", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("shadowed", body(SOURCE_BODY_LEN)),
|
||||
],
|
||||
);
|
||||
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("shadowed")
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
env.client.delete_object().bucket(bucket).key("shadowed").send().await?;
|
||||
|
||||
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
|
||||
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(
|
||||
listed,
|
||||
vec!["kept"],
|
||||
"a local delete marker shadows the source key the same way it does on GET"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// 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.
|
||||
|
||||
//! On-demand migration (ODM) end-to-end suite (rustfs/backlog#2147).
|
||||
//!
|
||||
//! `common` is the shared environment: one RustFS under test, one programmable
|
||||
//! fake S3 source, admin-API wrappers, seeding and local-state assertions.
|
||||
//! `harness_self_test` proves the harness itself; `get_basic_test` covers the
|
||||
//! GET read-through (rustfs/backlog#2156) and `backfill_test` the background
|
||||
//! backfill job (ODM-12, rustfs/backlog#2159); `list_through_test` covers the
|
||||
//! optional merged `ListObjectsV2` (ODM-17, rustfs/backlog#2164). The fault, concurrency,
|
||||
//! interaction and real-source matrix is rustfs/backlog#2158; its lane split
|
||||
//! lives in `.config/nextest.toml` (fault / concurrency / real source run
|
||||
//! nightly, the rest in the merge lane). `interop_test` is the provider
|
||||
//! interoperability lane (ODM-20, rustfs/backlog#2167): the same case bodies
|
||||
//! against the fake source locally and against a real provider named by the
|
||||
//! environment in `.github/workflows/on-demand-migration-interop.yml`.
|
||||
|
||||
pub mod common;
|
||||
|
||||
mod backfill_test;
|
||||
mod concurrency_test;
|
||||
mod fault_test;
|
||||
mod get_basic_test;
|
||||
mod harness_self_test;
|
||||
mod interaction_test;
|
||||
mod interop_test;
|
||||
mod list_through_test;
|
||||
mod real_source_test;
|
||||
@@ -0,0 +1,266 @@
|
||||
// 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.
|
||||
|
||||
//! On-demand migration against a real RustFS source (rustfs/backlog#2158).
|
||||
//!
|
||||
//! These cases start a second (and, for the loop guard, a third) RustFS
|
||||
//! process, so they carry the `_real_single_node` marker and run in the
|
||||
//! nightly lane. A real source keeps no request journal, so "the source was
|
||||
//! not consulted" is proven by removing the object from the source and
|
||||
//! showing the read still succeeds, or by pointing the *second* server at a
|
||||
//! fake source whose journal must stay empty.
|
||||
|
||||
use super::common::{
|
||||
AdminResponse, BoxError, ODM_ADMIN_ROUTE, OdmSourceSpec, OdmTestEnv, RawResponse, SeedObject, start_source_rustfs,
|
||||
start_source_rustfs_with_odm,
|
||||
};
|
||||
use crate::common::{RustFSTestEnvironment, signed_request};
|
||||
use crate::fake_s3_target::{BucketMode, Operation};
|
||||
use aws_sdk_s3::Client;
|
||||
use bytes::Bytes;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// Reinstalled configurations are applied asynchronously; every phase polls
|
||||
/// for the new behavior instead of sleeping.
|
||||
const APPLY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SETTLE: Duration = Duration::from_secs(60);
|
||||
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
/// `PUT /rustfs/admin/v3/on-demand-migration/{bucket}` against any server,
|
||||
/// not just the one under test.
|
||||
async fn configure_odm(env: &RustFSTestEnvironment, bucket: &str, spec: &OdmSourceSpec) -> Result<AdminResponse, BoxError> {
|
||||
let url = format!("{}{ODM_ADMIN_ROUTE}/{bucket}", env.url);
|
||||
let body = serde_json::to_vec(&spec.to_json())?;
|
||||
let response = signed_request(
|
||||
http::Method::PUT,
|
||||
&url,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
Some(body),
|
||||
Some("application/json"),
|
||||
)
|
||||
.await?;
|
||||
Ok(AdminResponse {
|
||||
status: response.status().as_u16(),
|
||||
body: response.text().await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn put_object(client: &Client, bucket: &str, key: &str, body: Bytes) -> TestResult {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(body))
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls a read against the server under test until it answers `expected`.
|
||||
/// This is how a reinstalled configuration is waited for when the source
|
||||
/// keeps no journal to probe.
|
||||
async fn wait_for_get_status(env: &OdmTestEnv, bucket: &str, key: &str, expected: u16) -> Result<RawResponse, BoxError> {
|
||||
let deadline = Instant::now() + APPLY_TIMEOUT;
|
||||
loop {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
if response.status == expected {
|
||||
return Ok(response);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"GET {bucket}/{key} stayed at {} instead of {expected}: {}",
|
||||
response.status,
|
||||
String::from_utf8_lossy(&response.body)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Case 20: a second RustFS as the migration source — the pull, a HEAD
|
||||
/// passthrough, a Range read, and both prefix knobs.
|
||||
#[tokio::test]
|
||||
async fn test_odm_rustfs_source_serves_pull_head_range_and_prefixes_real_single_node() -> TestResult {
|
||||
let bucket = "odm-real-source";
|
||||
let source_bucket = "odm-real-origin";
|
||||
let source = start_source_rustfs().await?;
|
||||
let source_client = source.create_s3_client();
|
||||
source.create_test_bucket(source_bucket).await?;
|
||||
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.rustfs.create_test_bucket(bucket).await?;
|
||||
let spec = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
|
||||
let configured = configure_odm(&env.rustfs, bucket, &spec).await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
|
||||
// Phase 1: a miss is pulled and stored; removing it from the source
|
||||
// afterwards proves the second read never goes back to the source.
|
||||
let pulled_key = "real/pulled.bin";
|
||||
let pulled_body = payload(256 * 1024);
|
||||
put_object(&source_client, source_bucket, pulled_key, pulled_body.clone()).await?;
|
||||
let pulled = wait_for_get_status(&env, bucket, pulled_key, 200).await?;
|
||||
assert_eq!(pulled.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(pulled.body, pulled_body, "the client receives the source bytes");
|
||||
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?, "the pull must store the object");
|
||||
source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key(pulled_key)
|
||||
.send()
|
||||
.await?;
|
||||
let local = env.raw_get(bucket, pulled_key).await?;
|
||||
assert_eq!(local.status, 200, "{}", String::from_utf8_lossy(&local.body));
|
||||
assert_eq!(local.header(ODM_RESPONSE_HEADER), None, "a local hit is not marked");
|
||||
assert_eq!(local.body, pulled_body, "the object is served from the local copy");
|
||||
|
||||
// Phase 2: HEAD proxies metadata without storing anything.
|
||||
let head_key = "real/head-only.bin";
|
||||
let head_body = payload(9_000);
|
||||
put_object(&source_client, source_bucket, head_key, head_body.clone()).await?;
|
||||
let head = env.raw_object_request(http::Method::HEAD, bucket, head_key, &[]).await?;
|
||||
assert_eq!(head.status, 200, "HEAD must be answered from the source");
|
||||
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(head.header("content-length"), Some(head_body.len().to_string().as_str()));
|
||||
env.assert_local_absent(bucket, head_key).await;
|
||||
|
||||
// Phase 3: a Range read is passed through as a 206.
|
||||
let range_key = "real/range.bin";
|
||||
let range_body = payload(100_000);
|
||||
put_object(&source_client, source_bucket, range_key, range_body.clone()).await?;
|
||||
let ranged = env
|
||||
.raw_object_request(http::Method::GET, bucket, range_key, &[("range", "bytes=100-199")])
|
||||
.await?;
|
||||
assert_eq!(ranged.status, 206, "{}", String::from_utf8_lossy(&ranged.body));
|
||||
assert_eq!(ranged.header("content-range"), Some("bytes 100-199/100000"));
|
||||
assert_eq!(ranged.body, range_body.slice(100..200));
|
||||
|
||||
// Phase 4: `filter.prefix` decides which local keys may consult the
|
||||
// source at all.
|
||||
let allowed_key = "allowed/doc.bin";
|
||||
let denied_key = "denied/doc.bin";
|
||||
let filtered_body = payload(4_096);
|
||||
put_object(&source_client, source_bucket, allowed_key, filtered_body.clone()).await?;
|
||||
put_object(&source_client, source_bucket, denied_key, filtered_body.clone()).await?;
|
||||
let mut filtered = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
|
||||
filtered.filter.prefix = Some("allowed/".to_string());
|
||||
let response = configure_odm(&env.rustfs, bucket, &filtered).await?;
|
||||
assert_eq!(response.status, 200, "{}", response.body);
|
||||
let denied = wait_for_get_status(&env, bucket, denied_key, 404).await?;
|
||||
assert_eq!(denied.header(ODM_RESPONSE_HEADER), None);
|
||||
env.assert_local_absent(bucket, denied_key).await;
|
||||
let allowed = wait_for_get_status(&env, bucket, allowed_key, 200).await?;
|
||||
assert_eq!(allowed.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(allowed.body, filtered_body);
|
||||
|
||||
// Phase 5: `filter.source_prefix` rewrites the key on the way out, so a
|
||||
// local key resolves to a different key in the source bucket.
|
||||
let rewritten_key = "rewritten/doc.bin";
|
||||
let rewritten_body = payload(2_048);
|
||||
put_object(&source_client, source_bucket, &format!("archive/{rewritten_key}"), rewritten_body.clone()).await?;
|
||||
let mut rewriting = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
|
||||
rewriting.filter.source_prefix = Some("archive/".to_string());
|
||||
let response = configure_odm(&env.rustfs, bucket, &rewriting).await?;
|
||||
assert_eq!(response.status, 200, "{}", response.body);
|
||||
let rewritten = wait_for_get_status(&env, bucket, rewritten_key, 200).await?;
|
||||
assert_eq!(rewritten.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(rewritten.body, rewritten_body, "the source prefix is prepended to the local key");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 21: two migrating servers pointed at each other must not build a
|
||||
/// request loop. The middle server also has a fake source of its own, whose
|
||||
/// journal is the evidence: a key it would happily fetch for a direct client
|
||||
/// is never fetched for a request that arrived with the anti-loop marker.
|
||||
#[tokio::test]
|
||||
async fn test_odm_chained_sources_stop_at_the_loop_guard_real_single_node() -> TestResult {
|
||||
let bucket = "odm-loop-guard";
|
||||
let fake_bucket = "odm-loop-fake";
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket_with_mode(fake_bucket, BucketMode::Unversioned);
|
||||
env.rustfs.create_test_bucket(bucket).await?;
|
||||
|
||||
let middle = start_source_rustfs_with_odm().await?;
|
||||
let middle_client = middle.create_s3_client();
|
||||
middle.create_test_bucket(bucket).await?;
|
||||
let middle_spec = OdmSourceSpec::for_fake_source(&env.source, fake_bucket);
|
||||
let configured = configure_odm(&middle, bucket, &middle_spec).await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
|
||||
let chained = OdmSourceSpec::for_rustfs_source(&middle, bucket);
|
||||
let configured = configure_odm(&env.rustfs, bucket, &chained).await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
|
||||
// The first hop works: an object that only the middle server holds is
|
||||
// migrated to the server under test.
|
||||
let present_key = "loop/present.bin";
|
||||
let present_body = payload(16 * 1024);
|
||||
put_object(&middle_client, bucket, present_key, present_body.clone()).await?;
|
||||
let served = wait_for_get_status(&env, bucket, present_key, 200).await?;
|
||||
assert_eq!(served.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(served.body, present_body, "the first hop serves the middle server's object");
|
||||
|
||||
// The second hop does not: this key exists only on the middle server's
|
||||
// own source, and the anti-loop marker stops the chain there.
|
||||
let guarded_key = "loop/chain-guard.bin";
|
||||
let guarded_body = payload(8 * 1024);
|
||||
env.seed_source(fake_bucket, &[SeedObject::new(guarded_key, guarded_body.clone())]);
|
||||
let guarded = env.raw_get(bucket, guarded_key).await?;
|
||||
assert_eq!(guarded.status, 404, "{}", String::from_utf8_lossy(&guarded.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, guarded_key),
|
||||
0,
|
||||
"a chained request must not reach a third source"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, guarded_key), 0);
|
||||
|
||||
// Proof that the guard, and not a broken configuration, is what stopped
|
||||
// it: the same key served directly by the middle server does reach the
|
||||
// fake source.
|
||||
let direct = middle_client.get_object().bucket(bucket).key(guarded_key).send().await?;
|
||||
assert_eq!(direct.body.collect().await?.into_bytes(), guarded_body);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, guarded_key),
|
||||
1,
|
||||
"an unmarked request does consult the middle server's source"
|
||||
);
|
||||
|
||||
// Now make the pair mutual and prove the read still terminates.
|
||||
let mutual = OdmSourceSpec::for_rustfs_source(&env.rustfs, bucket);
|
||||
let configured = configure_odm(&middle, bucket, &mutual).await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
|
||||
let mutual_key = "loop/mutual.bin";
|
||||
let started = Instant::now();
|
||||
let response = wait_for_get_status(&env, bucket, mutual_key, 404).await?;
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), None);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"a mutual configuration must not loop, took {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, mutual_key),
|
||||
0,
|
||||
"the fake source is out of the chain once the pair is mutual"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,15 +17,13 @@ Use the canonical CI-equivalent protocol command in the parent
|
||||
For targeted debugging of the core suite only:
|
||||
|
||||
```bash
|
||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
||||
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
||||
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
||||
```
|
||||
|
||||
This targeted command does not cover the full `e2e-protocols` profile.
|
||||
|
||||
`RUSTFS_BUILD_FEATURES` controls which features the test rustfs binary is
|
||||
built with. When this variable is set, the protocol test runner schedules
|
||||
only entries whose protocol is present in the requested feature list. Leave
|
||||
it unset to run every protocol entry.
|
||||
`e2e_binary.py` supplies `RUSTFS_BUILD_FEATURES` from the verified server's resolved Cargo features. The protocol runner schedules only entries present in that feature list; helpers check that their required features are available without rebuilding the server.
|
||||
`--test-threads=1` is required because every entry spawns a rustfs server
|
||||
on fixed bind ports.
|
||||
|
||||
|
||||
@@ -856,6 +856,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn scanner_dirty_usage_snapshot(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn background_heal_status(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::BackgroundHealStatusRequest>,
|
||||
@@ -904,6 +911,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadTransitionTierConfigResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn tier_daily_stats(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::TierDailyStatsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::TierDailyStatsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a gRPC lock server on a random port
|
||||
|
||||
@@ -35,7 +35,8 @@ mod tests {
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tokio::time::{Duration, Instant, interval};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{Duration, Instant, interval, sleep, timeout};
|
||||
use tracing::info;
|
||||
|
||||
const ENABLE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E";
|
||||
@@ -48,6 +49,8 @@ mod tests {
|
||||
const REPLACEMENT_INTENT_SUFFIX: &str = "_ahm_replacement_intent.json";
|
||||
const REPLACEMENT_COMPLETION_PROOF_SUFFIX: &str = "_ahm_replacement_completion_proof.json";
|
||||
const RESUME_CHECKPOINT_SUFFIX: &str = "_ahm_checkpoint.json";
|
||||
const FAULT_WINDOW_OBJECT_COUNT: usize = 24;
|
||||
const FAULT_WINDOW_OBJECT_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BaselineVersion {
|
||||
@@ -65,6 +68,12 @@ mod tests {
|
||||
CompletedWithIncomplete(BTreeSet<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ReplacementScenario {
|
||||
Baseline,
|
||||
MidRebuildIoFault,
|
||||
}
|
||||
|
||||
struct MountNamespaceGuard {
|
||||
mounts: Vec<PathBuf>,
|
||||
}
|
||||
@@ -102,6 +111,19 @@ mod tests {
|
||||
|
||||
impl FaultableBlockMount {
|
||||
fn mount(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
Self::mount_with_live_recovery(target, image_root, label, false)
|
||||
}
|
||||
|
||||
fn mount_live_recovery(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
Self::mount_with_live_recovery(target, image_root, label, true)
|
||||
}
|
||||
|
||||
fn mount_with_live_recovery(
|
||||
target: &Path,
|
||||
image_root: &Path,
|
||||
label: &str,
|
||||
live_recovery: bool,
|
||||
) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
fs::create_dir_all(image_root)?;
|
||||
let image = image_root.join(format!("{label}.img"));
|
||||
let file = fs::File::create(&image)?;
|
||||
@@ -114,7 +136,15 @@ mod tests {
|
||||
return Err("losetup --find --show returned an empty loop device".into());
|
||||
}
|
||||
|
||||
run_command("mkfs.ext4", &["-F", &loop_device])?;
|
||||
if live_recovery {
|
||||
// Keep the filesystem and RustFS' persistent root descriptor attached
|
||||
// across the transient all-block EIO. A journaling ext4 abort requires
|
||||
// an unmount to recover, which would test process/disk reattachment
|
||||
// instead of live I/O recovery.
|
||||
run_command("mkfs.ext4", &["-F", "-O", "^has_journal", &loop_device])?;
|
||||
} else {
|
||||
run_command("mkfs.ext4", &["-F", &loop_device])?;
|
||||
}
|
||||
let sectors = run_command_stdout("blockdev", &["--getsz", &loop_device])?;
|
||||
let dm_name = format!("rustfs_e2e_{label}_{}", std::process::id());
|
||||
let table = format!("0 {sectors} linear {loop_device} 0");
|
||||
@@ -122,7 +152,11 @@ mod tests {
|
||||
run_command("dmsetup", &["create", &dm_name, "--table", &table])?;
|
||||
|
||||
let target_arg = path_to_string(target, "faultable mount target")?;
|
||||
run_command("mount", &[&mapper, &target_arg])?;
|
||||
if live_recovery {
|
||||
run_command("mount", &["-o", "errors=continue", &mapper, &target_arg])?;
|
||||
} else {
|
||||
run_command("mount", &[&mapper, &target_arg])?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
target: target.to_path_buf(),
|
||||
@@ -159,18 +193,25 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
fn restore_linear_table(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
|
||||
let linear_table = format!("0 {sectors} linear {} 0", self.loop_device);
|
||||
run_command("dmsetup", &["suspend", &self.dm_name])?;
|
||||
// An ext4 journal abort can leave the mounted filesystem internally
|
||||
// read-only. Avoid dmsetup's filesystem freeze/flush in that state;
|
||||
// all I/O sent to the error target has already completed with EIO.
|
||||
run_command("dmsetup", &["suspend", "--noflush", &self.dm_name])?;
|
||||
run_command("dmsetup", &["load", &self.dm_name, "--table", &linear_table])?;
|
||||
run_command("dmsetup", &["resume", &self.dm_name])
|
||||
}
|
||||
|
||||
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
self.restore_linear_table()
|
||||
}
|
||||
|
||||
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut first_error: Option<Box<dyn Error + Send + Sync>> = None;
|
||||
if self.dm_created {
|
||||
let _ = self.restore_available();
|
||||
let _ = self.restore_linear_table();
|
||||
}
|
||||
if self.mounted {
|
||||
if let Err(error) = detach_mount(&self.target) {
|
||||
@@ -469,7 +510,11 @@ mod tests {
|
||||
Ok((completed.version_id().map(str::to_owned), digest))
|
||||
}
|
||||
|
||||
async fn seed_baseline(client: &Client, target_disk: &Path) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
|
||||
async fn seed_baseline(
|
||||
client: &Client,
|
||||
target_disk: &Path,
|
||||
extra_object_count: usize,
|
||||
) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
|
||||
let plain_bucket = "priv-replacement-plain";
|
||||
let versioned_bucket = "priv-replacement-versions";
|
||||
let null_bucket = "priv-replacement-null";
|
||||
@@ -532,7 +577,7 @@ mod tests {
|
||||
put_object_version(client, null_bucket, "null/current.bin", payload(512 * 1024, 8)).await?;
|
||||
versions.push((null_bucket, "null/current.bin", version_id, Some(body_sha256)));
|
||||
|
||||
let versions = versions
|
||||
let mut versions = versions
|
||||
.into_iter()
|
||||
.map(|(bucket, key, version_id, body_sha256)| {
|
||||
let expected = census_object_version_on_disk(target_disk, bucket, key, version_id.as_deref())?;
|
||||
@@ -548,6 +593,23 @@ mod tests {
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, Box<dyn Error + Send + Sync>>>()?;
|
||||
for index in 0..extra_object_count {
|
||||
let key = format!("fault-window/object-{index:04}.bin");
|
||||
let seed = u8::try_from(index + 32)?;
|
||||
let (version_id, body_sha256) =
|
||||
put_object_version(client, plain_bucket, &key, payload(FAULT_WINDOW_OBJECT_BYTES, seed)).await?;
|
||||
let expected = census_object_version_on_disk(target_disk, plain_bucket, &key, version_id.as_deref())?;
|
||||
if !expected.is_complete() {
|
||||
return Err(format!("fault-window baseline census is incomplete for {plain_bucket}/{key}: {expected:?}").into());
|
||||
}
|
||||
versions.push(BaselineVersion {
|
||||
bucket: plain_bucket.to_string(),
|
||||
key,
|
||||
version_id,
|
||||
body_sha256: Some(body_sha256),
|
||||
expected,
|
||||
});
|
||||
}
|
||||
let inline = versions
|
||||
.iter()
|
||||
.find(|version| version.key == "history/inline.bin")
|
||||
@@ -739,6 +801,98 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn target_record_details(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
) -> Result<Vec<(String, String)>, Box<dyn Error + Send + Sync>> {
|
||||
let target = target_disk.to_string_lossy();
|
||||
let records = status["cluster"]["records"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("replacement recovery status omitted cluster.records: {status}"))?;
|
||||
records
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record["targetSlots"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.any(|slot| slot.contains(target.as_ref()))
|
||||
})
|
||||
.map(|record| {
|
||||
let task_id = record["taskId"]
|
||||
.as_str()
|
||||
.filter(|task_id| !task_id.is_empty())
|
||||
.ok_or_else(|| format!("replacement recovery record omitted taskId: {record}"))?;
|
||||
let state = record["state"]
|
||||
.as_str()
|
||||
.filter(|state| !state.is_empty())
|
||||
.ok_or_else(|| format!("replacement recovery record omitted state: {record}"))?;
|
||||
Ok((task_id.to_string(), state.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn running_target_generation(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
) -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
|
||||
if !cluster_status_is_definitive(status)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let records = target_record_details(status, target_disk)?;
|
||||
if records.len() == 1 && records[0].1 == "running" {
|
||||
return Ok(Some(records[0].0.clone()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn assert_target_generation_nonterminal(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
expected_task_id: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if !cluster_status_is_definitive(status)? {
|
||||
return Err(format!("replacement recovery became non-definitive during target EIO: {status}").into());
|
||||
}
|
||||
let records = target_record_details(status, target_disk)?;
|
||||
let matching = records
|
||||
.iter()
|
||||
.filter(|(task_id, _)| task_id == expected_task_id)
|
||||
.collect::<Vec<_>>();
|
||||
if matching.len() != 1 {
|
||||
return Err(format!(
|
||||
"replacement generation {expected_task_id} must remain uniquely observable during target EIO: {records:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
match matching[0].1.as_str() {
|
||||
"waiting_for_replacement" | "running" | "incomplete" => Ok(()),
|
||||
state => Err(format!(
|
||||
"replacement generation {expected_task_id} reached invalid state {state:?} during target EIO: {status}"
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_target_generation_completed(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
expected_task_id: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if !cluster_status_is_definitive(status)? {
|
||||
return Err(format!("completed replacement recovery status is non-definitive: {status}").into());
|
||||
}
|
||||
let records = target_record_details(status, target_disk)?;
|
||||
if records == [(expected_task_id.to_string(), "completed".to_string())] {
|
||||
return Ok(());
|
||||
}
|
||||
Err(
|
||||
format!("replacement generation {expected_task_id} did not retain its identity through EIO recovery: {records:?}")
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_transient_recovery_version_absence(error: &(dyn Error + 'static)) -> bool {
|
||||
matches!(
|
||||
error.downcast_ref::<rustfs_filemeta::Error>(),
|
||||
@@ -775,6 +929,165 @@ mod tests {
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
async fn wait_for_partial_replacement<'a>(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
target_disk: &Path,
|
||||
versions: &'a [BaselineVersion],
|
||||
timeout_secs: u64,
|
||||
) -> Result<(usize, &'a BaselineVersion, String), Box<dyn Error + Send + Sync>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
let missing = incomplete_versions(target_disk, versions)?;
|
||||
let completed = versions.len().saturating_sub(missing.len());
|
||||
if completed > 0 && completed < versions.len() {
|
||||
let witness = versions.iter().find(|version| {
|
||||
census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref())
|
||||
.is_ok_and(|actual| actual.matches_manifest(&version.expected))
|
||||
});
|
||||
if let Some(witness) = witness {
|
||||
let status = replacement_status(cluster).await?;
|
||||
if let Some(task_id) = running_target_generation(&status, target_disk)? {
|
||||
return Ok((completed, witness, task_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if completed == versions.len() {
|
||||
return Err(format!(
|
||||
"replacement rebuilt all {} baseline versions before target EIO could be injected",
|
||||
versions.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let status = replacement_status(cluster).await?;
|
||||
return Err(format!(
|
||||
"replacement made no observable running partial progress within {timeout_secs}s: completed={completed}/{} status={status}",
|
||||
versions.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn cluster_process_ids(cluster: &RustFSTestClusterEnvironment) -> Result<Vec<u32>, Box<dyn Error + Send + Sync>> {
|
||||
cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, node)| {
|
||||
node.process
|
||||
.as_ref()
|
||||
.map(std::process::Child::id)
|
||||
.ok_or_else(|| format!("cluster node {index} process is not running").into())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn assert_cluster_processes_and_listeners_unchanged(
|
||||
cluster: &mut RustFSTestClusterEnvironment,
|
||||
expected_pids: &[u32],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if cluster.nodes.len() != expected_pids.len() {
|
||||
return Err("cluster node count changed during target EIO".into());
|
||||
}
|
||||
for (index, (node, expected_pid)) in cluster.nodes.iter_mut().zip(expected_pids).enumerate() {
|
||||
let process = node
|
||||
.process
|
||||
.as_mut()
|
||||
.ok_or_else(|| format!("cluster node {index} process disappeared during target EIO"))?;
|
||||
if process.id() != *expected_pid {
|
||||
return Err(format!(
|
||||
"cluster node {index} PID changed during target EIO: expected {expected_pid}, got {}",
|
||||
process.id()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if let Some(status) = process.try_wait()? {
|
||||
return Err(format!("cluster node {index} exited during target EIO with {status}").into());
|
||||
}
|
||||
match timeout(Duration::from_secs(2), TcpStream::connect(&node.address)).await {
|
||||
Ok(Ok(stream)) => drop(stream),
|
||||
Ok(Err(error)) => {
|
||||
return Err(format!("cluster node {index} TCP listener failed during target EIO: {error}").into());
|
||||
}
|
||||
Err(_) => return Err(format!("cluster node {index} TCP listener timed out during target EIO").into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exercise_mid_rebuild_io_fault(
|
||||
cluster: &mut RustFSTestClusterEnvironment,
|
||||
replacement_mount: &FaultableBlockMount,
|
||||
target_disk: &Path,
|
||||
versions: &[BaselineVersion],
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let partial_timeout_secs = std::env::var("RUSTFS_HEAL_DISK_IO_PARTIAL_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(120);
|
||||
let (partial_count, witness, task_id) =
|
||||
wait_for_partial_replacement(cluster, target_disk, versions, partial_timeout_secs).await?;
|
||||
let expected_pids = cluster_process_ids(cluster)?;
|
||||
|
||||
replacement_mount
|
||||
.make_unavailable()
|
||||
.map_err(|error| format!("failed to install dm-error on the active replacement: {error}"))?;
|
||||
let fault_result = async {
|
||||
replacement_mount
|
||||
.verify_raw_io_is_unavailable()
|
||||
.map_err(|error| format!("active replacement dm-error was not proven by direct I/O: {error}"))?;
|
||||
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
|
||||
|
||||
let observation_deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
let status = timeout(Duration::from_secs(5), replacement_status(cluster))
|
||||
.await
|
||||
.map_err(|_| "replacement recovery status timed out during target EIO")??;
|
||||
assert_target_generation_nonterminal(&status, target_disk, &task_id)?;
|
||||
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
|
||||
if Instant::now() >= observation_deadline {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Ok::<(), Box<dyn Error + Send + Sync>>(())
|
||||
}
|
||||
.await;
|
||||
let restore_result = replacement_mount
|
||||
.restore_available()
|
||||
.map_err(|error| format!("failed to restore the active replacement after dm-error: {error}"));
|
||||
if let Err(error) = fault_result {
|
||||
if let Err(restore_error) = restore_result {
|
||||
info!(%restore_error, "replacement restore also failed while preserving target EIO failure");
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
restore_result?;
|
||||
|
||||
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
|
||||
let actual = census_object_version_on_disk(target_disk, &witness.bucket, &witness.key, witness.version_id.as_deref())?;
|
||||
if !actual.matches_manifest(&witness.expected) {
|
||||
return Err(format!(
|
||||
"witnessed replacement shard did not survive target EIO for {}/{}@{:?}: {actual:?}",
|
||||
witness.bucket, witness.key, witness.version_id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let completed_after_restore = versions
|
||||
.len()
|
||||
.saturating_sub(incomplete_versions(target_disk, versions)?.len());
|
||||
if completed_after_restore < partial_count {
|
||||
return Err(format!(
|
||||
"replacement progress regressed across target EIO: before={partial_count}, after={completed_after_restore}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
fn replacement_completion_state(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
@@ -863,7 +1176,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_replacement_e2e(parity: usize, test_name: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
async fn run_replacement_e2e(
|
||||
parity: usize,
|
||||
test_name: &str,
|
||||
scenario: ReplacementScenario,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !privileged_run_enabled()? {
|
||||
return Ok(());
|
||||
@@ -900,7 +1217,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut target_mount = target_mount.ok_or("target drive was not mounted with the faultable block fixture")?;
|
||||
let mut replacement_mount = ZramBlockMount::reserve(&target_disk)?;
|
||||
let mut zram_replacement = match scenario {
|
||||
ReplacementScenario::Baseline => Some(ZramBlockMount::reserve(&target_disk)?),
|
||||
ReplacementScenario::MidRebuildIoFault => None,
|
||||
};
|
||||
|
||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
|
||||
cluster.set_env("RUSTFS_SCANNER_ENABLED", "true");
|
||||
@@ -908,13 +1228,21 @@ mod tests {
|
||||
cluster.set_env("RUSTFS_SCANNER_CYCLE", "1");
|
||||
cluster.set_env("RUSTFS_SCANNER_START_DELAY_SECS", "0");
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
|
||||
if scenario == ReplacementScenario::MidRebuildIoFault {
|
||||
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
|
||||
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
|
||||
}
|
||||
for node_index in 0..cluster.nodes.len() {
|
||||
cluster.set_node_env(node_index, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
|
||||
}
|
||||
cluster.start().await?;
|
||||
|
||||
let clients = cluster.create_all_clients()?;
|
||||
let versions = seed_baseline(&clients[0], &target_disk)
|
||||
let extra_object_count = match scenario {
|
||||
ReplacementScenario::Baseline => 0,
|
||||
ReplacementScenario::MidRebuildIoFault => FAULT_WINDOW_OBJECT_COUNT,
|
||||
};
|
||||
let versions = seed_baseline(&clients[0], &target_disk, extra_object_count)
|
||||
.await
|
||||
.map_err(|error| format!("pre-fault baseline seeding failed: {error}"))?;
|
||||
verify_bodies(&clients[0], &versions)
|
||||
@@ -935,7 +1263,20 @@ mod tests {
|
||||
|
||||
cluster.stop_node_gracefully(TARGET_NODE).await?;
|
||||
target_mount.cleanup()?;
|
||||
replacement_mount.mount_target()?;
|
||||
let mut faultable_replacement = match scenario {
|
||||
ReplacementScenario::Baseline => {
|
||||
zram_replacement
|
||||
.as_mut()
|
||||
.ok_or("baseline replacement zram was not reserved")?
|
||||
.mount_target()?;
|
||||
None
|
||||
}
|
||||
ReplacementScenario::MidRebuildIoFault => Some(FaultableBlockMount::mount_live_recovery(
|
||||
&target_disk,
|
||||
&image_root,
|
||||
&format!("p{parity}_replacement_node{TARGET_NODE}_drive{TARGET_DRIVE}"),
|
||||
)?),
|
||||
};
|
||||
let missing_before_restart = incomplete_versions(&target_disk, &versions)?;
|
||||
assert_eq!(
|
||||
missing_before_restart.len(),
|
||||
@@ -945,12 +1286,28 @@ mod tests {
|
||||
cluster.start_node(TARGET_NODE).await?;
|
||||
|
||||
let recovery_result = async {
|
||||
let faulted_task_id = match faultable_replacement.as_ref() {
|
||||
Some(replacement) => {
|
||||
Some(exercise_mid_rebuild_io_fault(&mut cluster, replacement, &target_disk, &versions).await?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
|
||||
if let Some(task_id) = faulted_task_id {
|
||||
let status = replacement_status(&cluster).await?;
|
||||
assert_target_generation_completed(&status, &target_disk, &task_id)?;
|
||||
}
|
||||
verify_bodies(&clients[0], &versions).await
|
||||
}
|
||||
.await;
|
||||
let stop_result = cluster.stop_node_gracefully(TARGET_NODE).await;
|
||||
let replacement_cleanup_result = replacement_mount.cleanup();
|
||||
let replacement_cleanup_result = match faultable_replacement.as_mut() {
|
||||
Some(replacement) => replacement.cleanup(),
|
||||
None => zram_replacement
|
||||
.as_mut()
|
||||
.ok_or("baseline replacement zram disappeared before cleanup")?
|
||||
.cleanup(),
|
||||
};
|
||||
|
||||
if let Err(error) = recovery_result {
|
||||
if let Err(stop_error) = stop_result {
|
||||
@@ -1097,6 +1454,65 @@ mod tests {
|
||||
assert_eq!(status_samples.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_eio_status_preserves_one_nonterminal_generation() {
|
||||
let target = Path::new("/mnt/target");
|
||||
for state in ["waiting_for_replacement", "running", "incomplete"] {
|
||||
let status = serde_json::json!({
|
||||
"cluster": {
|
||||
"definitive": true,
|
||||
"records": [{
|
||||
"taskId": "generation-a",
|
||||
"state": state,
|
||||
"targetSlots": ["http://127.0.0.1:9000/mnt/target"]
|
||||
}]
|
||||
}
|
||||
});
|
||||
assert!(assert_target_generation_nonterminal(&status, target, "generation-a").is_ok());
|
||||
}
|
||||
|
||||
let running = serde_json::json!({
|
||||
"cluster": {
|
||||
"definitive": true,
|
||||
"records": [{
|
||||
"taskId": "generation-a",
|
||||
"state": "running",
|
||||
"targetSlots": ["/mnt/target"]
|
||||
}]
|
||||
}
|
||||
});
|
||||
assert_eq!(running_target_generation(&running, target).unwrap().as_deref(), Some("generation-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_eio_status_rejects_false_or_replaced_completion() {
|
||||
let target = Path::new("/mnt/target");
|
||||
let completed = serde_json::json!({
|
||||
"cluster": {
|
||||
"definitive": true,
|
||||
"records": [{
|
||||
"taskId": "generation-a",
|
||||
"state": "completed",
|
||||
"targetSlots": ["/mnt/target"]
|
||||
}]
|
||||
}
|
||||
});
|
||||
assert!(assert_target_generation_nonterminal(&completed, target, "generation-a").is_err());
|
||||
assert!(assert_target_generation_completed(&completed, target, "generation-a").is_ok());
|
||||
assert!(assert_target_generation_completed(&completed, target, "generation-b").is_err());
|
||||
|
||||
let duplicate = serde_json::json!({
|
||||
"cluster": {
|
||||
"definitive": true,
|
||||
"records": [
|
||||
{"taskId": "generation-a", "state": "running", "targetSlots": ["/mnt/target"]},
|
||||
{"taskId": "generation-a", "state": "incomplete", "targetSlots": ["/mnt/target"]}
|
||||
]
|
||||
}
|
||||
});
|
||||
assert!(assert_target_generation_nonterminal(&duplicate, target, "generation-a").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_status_requires_definitive_empty_records() {
|
||||
let target = Path::new("/mnt/target");
|
||||
@@ -1117,6 +1533,7 @@ mod tests {
|
||||
run_replacement_e2e(
|
||||
4,
|
||||
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec8_plus_4_without_admin_heal",
|
||||
ReplacementScenario::Baseline,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1130,6 +1547,20 @@ mod tests {
|
||||
run_replacement_e2e(
|
||||
6,
|
||||
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec6_plus_6_without_admin_heal",
|
||||
ReplacementScenario::Baseline,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Linux mount namespaces are per-thread; keep mount setup and process
|
||||
/// spawning on one OS thread so child RustFS nodes inherit the test mounts.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "requires Linux root/CAP_SYS_ADMIN and RUSTFS_PRIVILEGED_REPLACEMENT_E2E=1"]
|
||||
async fn test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
run_replacement_e2e(
|
||||
4,
|
||||
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio",
|
||||
ReplacementScenario::MidRebuildIoFault,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||
use tokio::fs;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -86,7 +86,7 @@ type BacklogMetricPoints = Arc<Mutex<BTreeMap<String, BTreeMap<String, (u64, f64
|
||||
/// default. This suite opts its source servers into the loopback allowance explicitly
|
||||
/// so the shared harness (`RustFSTestEnvironment` / the cluster harness) stays
|
||||
/// fail-closed and every other e2e scenario keeps exercising the production SSRF policy.
|
||||
const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
|
||||
pub(crate) const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
|
||||
|
||||
/// Short data-scanner cycle for the failure-recovery tests (backlog#1147 repl-5).
|
||||
///
|
||||
@@ -402,14 +402,14 @@ fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String),
|
||||
Ok((access_key, secret_key, session_token))
|
||||
}
|
||||
|
||||
struct ReplicationTargetOptions<'a> {
|
||||
endpoint: &'a str,
|
||||
access_key: &'a str,
|
||||
secret_key: &'a str,
|
||||
target_bucket: &'a str,
|
||||
secure: bool,
|
||||
skip_tls_verify: bool,
|
||||
ca_cert_pem: Option<&'a str>,
|
||||
pub(crate) struct ReplicationTargetOptions<'a> {
|
||||
pub(crate) endpoint: &'a str,
|
||||
pub(crate) access_key: &'a str,
|
||||
pub(crate) secret_key: &'a str,
|
||||
pub(crate) target_bucket: &'a str,
|
||||
pub(crate) secure: bool,
|
||||
pub(crate) skip_tls_verify: bool,
|
||||
pub(crate) ca_cert_pem: Option<&'a str>,
|
||||
}
|
||||
|
||||
async fn set_replication_target(
|
||||
@@ -434,7 +434,7 @@ async fn set_replication_target(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_replication_target_with_options(
|
||||
pub(crate) async fn set_replication_target_with_options(
|
||||
source_env: &RustFSTestEnvironment,
|
||||
source_bucket: &str,
|
||||
options: ReplicationTargetOptions<'_>,
|
||||
@@ -504,7 +504,7 @@ async fn send_set_replication_target_request(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put_bucket_replication(
|
||||
pub(crate) async fn put_bucket_replication(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
target_arn: &str,
|
||||
@@ -643,7 +643,10 @@ async fn get_bucket_replication(
|
||||
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
|
||||
}
|
||||
|
||||
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
pub(crate) async fn enable_bucket_versioning(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
set_bucket_versioning(env, bucket, BucketVersioningStatus::Enabled).await
|
||||
}
|
||||
|
||||
@@ -2005,6 +2008,87 @@ fn proxy_error_response(error: impl std::fmt::Display) -> Response<Full<bytes::B
|
||||
.expect("static proxy response must be valid")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ReplicationResponseHoldRuntime {
|
||||
armed: Arc<AtomicBool>,
|
||||
backend_committed: watch::Sender<bool>,
|
||||
release: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl ReplicationResponseHoldRuntime {
|
||||
fn try_claim(&self) -> bool {
|
||||
self.armed
|
||||
.compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
struct ReplicationResponseHold {
|
||||
armed: Arc<AtomicBool>,
|
||||
backend_committed_signal: watch::Sender<bool>,
|
||||
backend_committed: watch::Receiver<bool>,
|
||||
release: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl ReplicationResponseHold {
|
||||
fn arm(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if self.armed.load(Ordering::Acquire) {
|
||||
return Err("replication response hold was already armed".into());
|
||||
}
|
||||
self.backend_committed_signal
|
||||
.send(false)
|
||||
.map_err(|_| "replication response hold closed before rearming")?;
|
||||
self.release
|
||||
.send(false)
|
||||
.map_err(|_| "replication response hold closed before rearming")?;
|
||||
self.armed
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map_err(|_| "replication response hold was already armed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_backend_commit(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let wait = async {
|
||||
while !*self.backend_committed.borrow() {
|
||||
self.backend_committed
|
||||
.changed()
|
||||
.await
|
||||
.map_err(|_| "replication response hold closed before the backend committed")?;
|
||||
}
|
||||
Ok::<(), Box<dyn Error + Send + Sync>>(())
|
||||
};
|
||||
timeout(Duration::from_secs(60), wait)
|
||||
.await
|
||||
.map_err(|_| "timed out waiting for the replication backend to commit")?
|
||||
}
|
||||
|
||||
fn release(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
self.release
|
||||
.send(true)
|
||||
.map_err(|_| "replication response hold closed before release")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn replication_response_hold() -> (ReplicationResponseHoldRuntime, ReplicationResponseHold) {
|
||||
let armed = Arc::new(AtomicBool::new(false));
|
||||
let (backend_committed, backend_committed_rx) = watch::channel(false);
|
||||
let (release, release_rx) = watch::channel(false);
|
||||
(
|
||||
ReplicationResponseHoldRuntime {
|
||||
armed: armed.clone(),
|
||||
backend_committed: backend_committed.clone(),
|
||||
release: release_rx,
|
||||
},
|
||||
ReplicationResponseHold {
|
||||
armed,
|
||||
backend_committed_signal: backend_committed,
|
||||
backend_committed: backend_committed_rx,
|
||||
release,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn forward_replication_proxy_request(
|
||||
request: Request<Incoming>,
|
||||
backend_url: &str,
|
||||
@@ -2012,6 +2096,7 @@ async fn forward_replication_proxy_request(
|
||||
request_count: &AtomicU64,
|
||||
mut replication_enabled: watch::Receiver<bool>,
|
||||
mut held_tagging: watch::Receiver<Option<String>>,
|
||||
mut response_hold: ReplicationResponseHoldRuntime,
|
||||
) -> Response<Full<bytes::Bytes>> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let is_replication = parts
|
||||
@@ -2037,6 +2122,7 @@ async fn forward_replication_proxy_request(
|
||||
}
|
||||
}
|
||||
}
|
||||
let hold_response = is_replication && parts.method == http::Method::PUT && response_hold.try_claim();
|
||||
|
||||
let Some(path_and_query) = parts.uri.path_and_query() else {
|
||||
return proxy_error_response("request URI omitted path");
|
||||
@@ -2059,6 +2145,16 @@ async fn forward_replication_proxy_request(
|
||||
Ok(body) => body,
|
||||
Err(error) => return proxy_error_response(error),
|
||||
};
|
||||
if hold_response && status.is_success() {
|
||||
if response_hold.backend_committed.send(true).is_err() {
|
||||
return proxy_error_response("replication response hold closed after the backend committed");
|
||||
}
|
||||
while !*response_hold.release.borrow() {
|
||||
if response_hold.release.changed().await.is_err() {
|
||||
return proxy_error_response("replication response hold closed before release");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut proxied = Response::builder().status(status);
|
||||
for (name, value) in &headers {
|
||||
proxied = proxied.header(name, value);
|
||||
@@ -2070,7 +2166,7 @@ async fn start_replication_counting_proxy(
|
||||
backend_url: &str,
|
||||
tasks: &mut JoinSet<()>,
|
||||
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>), Box<dyn Error + Send + Sync>> {
|
||||
let (proxy_url, request_count, replication_enabled, _held_tagging) =
|
||||
let (proxy_url, request_count, replication_enabled, _held_tagging, _response_hold) =
|
||||
start_replication_counting_proxy_with_tag_hold(backend_url, tasks).await?;
|
||||
Ok((proxy_url, request_count, replication_enabled))
|
||||
}
|
||||
@@ -2082,7 +2178,16 @@ async fn start_replication_counting_proxy(
|
||||
async fn start_replication_counting_proxy_with_tag_hold(
|
||||
backend_url: &str,
|
||||
tasks: &mut JoinSet<()>,
|
||||
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>, watch::Sender<Option<String>>), Box<dyn Error + Send + Sync>> {
|
||||
) -> Result<
|
||||
(
|
||||
String,
|
||||
Arc<AtomicU64>,
|
||||
watch::Sender<bool>,
|
||||
watch::Sender<Option<String>>,
|
||||
ReplicationResponseHold,
|
||||
),
|
||||
Box<dyn Error + Send + Sync>,
|
||||
> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let proxy_url = format!("http://{}", listener.local_addr()?);
|
||||
let backend_url = backend_url.to_string();
|
||||
@@ -2090,6 +2195,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
let task_request_count = request_count.clone();
|
||||
let (replication_enabled, task_replication_enabled) = watch::channel(true);
|
||||
let (held_tagging, task_held_tagging) = watch::channel(None);
|
||||
let (response_hold_runtime, response_hold) = replication_response_hold();
|
||||
tasks.spawn(async move {
|
||||
let client = local_http_client();
|
||||
let mut connections = JoinSet::new();
|
||||
@@ -2102,6 +2208,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
let request_count = task_request_count.clone();
|
||||
let replication_enabled = task_replication_enabled.clone();
|
||||
let held_tagging = task_held_tagging.clone();
|
||||
let response_hold = response_hold_runtime.clone();
|
||||
connections.spawn(async move {
|
||||
let service = service_fn(move |request| {
|
||||
let backend_url = backend_url.clone();
|
||||
@@ -2109,6 +2216,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
let request_count = request_count.clone();
|
||||
let replication_enabled = replication_enabled.clone();
|
||||
let held_tagging = held_tagging.clone();
|
||||
let response_hold = response_hold.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(
|
||||
forward_replication_proxy_request(
|
||||
@@ -2118,6 +2226,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
&request_count,
|
||||
replication_enabled,
|
||||
held_tagging,
|
||||
response_hold,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -2130,7 +2239,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok((proxy_url, request_count, replication_enabled, held_tagging))
|
||||
Ok((proxy_url, request_count, replication_enabled, held_tagging, response_hold))
|
||||
}
|
||||
|
||||
async fn site_replication_remove(
|
||||
@@ -3821,6 +3930,12 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut source_env_vars = replication_fast_env();
|
||||
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
// This matrix verifies request-time rule/admission behavior. Keep the
|
||||
// background existing-object scanner outside the observation window: with
|
||||
// ExistingObjectReplication enabled it may legitimately discover an
|
||||
// object after its tags change, which is a separate data-replication path
|
||||
// that PR #5696 intentionally did not alter.
|
||||
source_env_vars.push(("RUSTFS_SCANNER_START_DELAY_SECS", "300"));
|
||||
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
|
||||
|
||||
let mut target_env_a = RustFSTestEnvironment::new().await?;
|
||||
@@ -4049,6 +4164,13 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
.await?;
|
||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||
|
||||
// A metadata edit must not retroactively admit data that failed the tag
|
||||
// filter at PUT time. This is the PR #5696 safety boundary: the target ARN
|
||||
// has no persisted data-admission state for this version, so adding the
|
||||
// matching tag later remains metadata-only and fails closed.
|
||||
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
@@ -6621,6 +6743,99 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_site_replication_replays_bucket_created_during_peer_outage_real_dual_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
// Keep compilation outside the scenario timeout. Recovery itself waits
|
||||
// for the production 30-second lightweight retry tick.
|
||||
let _rustfs_binary = rustfs_binary_path();
|
||||
|
||||
match timeout(Duration::from_secs(150), async {
|
||||
let mut site_env = replication_fast_env();
|
||||
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
|
||||
let mut site_a_env = RustFSTestEnvironment::new().await?;
|
||||
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
|
||||
|
||||
let mut site_b_env = RustFSTestEnvironment::new().await?;
|
||||
site_b_env.start_rustfs_server_without_cleanup_with_env(&site_env).await?;
|
||||
|
||||
let site_a_client = site_a_env.create_s3_client();
|
||||
let site_b_client = site_b_env.create_s3_client();
|
||||
let bucket = "site-repl-peer-outage";
|
||||
let key = "after-recovery.txt";
|
||||
let payload = b"site replication recovered the missed bucket".to_vec();
|
||||
|
||||
let add_status = site_replication_add(
|
||||
&site_a_env,
|
||||
&[
|
||||
PeerSite {
|
||||
name: "outage-site-a".to_string(),
|
||||
endpoint: site_a_env.url.clone(),
|
||||
access_key: site_a_env.access_key.clone(),
|
||||
secret_key: site_a_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
PeerSite {
|
||||
name: "outage-site-b".to_string(),
|
||||
endpoint: site_b_env.url.clone(),
|
||||
access_key: site_b_env.access_key.clone(),
|
||||
secret_key: site_b_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
assert!(add_status.success, "unexpected site add result: {add_status:?}");
|
||||
wait_for_site_replication_enabled(&site_a_env, 2).await?;
|
||||
wait_for_site_replication_enabled(&site_b_env, 2).await?;
|
||||
|
||||
site_b_env.stop_server();
|
||||
site_a_client.create_bucket().bucket(bucket).send().await?;
|
||||
site_a_client.head_bucket().bucket(bucket).send().await?;
|
||||
|
||||
let queued = site_replication_info(&site_a_env)
|
||||
.await?
|
||||
.retry_stats
|
||||
.ok_or("peer outage did not persist a site replication retry event")?;
|
||||
assert!(queued.pending + queued.failed > 0, "peer outage retry queue was unexpectedly empty");
|
||||
|
||||
site_b_env.restart_server_preserving_data(vec![], &site_env).await?;
|
||||
let recovery_deadline = tokio::time::Instant::now() + Duration::from_secs(75);
|
||||
loop {
|
||||
let bucket_recovered = site_b_client.head_bucket().bucket(bucket).send().await.is_ok();
|
||||
let queue_empty = site_replication_info(&site_a_env).await?.retry_stats.is_none();
|
||||
if bucket_recovered && queue_empty {
|
||||
break;
|
||||
}
|
||||
if tokio::time::Instant::now() >= recovery_deadline {
|
||||
return Err(format!(
|
||||
"site replication retry did not settle after peer recovery; bucket_recovered={bucket_recovered}, queue_empty={queue_empty}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
site_a_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(payload.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(wait_for_object_on_target(&site_b_client, bucket, key).await?, payload);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("site replication peer-outage recovery timed out after 150 seconds".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-applying a site's own replication config must not disable the peer's reverse direction.
|
||||
///
|
||||
/// `PutBucketReplication` broadcasts the config to every peer — the console's replication
|
||||
@@ -7152,6 +7367,27 @@ async fn put_single_tag(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn put_single_tag_current(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
tag_key: &str,
|
||||
tag_value: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
client
|
||||
.put_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.tagging(
|
||||
aws_sdk_s3::types::Tagging::builder()
|
||||
.tag_set(aws_sdk_s3::types::Tag::builder().key(tag_key).value(tag_value).build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_single_tag(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
@@ -7199,6 +7435,28 @@ async fn wait_for_single_tag(
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll one site until `tag_key` is absent from the selected version.
|
||||
async fn wait_for_tag_absent(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: &str,
|
||||
tag_key: &str,
|
||||
site: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let observed = get_single_tag(client, bucket, key, version_id, tag_key).await?;
|
||||
if observed.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("{site}: {bucket}/{key}?versionId={version_id} tag {tag_key} remained {observed:?}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag key the dual-node LWW scenario edits on both sites.
|
||||
const LWW_TAG_KEY: &str = "owner";
|
||||
|
||||
@@ -7246,6 +7504,265 @@ async fn wait_for_proxy_replication_requests(
|
||||
}
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2099: metadata admission must not drop a tag edit made after
|
||||
/// the target has committed the initial object but before the source persists
|
||||
/// that replication as COMPLETED.
|
||||
#[tokio::test]
|
||||
async fn test_site_replication_tagging_during_initial_pending_window_converges() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
// `RustFSTestEnvironment::start_rustfs_server_with_env` resolves (and on a
|
||||
// cold checkout builds) this binary synchronously. Keep that setup outside
|
||||
// the scenario timeout so 180 seconds measures the runtime race rather
|
||||
// than compilation latency.
|
||||
let _rustfs_binary = rustfs_binary_path();
|
||||
|
||||
match timeout(Duration::from_secs(180), async {
|
||||
const PAYLOAD: &str = "tagging during pending replication";
|
||||
const TAG_KEY: &str = "window";
|
||||
const TAG_VALUE: &str = "pending";
|
||||
const DELETE_PAYLOAD: &str = "tag deletion during pending replication";
|
||||
const DELETE_TAG_KEY: &str = "remove";
|
||||
const DELETE_TAG_VALUE: &str = "while-pending";
|
||||
|
||||
let mut site_env = replication_fast_env();
|
||||
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
|
||||
let mut site_a_env = RustFSTestEnvironment::new().await?;
|
||||
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
|
||||
|
||||
let mut site_b_env = RustFSTestEnvironment::new().await?;
|
||||
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
|
||||
|
||||
let site_a_client = site_a_env.create_s3_client();
|
||||
let site_b_client = site_b_env.create_s3_client();
|
||||
let mut proxy_tasks = JoinSet::new();
|
||||
let (
|
||||
site_b_proxy,
|
||||
_site_b_replication_requests,
|
||||
_site_b_replication_enabled,
|
||||
_site_b_held_tagging,
|
||||
mut site_b_response_hold,
|
||||
) = start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
|
||||
|
||||
let add_status = site_replication_add(
|
||||
&site_a_env,
|
||||
&[
|
||||
PeerSite {
|
||||
name: "pending-site-a".to_string(),
|
||||
endpoint: site_a_env.url.clone(),
|
||||
access_key: site_a_env.access_key.clone(),
|
||||
secret_key: site_a_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
PeerSite {
|
||||
name: "pending-site-b".to_string(),
|
||||
endpoint: site_b_env.url.clone(),
|
||||
access_key: site_b_env.access_key.clone(),
|
||||
secret_key: site_b_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
assert!(add_status.success, "unexpected site add result: {add_status:?}");
|
||||
|
||||
let site_info = wait_for_site_replication_enabled(&site_a_env, 2).await?;
|
||||
wait_for_site_replication_enabled(&site_b_env, 2).await?;
|
||||
let mut site_b_peer = site_info
|
||||
.sites
|
||||
.iter()
|
||||
.find(|peer| peer.endpoint == site_b_env.url.as_str())
|
||||
.ok_or("site B peer missing from replication info")?
|
||||
.clone();
|
||||
site_b_peer.endpoint = site_b_proxy.clone();
|
||||
site_b_peer.sync_state = SyncStatus::Enable;
|
||||
let edit = site_replication_edit(&site_a_env, "", &site_b_peer).await?;
|
||||
assert!(edit.success, "unexpected site B endpoint edit: {edit:?}");
|
||||
for env in [&site_a_env, &site_b_env] {
|
||||
wait_for_site_replication_info(env, |info| info.sites.iter().any(|peer| peer.endpoint == site_b_proxy)).await?;
|
||||
}
|
||||
|
||||
let bucket = "site-repl-tag-pending";
|
||||
let key = "pending-window.txt";
|
||||
site_a_client.create_bucket().bucket(bucket).send().await?;
|
||||
wait_for_bucket_on_target(&site_b_client, bucket).await?;
|
||||
|
||||
site_b_response_hold.arm()?;
|
||||
let put_task = {
|
||||
let client = site_a_client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = key.to_string();
|
||||
tokio::spawn(async move {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(PAYLOAD.as_bytes()))
|
||||
.send()
|
||||
.await
|
||||
})
|
||||
};
|
||||
|
||||
// The proxy reads B's complete successful response before parking it,
|
||||
// so B's GET proves the object is committed while A's worker is still
|
||||
// unable to persist COMPLETED.
|
||||
site_b_response_hold.wait_for_backend_commit().await?;
|
||||
wait_for_replicated_object(&site_b_client, bucket, key, PAYLOAD).await?;
|
||||
let source_head = site_a_client.head_object().bucket(bucket).key(key).send().await?;
|
||||
let version_id = source_head
|
||||
.version_id()
|
||||
.ok_or("source HEAD omitted the pending version ID")?
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
source_head.replication_status().map(|status| status.as_str()),
|
||||
Some("PENDING"),
|
||||
"source must still report PENDING while the initial replication response is held"
|
||||
);
|
||||
|
||||
let tag_task = {
|
||||
let client = site_a_client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = key.to_string();
|
||||
// The original real-machine failure used the current-version S3
|
||||
// API (no versionId). The delete phase below deliberately keeps an
|
||||
// explicit versionId so both request shapes stay covered.
|
||||
tokio::spawn(async move { put_single_tag_current(&client, &bucket, &key, TAG_KEY, TAG_VALUE).await })
|
||||
};
|
||||
wait_for_single_tag(&site_a_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site A").await?;
|
||||
assert_eq!(
|
||||
head_replication_status(&site_a_client, bucket, key, &version_id)
|
||||
.await?
|
||||
.as_deref(),
|
||||
Some("PENDING"),
|
||||
"tag update must be authored before the initial replication reaches COMPLETED"
|
||||
);
|
||||
|
||||
site_b_response_hold.release()?;
|
||||
let put_output = timeout(Duration::from_secs(60), put_task)
|
||||
.await
|
||||
.map_err(|_| "source PutObject remained blocked after releasing the replication response")???;
|
||||
timeout(Duration::from_secs(60), tag_task)
|
||||
.await
|
||||
.map_err(|_| "PutObjectTagging remained blocked after releasing the replication response")???;
|
||||
assert_eq!(put_output.version_id(), Some(version_id.as_str()));
|
||||
|
||||
wait_for_single_tag(&site_b_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site B").await?;
|
||||
wait_for_version_replication_status(&site_a_client, bucket, key, &version_id, &["COMPLETED"], "site A").await?;
|
||||
wait_for_version_replication_status(&site_b_client, bucket, key, &version_id, &["REPLICA"], "site B").await?;
|
||||
|
||||
let source_state = list_replication_state(&site_a_client, bucket).await?;
|
||||
let target_state = list_replication_state(&site_b_client, bucket).await?;
|
||||
assert_eq!(source_state, target_state, "tag replication must not fork the object version");
|
||||
assert_eq!(source_state.len(), 1, "tag replication must leave exactly one object version");
|
||||
assert_eq!(source_state[0].key, key);
|
||||
assert_eq!(source_state[0].version_id, version_id);
|
||||
|
||||
// Re-arm the same response barrier for an initially tagged object.
|
||||
// Deleting its tag while A is still PENDING proves the same admission
|
||||
// rule covers DeleteObjectTagging rather than only the original PUT
|
||||
// symptom.
|
||||
let delete_key = "pending-delete-window.txt";
|
||||
site_b_response_hold.arm()?;
|
||||
let delete_put_task = {
|
||||
let client = site_a_client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = delete_key.to_string();
|
||||
tokio::spawn(async move {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.tagging(format!("{DELETE_TAG_KEY}={DELETE_TAG_VALUE}"))
|
||||
.body(ByteStream::from_static(DELETE_PAYLOAD.as_bytes()))
|
||||
.send()
|
||||
.await
|
||||
})
|
||||
};
|
||||
|
||||
site_b_response_hold.wait_for_backend_commit().await?;
|
||||
wait_for_replicated_object(&site_b_client, bucket, delete_key, DELETE_PAYLOAD).await?;
|
||||
let delete_source_head = site_a_client.head_object().bucket(bucket).key(delete_key).send().await?;
|
||||
let delete_version_id = delete_source_head
|
||||
.version_id()
|
||||
.ok_or("source HEAD omitted the pending tagged version ID")?
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
delete_source_head.replication_status().map(|status| status.as_str()),
|
||||
Some("PENDING"),
|
||||
"source tagged object must remain PENDING while its initial response is held"
|
||||
);
|
||||
wait_for_single_tag(
|
||||
&site_b_client,
|
||||
bucket,
|
||||
delete_key,
|
||||
&delete_version_id,
|
||||
DELETE_TAG_KEY,
|
||||
DELETE_TAG_VALUE,
|
||||
"site B",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let delete_tag_task = {
|
||||
let client = site_a_client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = delete_key.to_string();
|
||||
let version_id = delete_version_id.clone();
|
||||
tokio::spawn(async move {
|
||||
client
|
||||
.delete_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await
|
||||
})
|
||||
};
|
||||
wait_for_tag_absent(&site_a_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site A").await?;
|
||||
assert_eq!(
|
||||
head_replication_status(&site_a_client, bucket, delete_key, &delete_version_id)
|
||||
.await?
|
||||
.as_deref(),
|
||||
Some("PENDING"),
|
||||
"tag deletion must be authored before the initial replication reaches COMPLETED"
|
||||
);
|
||||
|
||||
site_b_response_hold.release()?;
|
||||
let delete_put_output = timeout(Duration::from_secs(60), delete_put_task)
|
||||
.await
|
||||
.map_err(|_| "source tagged PutObject remained blocked after releasing the replication response")???;
|
||||
timeout(Duration::from_secs(60), delete_tag_task)
|
||||
.await
|
||||
.map_err(|_| "DeleteObjectTagging remained blocked after releasing the replication response")???;
|
||||
assert_eq!(delete_put_output.version_id(), Some(delete_version_id.as_str()));
|
||||
|
||||
wait_for_tag_absent(&site_b_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site B").await?;
|
||||
wait_for_version_replication_status(&site_a_client, bucket, delete_key, &delete_version_id, &["COMPLETED"], "site A")
|
||||
.await?;
|
||||
wait_for_version_replication_status(&site_b_client, bucket, delete_key, &delete_version_id, &["REPLICA"], "site B")
|
||||
.await?;
|
||||
|
||||
let source_state = list_replication_state(&site_a_client, bucket).await?;
|
||||
let target_state = list_replication_state(&site_b_client, bucket).await?;
|
||||
assert_eq!(source_state, target_state, "tag deletion must not fork the object version");
|
||||
assert_eq!(source_state.len(), 2, "pending-window scenarios must leave exactly two object versions");
|
||||
assert!(
|
||||
source_state
|
||||
.iter()
|
||||
.any(|entry| entry.key == delete_key && entry.version_id == delete_version_id),
|
||||
"tag deletion must preserve the original version identity"
|
||||
);
|
||||
|
||||
proxy_tasks.abort_all();
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("pending-window site-replication tagging test timed out".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// rustfs/backlog#1953 (audit A4/P1-6): receiver-side LWW for replicated
|
||||
/// metadata categories, exercised end to end over the real dual-node
|
||||
/// active-active site-replication control plane — sender, worker, status
|
||||
@@ -7282,9 +7799,9 @@ async fn test_site_replication_tagging_lww_converges_active_active_real_dual_nod
|
||||
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
|
||||
|
||||
let mut proxy_tasks = JoinSet::new();
|
||||
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging) =
|
||||
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging, _site_a_response_hold) =
|
||||
start_replication_counting_proxy_with_tag_hold(&site_a_env.url, &mut proxy_tasks).await?;
|
||||
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging) =
|
||||
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging, _site_b_response_hold) =
|
||||
start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
|
||||
|
||||
let site_a_client = site_a_env.create_s3_client();
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
// 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.
|
||||
|
||||
//! Outbound target matrix: every object shape RustFS replicates, against
|
||||
//! every remote-target failure mode the fake target models.
|
||||
//!
|
||||
//! The matrix exists because a fix for one target class shipped a regression
|
||||
//! for another (rustfs#6895 fixed rustfs#6853 and caused rustfs#7082; see
|
||||
//! `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`).
|
||||
//! Each row is one target mode with its own RustFS source and fake target;
|
||||
//! each cell is one object shape. [`expectation`] is the single place that
|
||||
//! says what a cell must do today:
|
||||
//!
|
||||
//! - `Completed` cells must replicate and the target must hold the source
|
||||
//! bytes; the journal must also show the wire shape the cell relies on.
|
||||
//! - `KnownFailing` cells pin an open issue. They must fail for the recorded
|
||||
//! reason, and the moment they start passing the test fails with an XPASS
|
||||
//! message so the expectation is flipped in the same PR as the fix.
|
||||
//!
|
||||
//! Adding a target behavior the fleet has shown: add the mode to the fake
|
||||
//! target, add a row here, and record any cell that is red before the fix.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, replication_fast_env};
|
||||
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
|
||||
use crate::fake_s3_target::{FakeS3Target, Operation as FakeTargetOperation, RequestRecord};
|
||||
use crate::on_demand_migration::common::fake_source_client;
|
||||
use crate::replication_extension_test::{
|
||||
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, put_bucket_replication,
|
||||
set_replication_target_with_options,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTime};
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockMode};
|
||||
use bytes::Bytes;
|
||||
use std::error::Error;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// A remote-target behavior the fleet has shown, as the fake target models it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum TargetMode {
|
||||
/// RustFS / MinIO-like target: adopts source version ids, decodes any
|
||||
/// framing, enforces no checksum rule.
|
||||
Baseline,
|
||||
/// SeaweedFS 3.97 (rustfs#6853): refuses `aws-chunked` bodies. A sender
|
||||
/// that frames its uploads gets a hard failure here instead of a
|
||||
/// silently corrupted replica.
|
||||
RejectAwsChunked,
|
||||
/// AWS S3 / MinIO / Impossible Cloud (rustfs#7082): a PutObject with
|
||||
/// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`.
|
||||
RequireChecksumWithObjectLock,
|
||||
/// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids
|
||||
/// (rustfs/backlog#2085). Data must still land.
|
||||
MintOwnVersionIds,
|
||||
}
|
||||
|
||||
impl TargetMode {
|
||||
const ALL: [TargetMode; 4] = [
|
||||
TargetMode::Baseline,
|
||||
TargetMode::RejectAwsChunked,
|
||||
TargetMode::RequireChecksumWithObjectLock,
|
||||
TargetMode::MintOwnVersionIds,
|
||||
];
|
||||
|
||||
fn apply(self, target: &FakeS3Target) {
|
||||
match self {
|
||||
TargetMode::Baseline => {}
|
||||
TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true),
|
||||
TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true),
|
||||
TargetMode::MintOwnVersionIds => target.assign_own_version_ids(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn slug(self) -> &'static str {
|
||||
match self {
|
||||
TargetMode::Baseline => "baseline",
|
||||
TargetMode::RejectAwsChunked => "reject-aws-chunked",
|
||||
TargetMode::RequireChecksumWithObjectLock => "require-checksum-object-lock",
|
||||
TargetMode::MintOwnVersionIds => "mint-own-version-ids",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An object shape the replication transport treats differently.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ObjectShape {
|
||||
/// The exact rustfs#7082 reproduction: a zero-byte object.
|
||||
Empty,
|
||||
/// Small single-part object with no Object Lock parameters.
|
||||
Plain,
|
||||
/// Single-part object with a GOVERNANCE retention period.
|
||||
Retention,
|
||||
/// Single-part object with legal hold ON.
|
||||
LegalHold,
|
||||
/// Two-part multipart upload, no Object Lock parameters.
|
||||
Multipart,
|
||||
/// Two-part multipart upload with a GOVERNANCE retention period; the
|
||||
/// lock headers travel on CreateMultipartUpload, which has no body.
|
||||
LockedMultipart,
|
||||
}
|
||||
|
||||
impl ObjectShape {
|
||||
const ALL: [ObjectShape; 6] = [
|
||||
ObjectShape::Empty,
|
||||
ObjectShape::Plain,
|
||||
ObjectShape::Retention,
|
||||
ObjectShape::LegalHold,
|
||||
ObjectShape::Multipart,
|
||||
ObjectShape::LockedMultipart,
|
||||
];
|
||||
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
ObjectShape::Empty => "matrix/empty.bin",
|
||||
ObjectShape::Plain => "matrix/plain.bin",
|
||||
ObjectShape::Retention => "matrix/retention.bin",
|
||||
ObjectShape::LegalHold => "matrix/legal-hold.bin",
|
||||
ObjectShape::Multipart => "matrix/multipart.bin",
|
||||
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
|
||||
}
|
||||
}
|
||||
|
||||
fn carries_object_lock_params(self) -> bool {
|
||||
matches!(self, ObjectShape::Retention | ObjectShape::LegalHold | ObjectShape::LockedMultipart)
|
||||
}
|
||||
|
||||
/// Upload the shape to the source and return the bytes the target must
|
||||
/// end up holding.
|
||||
async fn put(self, client: &Client, bucket: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
|
||||
let key = self.key();
|
||||
match self {
|
||||
ObjectShape::Empty => {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b""))
|
||||
.send()
|
||||
.await?;
|
||||
Ok(Bytes::new())
|
||||
}
|
||||
ObjectShape::Plain => {
|
||||
let body = payload(64 * 1024, 0x11);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
Ok(body)
|
||||
}
|
||||
ObjectShape::Retention => {
|
||||
let body = payload(48 * 1024, 0x22);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.object_lock_mode(ObjectLockMode::Governance)
|
||||
.object_lock_retain_until_date(retain_until())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(body)
|
||||
}
|
||||
ObjectShape::LegalHold => {
|
||||
let body = payload(32 * 1024, 0x33);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(body)
|
||||
}
|
||||
ObjectShape::Multipart => multipart_put(client, bucket, key, 0x44, false).await,
|
||||
ObjectShape::LockedMultipart => multipart_put(client, bucket, key, 0x55, true).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Expectation {
|
||||
/// Replicates COMPLETED and the target holds the source bytes.
|
||||
Completed,
|
||||
/// Replicates FAILED today for a recorded reason; pinned to an open issue.
|
||||
KnownFailing(&'static str),
|
||||
}
|
||||
|
||||
/// The cells that are red today, each pinned to the open issue that owns it.
|
||||
/// This is the single source of truth: a fix that turns a cell green must
|
||||
/// remove its entry in the same PR, and [`check_known_failing_cell`] refuses
|
||||
/// an unexpected pass so the table cannot go stale silently. rustfs#7082
|
||||
/// (Retention and LegalHold against the checksum-requiring target) lived
|
||||
/// here until the replication PUT started carrying a Content-MD5 derived
|
||||
/// from the source ETag.
|
||||
const KNOWN_FAILING_CELLS: &[(TargetMode, ObjectShape, &str)] = &[];
|
||||
|
||||
fn expectation(mode: TargetMode, shape: ObjectShape) -> Expectation {
|
||||
KNOWN_FAILING_CELLS
|
||||
.iter()
|
||||
.find(|(known_mode, known_shape, _)| *known_mode == mode && *known_shape == shape)
|
||||
.map(|(_, _, issue)| Expectation::KnownFailing(issue))
|
||||
.unwrap_or(Expectation::Completed)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matrix_baseline_target() -> TestResult {
|
||||
run_row(TargetMode::Baseline).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matrix_reject_aws_chunked_target() -> TestResult {
|
||||
run_row(TargetMode::RejectAwsChunked).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matrix_require_checksum_with_object_lock_target() -> TestResult {
|
||||
run_row(TargetMode::RequireChecksumWithObjectLock).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matrix_mint_own_version_ids_target() -> TestResult {
|
||||
run_row(TargetMode::MintOwnVersionIds).await
|
||||
}
|
||||
|
||||
/// Every known-red entry must name a real cell and an issue, and the lookup
|
||||
/// must round-trip, so a stale or mistyped entry cannot silently pin nothing.
|
||||
#[test]
|
||||
fn known_failing_table_names_real_cells() {
|
||||
for (mode, shape, issue) in KNOWN_FAILING_CELLS {
|
||||
assert!(
|
||||
TargetMode::ALL.contains(mode) && ObjectShape::ALL.contains(shape),
|
||||
"{mode:?}/{shape:?} is not a matrix cell"
|
||||
);
|
||||
assert!(
|
||||
issue.starts_with("rustfs#") || issue.starts_with("rustfs/backlog#"),
|
||||
"{issue} must name an open issue"
|
||||
);
|
||||
assert_eq!(expectation(*mode, *shape), Expectation::KnownFailing(issue));
|
||||
}
|
||||
let red_cells = TargetMode::ALL
|
||||
.iter()
|
||||
.flat_map(|mode| ObjectShape::ALL.iter().map(move |shape| (*mode, *shape)))
|
||||
.filter(|(mode, shape)| matches!(expectation(*mode, *shape), Expectation::KnownFailing(_)))
|
||||
.count();
|
||||
assert_eq!(red_cells, KNOWN_FAILING_CELLS.len());
|
||||
}
|
||||
|
||||
async fn run_row(mode: TargetMode) -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let target = FakeS3Target::start().await?;
|
||||
let target_bucket = format!("matrix-{}-dst", mode.slug());
|
||||
target.create_bucket_with_object_lock(target_bucket.clone());
|
||||
mode.apply(&target);
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut env_vars = replication_fast_env();
|
||||
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
|
||||
source_env.start_rustfs_server_with_env(vec![], &env_vars).await?;
|
||||
|
||||
let source_bucket = format!("matrix-{}-src", mode.slug());
|
||||
let source_client = source_env.create_s3_client();
|
||||
source_client
|
||||
.create_bucket()
|
||||
.bucket(&source_bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
enable_bucket_versioning(&source_env, &source_bucket).await?;
|
||||
let target_arn = set_replication_target_with_options(
|
||||
&source_env,
|
||||
&source_bucket,
|
||||
ReplicationTargetOptions {
|
||||
endpoint: &target.address(),
|
||||
access_key: FAKE_ACCESS_KEY,
|
||||
secret_key: FAKE_SECRET_KEY,
|
||||
target_bucket: &target_bucket,
|
||||
secure: false,
|
||||
skip_tls_verify: false,
|
||||
ca_cert_pem: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication(&source_env, &source_bucket, &target_arn).await?;
|
||||
|
||||
let target_client = fake_source_client(&target);
|
||||
let mut failures = Vec::new();
|
||||
for shape in ObjectShape::ALL {
|
||||
let cell = format!("{}/{:?}", mode.slug(), shape);
|
||||
let expected_body = shape.put(&source_client, &source_bucket).await?;
|
||||
let status = wait_for_terminal_replication_status(&source_client, &source_bucket, shape.key()).await?;
|
||||
let journal = target.requests();
|
||||
let outcome = match expectation(mode, shape) {
|
||||
Expectation::Completed => {
|
||||
check_completed_cell(&cell, &status, &target_client, &target_bucket, shape, &expected_body, &journal).await
|
||||
}
|
||||
Expectation::KnownFailing(issue) => check_known_failing_cell(&cell, issue, &status, shape, &journal),
|
||||
};
|
||||
if let Err(err) = outcome {
|
||||
failures.push(format!("{cell}: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
target.shutdown().await;
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} matrix cell(s) violated their expectation:\n {}",
|
||||
failures.len(),
|
||||
failures.join("\n ")
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_completed_cell(
|
||||
cell: &str,
|
||||
status: &str,
|
||||
target_client: &Client,
|
||||
target_bucket: &str,
|
||||
shape: ObjectShape,
|
||||
expected_body: &Bytes,
|
||||
journal: &[RequestRecord],
|
||||
) -> TestResult {
|
||||
if status != "COMPLETED" {
|
||||
return Err(format!("expected COMPLETED, source reports {status}").into());
|
||||
}
|
||||
let stored = target_client
|
||||
.get_object()
|
||||
.bucket(target_bucket)
|
||||
.key(shape.key())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("target GET failed after COMPLETED: {err}"))?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
if stored != *expected_body {
|
||||
return Err(format!(
|
||||
"target holds {} bytes that differ from the {} source bytes (COMPLETED over a corrupted replica)",
|
||||
stored.len(),
|
||||
expected_body.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// The wire shape the cell relies on: plain signed payloads (rustfs#6853)
|
||||
// for every upload of this key, and the lock headers present exactly when
|
||||
// the shape carries them.
|
||||
let uploads: Vec<&RequestRecord> = journal
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record.key.as_deref() == Some(shape.key())
|
||||
&& matches!(
|
||||
record.operation,
|
||||
FakeTargetOperation::PutObject | FakeTargetOperation::UploadPart | FakeTargetOperation::CreateMultipartUpload
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if uploads.is_empty() {
|
||||
return Err("no upload reached the target although the source reports COMPLETED".into());
|
||||
}
|
||||
if let Some(framed) = uploads.iter().find(|record| record.transport.aws_chunked) {
|
||||
return Err(format!("{cell}: an upload went out aws-chunked (rustfs#6853 framing): {framed:?}").into());
|
||||
}
|
||||
let lock_headers_seen = uploads.iter().any(|record| record.transport.object_lock_params);
|
||||
if lock_headers_seen != shape.carries_object_lock_params() {
|
||||
return Err(format!(
|
||||
"object lock headers on the wire: {lock_headers_seen}, shape carries them: {}",
|
||||
shape.carries_object_lock_params()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// rustfs#7082 contract: every PutObject that carries Object Lock
|
||||
// parameters also carries Content-MD5 or an x-amz-checksum-* header,
|
||||
// whatever the target's own policy is.
|
||||
if let Some(bare) = uploads.iter().find(|record| {
|
||||
record.operation == FakeTargetOperation::PutObject
|
||||
&& record.transport.object_lock_params
|
||||
&& record.transport.content_md5.is_none()
|
||||
&& record.transport.checksum_headers.is_empty()
|
||||
}) {
|
||||
return Err(format!("a locked PutObject went out without any integrity header (rustfs#7082): {bare:?}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_known_failing_cell(cell: &str, issue: &str, status: &str, shape: ObjectShape, journal: &[RequestRecord]) -> TestResult {
|
||||
if status == "COMPLETED" {
|
||||
return Err(format!(
|
||||
"XPASS: {cell} reached COMPLETED but the expectation table pins it to {issue}; \
|
||||
the fix landed, so flip this cell to Expectation::Completed in the same PR"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if status != "FAILED" {
|
||||
return Err(format!("expected FAILED ({issue}), source reports {status}").into());
|
||||
}
|
||||
// Fail for the recorded reason, not by accident: the PUT carried the lock
|
||||
// headers and no integrity header at all.
|
||||
let rejected = journal.iter().any(|record| {
|
||||
record.operation == FakeTargetOperation::PutObject
|
||||
&& record.key.as_deref() == Some(shape.key())
|
||||
&& record.transport.object_lock_params
|
||||
&& record.transport.content_md5.is_none()
|
||||
&& record.transport.checksum_headers.is_empty()
|
||||
});
|
||||
if !rejected {
|
||||
return Err(format!(
|
||||
"FAILED, but not for the {issue} reason (a locked PUT without Content-MD5 / x-amz-checksum-*); journal: {journal:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// First terminal replication status (`COMPLETED` or `FAILED`) the source
|
||||
/// reports for the key.
|
||||
async fn wait_for_terminal_replication_status(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let wait = async {
|
||||
loop {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
match head.replication_status().map(|status| status.as_str().to_string()) {
|
||||
Some(status) if status == "COMPLETED" || status == "FAILED" => return Ok(status),
|
||||
_ => sleep(Duration::from_millis(200)).await,
|
||||
}
|
||||
}
|
||||
};
|
||||
match timeout(Duration::from_secs(90), wait).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("{key} reached no terminal replication status within 90 seconds").into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn multipart_put(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
fill: u8,
|
||||
locked: bool,
|
||||
) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
|
||||
let part_one = payload(5 * 1024 * 1024, fill);
|
||||
let part_two = payload(256 * 1024, fill.wrapping_add(1));
|
||||
let mut create = client.create_multipart_upload().bucket(bucket).key(key);
|
||||
if locked {
|
||||
create = create
|
||||
.object_lock_mode(ObjectLockMode::Governance)
|
||||
.object_lock_retain_until_date(retain_until());
|
||||
}
|
||||
let upload_id = create
|
||||
.send()
|
||||
.await?
|
||||
.upload_id()
|
||||
.ok_or("CreateMultipartUpload returned no upload id")?
|
||||
.to_string();
|
||||
let mut completed = Vec::new();
|
||||
for (number, part) in [(1, &part_one), (2, &part_two)] {
|
||||
let etag = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(number)
|
||||
.body(ByteStream::from(part.clone()))
|
||||
.send()
|
||||
.await?
|
||||
.e_tag()
|
||||
.ok_or("UploadPart returned no ETag")?
|
||||
.to_string();
|
||||
completed.push(CompletedPart::builder().part_number(number).e_tag(etag).build());
|
||||
}
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
|
||||
.send()
|
||||
.await?;
|
||||
let mut body = Vec::with_capacity(part_one.len() + part_two.len());
|
||||
body.extend_from_slice(&part_one);
|
||||
body.extend_from_slice(&part_two);
|
||||
Ok(Bytes::from(body))
|
||||
}
|
||||
|
||||
fn payload(len: usize, fill: u8) -> Bytes {
|
||||
Bytes::from((0..len).map(|i| fill.wrapping_add((i % 251) as u8)).collect::<Vec<u8>>())
|
||||
}
|
||||
|
||||
fn retain_until() -> DateTime {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock after epoch")
|
||||
.as_secs();
|
||||
DateTime::from_secs(now as i64 + 86_400)
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
|
||||
pub(crate) use rustfs_ecstore::api::disk::{RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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.
|
||||
|
||||
//! Two-node gate for the tier stats wire contract (rustfs/backlog#2207).
|
||||
//!
|
||||
//! Before this contract, `GET /v3/tier-stats` answered from the process that
|
||||
//! happened to receive the request, so the same query returned different
|
||||
//! numbers depending on which node a client reached, with nothing in the body
|
||||
//! saying so. These tests pin the two properties that fix costs: the answer is
|
||||
//! node-independent, and it states how much of the cluster it covers.
|
||||
|
||||
use crate::common::{RustFSTestClusterEnvironment, admin_request, init_logging};
|
||||
use http::Method;
|
||||
use http::StatusCode;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const TIER_STATS_PATH: &str = "/rustfs/admin/v3/tier-stats";
|
||||
const PEER_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
async fn tier_stats(cluster: &RustFSTestClusterEnvironment, node: usize, query: &str) -> TestResult<(StatusCode, String)> {
|
||||
admin_request(
|
||||
&cluster.nodes[node].url,
|
||||
Method::GET,
|
||||
&format!("{TIER_STATS_PATH}{query}"),
|
||||
None,
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn tier_stats_json(cluster: &RustFSTestClusterEnvironment, node: usize) -> TestResult<Value> {
|
||||
let (status, body) = tier_stats(cluster, node, "").await?;
|
||||
assert_eq!(status, StatusCode::OK, "node {node} must answer tier-stats: {body}");
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
/// Wait until every node reports the whole cluster.
|
||||
///
|
||||
/// Peer clients are established during startup, so a query issued in the first
|
||||
/// moments can legitimately see a peer as unavailable. Polling separates that
|
||||
/// startup window from the failure this test is about: an answer that stays
|
||||
/// partial because the peer never reports at all.
|
||||
async fn wait_for_complete_activity(cluster: &RustFSTestClusterEnvironment) -> TestResult<Vec<Value>> {
|
||||
let deadline = Instant::now() + PEER_CONVERGENCE_TIMEOUT;
|
||||
loop {
|
||||
let mut bodies = Vec::with_capacity(cluster.nodes.len());
|
||||
for node in 0..cluster.nodes.len() {
|
||||
bodies.push(tier_stats_json(cluster, node).await?);
|
||||
}
|
||||
|
||||
if bodies.iter().all(|body| body["activity"]["status"] == "complete") {
|
||||
return Ok(bodies);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("tier-stats activity never became complete on every node: {bodies:?}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_stats_answers_the_same_cluster_result_from_either_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
cluster.start().await?;
|
||||
|
||||
let bodies = wait_for_complete_activity(&cluster).await?;
|
||||
let (first, second) = (&bodies[0], &bodies[1]);
|
||||
|
||||
for (node, body) in bodies.iter().enumerate() {
|
||||
assert_eq!(body["contractVersion"], 2, "node {node} must name the current contract version");
|
||||
assert_eq!(
|
||||
body["activity"]["nodesExpected"], 2,
|
||||
"node {node} must expect both cluster members, not only itself"
|
||||
);
|
||||
assert_eq!(
|
||||
body["activity"]["nodesReporting"], 2,
|
||||
"node {node} must include its peer's rolling window in the sum"
|
||||
);
|
||||
assert_eq!(
|
||||
body["activity"]["unavailableNodes"],
|
||||
serde_json::json!([]),
|
||||
"node {node} reported a complete result while naming an unavailable member"
|
||||
);
|
||||
}
|
||||
|
||||
// A fixture cluster has no remote tier, so this pair is equal over an
|
||||
// empty list; `nodesReporting` above is what proves the peer answered.
|
||||
// The per-tier summing itself is pinned by the aggregator unit tests.
|
||||
assert_eq!(
|
||||
first["tiers"], second["tiers"],
|
||||
"the same query must not depend on which node received it"
|
||||
);
|
||||
assert_eq!(
|
||||
first["inventory"]["status"], second["inventory"]["status"],
|
||||
"both nodes read the same persisted usage snapshot"
|
||||
);
|
||||
|
||||
cluster.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_stats_keeps_the_legacy_body_reachable_and_rejects_unknown_formats() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
cluster.start().await?;
|
||||
|
||||
let (status, body) = tier_stats(&cluster, 0, "?format=legacy").await?;
|
||||
assert_eq!(status, StatusCode::OK, "the pinned legacy body must stay reachable: {body}");
|
||||
let legacy: Value = serde_json::from_str(&body)?;
|
||||
assert!(
|
||||
legacy.is_object() && legacy.get("contractVersion").is_none(),
|
||||
"the legacy body is the bare tier map, not the current envelope: {legacy}"
|
||||
);
|
||||
|
||||
let (status, body) = tier_stats(&cluster, 0, "?format=v3").await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"an unknown format must be rejected rather than answered in another shape: {body}"
|
||||
);
|
||||
|
||||
cluster.stop();
|
||||
Ok(())
|
||||
}
|
||||
@@ -130,7 +130,7 @@ rustfs-concurrency.workspace = true
|
||||
rustfs-credentials = { workspace = true }
|
||||
rustfs-common.workspace = true
|
||||
rustfs-heal-contracts.workspace = true
|
||||
rustfs-scanner-contracts.workspace = true
|
||||
rustfs-scanner-metrics.workspace = true
|
||||
rustfs-policy.workspace = true
|
||||
rustfs-protos.workspace = true
|
||||
rustfs-replication.workspace = true
|
||||
@@ -163,10 +163,10 @@ http-body = { workspace = true }
|
||||
http-body-util.workspace = true
|
||||
url.workspace = true
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "serde", "macro-diagnostics"] }
|
||||
reed-solomon-erasure = { workspace = true, features = ["simd-accel"] }
|
||||
rustfs-erasure-codec = { workspace = true, features = ["simd-accel"] }
|
||||
reed-solomon-simd = { workspace = true }
|
||||
lazy_static.workspace = true
|
||||
moka = { workspace = true, features = ["future"] }
|
||||
moka = { workspace = true, features = ["future", "sync"] }
|
||||
rustfs-lock.workspace = true
|
||||
rustfs-io-metrics.workspace = true
|
||||
regex = { workspace = true }
|
||||
@@ -185,7 +185,7 @@ hyper-rustls = { workspace = true, default-features = false, features = ["native
|
||||
hostname.workspace = true
|
||||
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
||||
rustls-pki-types.workspace = true
|
||||
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread"] }
|
||||
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread", "time"] }
|
||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||
xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] }
|
||||
tower = { workspace = true, features = ["timeout"] }
|
||||
@@ -244,6 +244,7 @@ windows-sys = { workspace = true, features = [
|
||||
windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] }
|
||||
|
||||
[dev-dependencies]
|
||||
aws-smithy-async.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
|
||||
criterion = { workspace = true, features = ["html_reports"] }
|
||||
temp-env = { workspace = true, features = ["async_closure"] }
|
||||
|
||||
@@ -122,10 +122,10 @@ fn bench_encode_performance(c: &mut Criterion) {
|
||||
});
|
||||
group.finish();
|
||||
|
||||
// Test direct reed-solomon-erasure implementation for large shards (>= 512 bytes)
|
||||
// Test direct rustfs-erasure-codec implementation for large shards (>= 512 bytes)
|
||||
let shard_size = calc_shard_size(config.data_size, config.data_shards);
|
||||
if shard_size >= 512 && config.parity_shards > 0 {
|
||||
use reed_solomon_erasure::galois_8::ReedSolomon;
|
||||
use rustfs_erasure_codec::galois_8::ReedSolomon;
|
||||
|
||||
let mut rse_group = c.benchmark_group("encode_rse_direct");
|
||||
rse_group.throughput(Throughput::Bytes(config.data_size as u64));
|
||||
@@ -204,10 +204,10 @@ fn bench_decode_performance(c: &mut Criterion) {
|
||||
);
|
||||
group.finish();
|
||||
|
||||
// Test direct reed-solomon-erasure decoding for large shards
|
||||
// Test direct rustfs-erasure-codec decoding for large shards
|
||||
let shard_size = calc_shard_size(config.data_size, config.data_shards);
|
||||
if shard_size >= 512 && config.parity_shards > 0 {
|
||||
use reed_solomon_erasure::galois_8::ReedSolomon;
|
||||
use rustfs_erasure_codec::galois_8::ReedSolomon;
|
||||
|
||||
if let Ok(rs) = ReedSolomon::new(config.data_shards, config.parity_shards) {
|
||||
let mut rse_group = c.benchmark_group("decode_rse_direct");
|
||||
|
||||
@@ -49,7 +49,7 @@ pub mod bucket {
|
||||
apply_transition_rule, enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
|
||||
enqueue_transition_for_existing_objects_scoped, enqueue_transition_for_existing_objects_scoped_with_cancel,
|
||||
enqueue_transition_immediate, expire_transitioned_object, get_global_expiry_state, get_global_transition_state,
|
||||
init_background_expiry, manual_transition_queue_snapshot, post_restore_opts,
|
||||
init_background_expiry, lifecycle_version_delete_target, manual_transition_queue_snapshot, post_restore_opts,
|
||||
run_stale_multipart_upload_cleanup_once, validate_transition_tier,
|
||||
};
|
||||
}
|
||||
@@ -78,6 +78,7 @@ pub mod bucket {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::lifecycle::transition_transaction::{
|
||||
TransitionTransactionRecoveryStats, recover_transition_transaction_records,
|
||||
recover_transition_transaction_records_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod tier_last_day_stats {
|
||||
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
|
||||
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats, TierDailyStatsWire};
|
||||
}
|
||||
|
||||
pub mod tier_sweeper {
|
||||
@@ -128,7 +129,6 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod metadata {
|
||||
pub use crate::bucket::metadata::BUCKET_DURABILITY_CONFIG;
|
||||
pub use crate::bucket::metadata::{
|
||||
BUCKET_ACCELERATE_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_LOGGING_CONFIG,
|
||||
BUCKET_NOTIFICATION_CONFIG, BUCKET_POLICY_CONFIG, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_QUOTA_CONFIG_FILE,
|
||||
@@ -137,6 +137,7 @@ pub mod bucket {
|
||||
BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, BUCKET_WEBSITE_CONFIG, BucketMetadata, OBJECT_LOCK_CONFIG,
|
||||
load_bucket_metadata, table_catalog_path_hash,
|
||||
};
|
||||
pub use crate::bucket::metadata::{BUCKET_DURABILITY_CONFIG, BUCKET_ON_DEMAND_MIGRATION_CONFIG};
|
||||
}
|
||||
|
||||
pub mod durability {
|
||||
@@ -145,20 +146,66 @@ pub mod bucket {
|
||||
};
|
||||
}
|
||||
|
||||
pub mod on_demand_migration {
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
ApplyOutcome, BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION,
|
||||
Breaker, BreakerState, BreakerTransition, BreakerVerdict, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, GaugeGuard,
|
||||
LastSourceError, LatencyBucketSnapshot, NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache, OdmBucketSnapshot, OdmLookup,
|
||||
OdmOp, OdmOutcome, OdmStateError, OdmStats, OdmStatsSnapshot, OnDemandMigrationSys, PullError, PullFailureReason,
|
||||
PullFollower, PullLeader, PullOutcome, PullPath, PullResult, PullSlot, SOURCE_LATENCY_BUCKET_BOUNDS_MS,
|
||||
SourceLatencySnapshot, source_client_spec,
|
||||
};
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
|
||||
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy,
|
||||
SourceConfig, SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
|
||||
};
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
|
||||
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody,
|
||||
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
|
||||
idle_guarded_body,
|
||||
};
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
|
||||
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
|
||||
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
|
||||
};
|
||||
pub mod backfill {
|
||||
pub use crate::bucket::on_demand_migration::backfill::{
|
||||
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
|
||||
BACKFILL_LEASE_LOCK_PREFIX, BACKFILL_LIST_PAGE_SIZE, BACKFILL_RECOVERY_INTERVAL, BACKFILL_SAVE_EVERY_KEYS,
|
||||
BACKFILL_SAVE_INTERVAL, BackfillCheckpoint, BackfillContext, BackfillContextFactory, BackfillError,
|
||||
BackfillLastError, BackfillOwner, BackfillRecoveryStats, BackfillRequest, BackfillRunner, BackfillState,
|
||||
BucketBackfillContext, LocalBackfillObject, PriorityPullPermits, PullPermit, PullPriority, SkipExisting,
|
||||
StoredCheckpoint, SysBackfillContexts, global_backfill_runner, install_global_backfill_runner, key_hash,
|
||||
read_checkpoint, run_backfill_recovery_loop, spawn_backfill_recovery_loop,
|
||||
};
|
||||
}
|
||||
pub mod source_client {
|
||||
pub use crate::bucket::on_demand_migration::source_client::{
|
||||
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
|
||||
SourceProbe, SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
|
||||
resolve_path_style,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub mod metadata_sys {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
|
||||
pub use crate::bucket::metadata_sys::{
|
||||
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||
acquire_bucket_metadata_transaction_lock_for_incarnation, capture_bucket_metadata_incarnation, delete,
|
||||
delete_if_incarnation, delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy,
|
||||
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
||||
get_object_lock_config, get_object_lock_config_state, get_public_access_block_config, get_quota_config,
|
||||
get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config,
|
||||
get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
|
||||
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||
update_quota_if_incarnation, update_under_transaction_lock,
|
||||
acquire_bucket_metadata_transaction_lock_for_incarnation, acquire_scanner_bucket_incarnation_fence,
|
||||
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, delete_under_transaction_lock, get,
|
||||
get_accelerate_config, get_bucket_policy, get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk,
|
||||
get_cors_config, get_durability_config, get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config,
|
||||
get_notification_config, get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
|
||||
get_public_access_block_config, get_quota_config, get_replication_config, get_request_payment_config, get_sse_config,
|
||||
get_tagging_config, get_versioning_config, get_website_config, init_bucket_metadata_sys, list_bucket_targets,
|
||||
reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata, update,
|
||||
update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation, update_quota_if_incarnation,
|
||||
update_under_transaction_lock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,6 +246,20 @@ pub mod bucket {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod remote_s3_client {
|
||||
pub use crate::bucket::remote_s3_client::{
|
||||
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
|
||||
validate_remote_endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod sealed_credentials {
|
||||
pub use crate::bucket::sealed_credentials::{
|
||||
CredentialSealer, SEALED_CREDENTIAL_VERSION, SealScope, SealedCredential, SealedCredentialError,
|
||||
SealedCredentialStore, credential_sealer, install_credential_sealer, seal_secret, unseal_secret,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod replication {
|
||||
pub use crate::bucket::replication::replication_pool::{
|
||||
DurableMrfBacklogSummary, DurableMrfBucketBacklog, DurableMrfTargetBacklog, MrfBacklogObservabilitySummary,
|
||||
@@ -419,9 +480,11 @@ pub mod notification {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
|
||||
pub use crate::services::notification_sys::{
|
||||
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr,
|
||||
NotificationSys, ScannerPublicationLeaseGrant, acquire_cross_pool_fence_fleet_proof,
|
||||
acquire_legacy_transition_state_reconcile_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||
legacy_transition_state_reconcile_fleet_proof_matches, new_global_notification_sys,
|
||||
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -440,6 +503,11 @@ pub mod object {
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
SnapshotConsistencyError,
|
||||
};
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::store::DeleteAfterObjectLockSnapshotBarrier;
|
||||
}
|
||||
}
|
||||
|
||||
pub mod rebalance {
|
||||
@@ -467,8 +535,8 @@ pub mod rpc {
|
||||
pub use crate::cluster::rpc::{
|
||||
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
|
||||
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
||||
ScannerBucketListing, ScannerPeerActivity, ScannerPublicationLease, TONIC_RPC_PREFIX, TonicInterceptor,
|
||||
build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
|
||||
ScannerBucketListing, ScannerPeerActivity, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, TONIC_RPC_PREFIX,
|
||||
TonicInterceptor, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
|
||||
encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
||||
gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth,
|
||||
normalize_tonic_rpc_audience, set_tonic_canonical_body_digest, sign_ns_scanner_capability,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,9 @@ use super::runtime_boundary as runtime_sources;
|
||||
use crate::bucket::lifecycle::lifecycle;
|
||||
use crate::object_api::ObjectInfo;
|
||||
use crate::services::event_notification::{EventArgs, send_event};
|
||||
use crate::storage_api_contracts::object::{DeletedObject, ObjectToDelete};
|
||||
use rustfs_s3_types::EventName;
|
||||
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||
use rustfs_scanner_metrics::metrics::IlmAction;
|
||||
|
||||
const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]";
|
||||
const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]";
|
||||
@@ -76,6 +77,60 @@ pub(crate) fn emit_non_transitioned_expiration_event(action: IlmAction, source:
|
||||
emit_lifecycle_event(event_name, deleted, LIFECYCLE_EXPIRY_USER_AGENT);
|
||||
}
|
||||
|
||||
/// Emit the lifecycle expiration event for one version removed by the batch
|
||||
/// `NewerNoncurrentVersions` expiry path.
|
||||
///
|
||||
/// That path never sent events, so a successful noncurrent-version expiry was
|
||||
/// invisible to notification subscribers even though the equivalent
|
||||
/// current-version path emits one (backlog#2202).
|
||||
pub(crate) fn emit_noncurrent_expiration_event(bucket: &str, target: &ObjectToDelete, deleted: &DeletedObject, failed: bool) {
|
||||
if let Some((event_name, object)) = noncurrent_expiration_event(bucket, target, deleted, failed) {
|
||||
emit_lifecycle_event(event_name, object, LIFECYCLE_EXPIRY_USER_AGENT);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which event a single batch entry earned, if any.
|
||||
///
|
||||
/// Only an entry that mutated something may be announced. "No error" is not
|
||||
/// enough, and neither is `found`: the disk layer skips an absent version and
|
||||
/// reports success (`delete_versions_internal` in `disk/local.rs` continues
|
||||
/// past `FileVersionNotFound`), so a batch entry for a version that was
|
||||
/// already gone comes back indistinguishable from a committed delete. The
|
||||
/// delete plan's own source lookup is the signal that survives that, and the
|
||||
/// lifecycle batch path always performs it because every target carries an
|
||||
/// exact version identity.
|
||||
fn noncurrent_expiration_event(
|
||||
bucket: &str,
|
||||
target: &ObjectToDelete,
|
||||
deleted: &DeletedObject,
|
||||
failed: bool,
|
||||
) -> Option<(EventName, ObjectInfo)> {
|
||||
if failed || !deleted.found || deleted.source_missing {
|
||||
return None;
|
||||
}
|
||||
// A version removed by explicit version id is a plain versioned delete
|
||||
// even when that version is itself a delete marker; only a request that
|
||||
// carried no version id can publish a new delete marker. This is the rule
|
||||
// the S3 DeleteObjects path applies (issue #6745). `delete_object_versions`
|
||||
// now refuses targets without an exact version identity, so the
|
||||
// marker-creation shape is unreachable from that caller; the mapping stays
|
||||
// here so a future caller cannot silently announce the wrong mutation.
|
||||
let created_delete_marker = deleted.delete_marker && target.version_id.is_none();
|
||||
let (event_name, version_id) = if created_delete_marker {
|
||||
(EventName::LifecycleExpirationDeleteMarkerCreated, deleted.delete_marker_version_id)
|
||||
} else {
|
||||
(EventName::LifecycleExpirationDelete, deleted.version_id.or(target.version_id))
|
||||
};
|
||||
let object = ObjectInfo {
|
||||
bucket: bucket.to_string(),
|
||||
name: target.object_name.clone(),
|
||||
version_id,
|
||||
delete_marker: deleted.delete_marker,
|
||||
..Default::default()
|
||||
};
|
||||
Some((event_name, object))
|
||||
}
|
||||
|
||||
fn emit_lifecycle_event(event_name: EventName, object: ObjectInfo, user_agent: &str) {
|
||||
send_event(EventArgs {
|
||||
event_name: event_name.to_string(),
|
||||
@@ -113,6 +168,7 @@ fn non_transitioned_expiration_event_name(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn transitioned_expiration_event_marks_delete_marker_creation() {
|
||||
@@ -129,4 +185,87 @@ mod tests {
|
||||
EventName::LifecycleExpirationDelete
|
||||
);
|
||||
}
|
||||
|
||||
fn deleted_version(version_id: Uuid) -> DeletedObject {
|
||||
DeletedObject {
|
||||
object_name: "object".to_string(),
|
||||
version_id: Some(version_id),
|
||||
found: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn target_version(version_id: Option<Uuid>) -> ObjectToDelete {
|
||||
ObjectToDelete {
|
||||
object_name: "object".to_string(),
|
||||
version_id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncurrent_expiration_emits_versioned_delete_with_exact_identity() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let (event_name, object) =
|
||||
noncurrent_expiration_event("bucket", &target_version(Some(version_id)), &deleted_version(version_id), false)
|
||||
.expect("a committed delete must emit");
|
||||
assert_eq!(event_name, EventName::LifecycleExpirationDelete);
|
||||
assert_eq!(object.bucket, "bucket");
|
||||
assert_eq!(object.name, "object");
|
||||
assert_eq!(object.version_id, Some(version_id));
|
||||
}
|
||||
|
||||
/// Removing a noncurrent version that happens to be a delete marker is a
|
||||
/// plain versioned delete, not a delete-marker creation.
|
||||
#[test]
|
||||
fn noncurrent_expiration_of_a_delete_marker_version_is_a_plain_delete() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let deleted = DeletedObject {
|
||||
delete_marker: true,
|
||||
..deleted_version(version_id)
|
||||
};
|
||||
let (event_name, object) = noncurrent_expiration_event("bucket", &target_version(Some(version_id)), &deleted, false)
|
||||
.expect("a committed delete must emit");
|
||||
assert_eq!(event_name, EventName::LifecycleExpirationDelete);
|
||||
assert_eq!(object.version_id, Some(version_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncurrent_expiration_reports_a_created_delete_marker() {
|
||||
let marker_version_id = Uuid::new_v4();
|
||||
let deleted = DeletedObject {
|
||||
object_name: "object".to_string(),
|
||||
delete_marker: true,
|
||||
delete_marker_version_id: Some(marker_version_id),
|
||||
found: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (event_name, object) =
|
||||
noncurrent_expiration_event("bucket", &target_version(None), &deleted, false).expect("a committed delete must emit");
|
||||
assert_eq!(event_name, EventName::LifecycleExpirationDeleteMarkerCreated);
|
||||
assert_eq!(object.version_id, Some(marker_version_id));
|
||||
}
|
||||
|
||||
/// A batch mixes successes with failures and versions that were already
|
||||
/// gone; only a real mutation may produce an event. A version that was
|
||||
/// already gone comes back with no error and `found` set, so
|
||||
/// `source_missing` is the signal that keeps it silent.
|
||||
#[test]
|
||||
fn noncurrent_expiration_skips_failed_and_missing_versions() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let target = target_version(Some(version_id));
|
||||
assert!(noncurrent_expiration_event("bucket", &target, &deleted_version(version_id), true).is_none());
|
||||
|
||||
let absent = DeletedObject {
|
||||
source_missing: true,
|
||||
..deleted_version(version_id)
|
||||
};
|
||||
assert!(noncurrent_expiration_event("bucket", &target, &absent, false).is_none());
|
||||
|
||||
let not_found = DeletedObject {
|
||||
found: false,
|
||||
..deleted_version(version_id)
|
||||
};
|
||||
assert!(noncurrent_expiration_event("bucket", &target, ¬_found, false).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ use crate::object_api::ObjectInfo;
|
||||
pub use rustfs_lifecycle::{
|
||||
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate, TRANSITION_COMPLETE,
|
||||
TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due, expected_expiry_time,
|
||||
expiration_action_has_valid_target,
|
||||
};
|
||||
|
||||
pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts {
|
||||
|
||||
@@ -25,6 +25,7 @@ use super::{
|
||||
manual_transition_job, tier_delete_journal, transition_transaction,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::services::tier::tier_probe_intent;
|
||||
|
||||
pub(crate) const ILM_META_PREFIX: &str = "ilm";
|
||||
const ILM_META_OBJECT_PREFIX: &str = "ilm/";
|
||||
@@ -35,6 +36,7 @@ pub(crate) enum DurableIlmRecordKind {
|
||||
TierDeleteJournal,
|
||||
TierDeleteDispatchManifest,
|
||||
TransitionTransaction,
|
||||
TierProbeIntent,
|
||||
ManualTransitionJob,
|
||||
ManualTransitionScope,
|
||||
ManualTransitionTask,
|
||||
@@ -73,6 +75,12 @@ pub(crate) const TRANSITION_TRANSACTION_NAMESPACE: DurableIlmNamespace = Durable
|
||||
max_record_size: transition_transaction::MAX_TRANSITION_TRANSACTION_SIZE,
|
||||
kind: DurableIlmRecordKind::TransitionTransaction,
|
||||
};
|
||||
pub(crate) const TIER_PROBE_INTENT_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
|
||||
name: "tier-probe-intent",
|
||||
prefix: tier_probe_intent::TIER_PROBE_INTENT_RECORD_PREFIX,
|
||||
max_record_size: tier_probe_intent::MAX_TIER_PROBE_INTENT_SIZE,
|
||||
kind: DurableIlmRecordKind::TierProbeIntent,
|
||||
};
|
||||
pub(crate) const MANUAL_TRANSITION_JOB_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
|
||||
name: "manual-transition-job",
|
||||
prefix: "ilm/manual-transition/jobs",
|
||||
@@ -98,11 +106,12 @@ pub(crate) const MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE: DurableIlmNamespace
|
||||
kind: DurableIlmRecordKind::ManualTransitionWorkerResult,
|
||||
};
|
||||
|
||||
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 8] = [
|
||||
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 9] = [
|
||||
TIER_DELETE_JOURNAL_NAMESPACE,
|
||||
TIER_DELETE_JOURNAL_V6_NAMESPACE,
|
||||
TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE,
|
||||
TRANSITION_TRANSACTION_NAMESPACE,
|
||||
TIER_PROBE_INTENT_NAMESPACE,
|
||||
MANUAL_TRANSITION_JOB_NAMESPACE,
|
||||
MANUAL_TRANSITION_SCOPE_NAMESPACE,
|
||||
MANUAL_TRANSITION_TASK_NAMESPACE,
|
||||
@@ -182,6 +191,16 @@ pub(crate) enum DurableIlmRecordCheckpoint {
|
||||
identity_sha256: String,
|
||||
state: tier_delete_journal::TierDeleteDispatchManifestState,
|
||||
},
|
||||
TierDeleteDispatchParent {
|
||||
content_sha256: String,
|
||||
identity_sha256: String,
|
||||
revision: u64,
|
||||
next_chunk_sequence: u64,
|
||||
completed_journal_count: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
active_chunk_identity_sha256: Option<String>,
|
||||
completed: bool,
|
||||
},
|
||||
TransitionTransaction {
|
||||
content_sha256: String,
|
||||
identity_sha256: String,
|
||||
@@ -190,6 +209,15 @@ pub(crate) enum DurableIlmRecordCheckpoint {
|
||||
revision: u64,
|
||||
state: transition_transaction::TransitionTransactionState,
|
||||
},
|
||||
TierProbeIntent {
|
||||
content_sha256: String,
|
||||
identity_sha256: String,
|
||||
remote_version_sha256: String,
|
||||
remote_version_known: bool,
|
||||
owner_fence_sha256: String,
|
||||
revision: u64,
|
||||
state: tier_probe_intent::TierProbeIntentState,
|
||||
},
|
||||
ManualTransitionJob {
|
||||
content_sha256: String,
|
||||
identity_sha256: String,
|
||||
@@ -220,7 +248,9 @@ impl DurableIlmRecordCheckpoint {
|
||||
match self {
|
||||
Self::TierDeleteJournal { content_sha256, .. }
|
||||
| Self::TierDeleteDispatchManifest { content_sha256, .. }
|
||||
| Self::TierDeleteDispatchParent { content_sha256, .. }
|
||||
| Self::TransitionTransaction { content_sha256, .. }
|
||||
| Self::TierProbeIntent { content_sha256, .. }
|
||||
| Self::ManualTransitionJob { content_sha256, .. }
|
||||
| Self::ManualTransitionScope { content_sha256, .. }
|
||||
| Self::ManualTransitionTask { content_sha256 }
|
||||
@@ -341,6 +371,52 @@ impl DurableIlmRecordCheckpoint {
|
||||
(Preparing, DispatchAuthorized | Aborting) | (Aborting, Aborted) | (DispatchAuthorized, Completed)
|
||||
)
|
||||
}
|
||||
(
|
||||
Self::TierDeleteDispatchParent {
|
||||
identity_sha256: previous_identity,
|
||||
revision: previous_revision,
|
||||
next_chunk_sequence: previous_sequence,
|
||||
completed_journal_count: previous_completed_journals,
|
||||
active_chunk_identity_sha256: previous_active,
|
||||
completed: previous_completed,
|
||||
..
|
||||
},
|
||||
Self::TierDeleteDispatchParent {
|
||||
identity_sha256: next_identity,
|
||||
revision: next_revision,
|
||||
next_chunk_sequence: next_sequence,
|
||||
completed_journal_count: next_completed_journals,
|
||||
active_chunk_identity_sha256: next_active,
|
||||
completed: next_completed,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
let Some((sequence_delta, completed_journal_delta)) = tier_delete_dispatch_parent_progress_delta(
|
||||
*previous_sequence,
|
||||
*previous_completed_journals,
|
||||
*next_sequence,
|
||||
*next_completed_journals,
|
||||
) else {
|
||||
return Err(Error::other("durable ILM record generation is not a monotonic successor"));
|
||||
};
|
||||
let same_position_transition = sequence_delta == 0
|
||||
&& completed_journal_delta == 0
|
||||
&& matches!(
|
||||
(previous_active.as_ref(), next_active.as_ref(), previous_completed, next_completed),
|
||||
(None, Some(_), false, false) | (Some(_), None, false, false) | (None, None, false, true)
|
||||
);
|
||||
let progress_transition = sequence_delta > 0
|
||||
&& completed_journal_delta > 0
|
||||
&& !matches!(
|
||||
(previous_active.as_ref(), next_active.as_ref()),
|
||||
(Some(previous), Some(next)) if previous == next
|
||||
);
|
||||
previous_identity == next_identity
|
||||
&& !previous_completed
|
||||
&& next_revision > previous_revision
|
||||
&& (!next_completed || next_active.is_none())
|
||||
&& (same_position_transition || progress_transition)
|
||||
}
|
||||
(
|
||||
Self::TransitionTransaction {
|
||||
identity_sha256: previous_identity,
|
||||
@@ -364,6 +440,32 @@ impl DurableIlmRecordCheckpoint {
|
||||
.is_some_and(|expected_revision| *next_revision == expected_revision)
|
||||
&& (!previous_remote_version_known || previous_remote_version == next_remote_version)
|
||||
}
|
||||
(
|
||||
Self::TierProbeIntent {
|
||||
identity_sha256: previous_identity,
|
||||
remote_version_sha256: previous_remote_version,
|
||||
remote_version_known: previous_remote_version_known,
|
||||
owner_fence_sha256: previous_owner_fence,
|
||||
revision: previous_revision,
|
||||
state: previous_state,
|
||||
..
|
||||
},
|
||||
Self::TierProbeIntent {
|
||||
identity_sha256: next_identity,
|
||||
remote_version_sha256: next_remote_version,
|
||||
owner_fence_sha256: next_owner_fence,
|
||||
revision: next_revision,
|
||||
state: next_state,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
previous_identity == next_identity
|
||||
&& previous_owner_fence == next_owner_fence
|
||||
&& next_revision
|
||||
.checked_sub(*previous_revision)
|
||||
.is_some_and(|distance| distance == 1 && tier_probe_state_reaches(*previous_state, *next_state, distance))
|
||||
&& (!previous_remote_version_known || previous_remote_version == next_remote_version)
|
||||
}
|
||||
(
|
||||
Self::ManualTransitionJob {
|
||||
content_sha256: previous_content,
|
||||
@@ -443,6 +545,14 @@ impl DurableIlmRecordCheckpoint {
|
||||
/// after the exact terminal ETag and terminal receipt were committed, to
|
||||
/// purge older object versions exposed by that deletion.
|
||||
pub(crate) fn is_predecessor_of_terminal(&self, terminal: &Self) -> bool {
|
||||
if let Self::TierProbeIntent { state, .. } = terminal
|
||||
&& !matches!(
|
||||
state,
|
||||
tier_probe_intent::TierProbeIntentState::AbortedNoRemote | tier_probe_intent::TierProbeIntentState::Completed
|
||||
)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self == terminal || self.validate_successor(terminal).is_ok() {
|
||||
return true;
|
||||
}
|
||||
@@ -475,11 +585,89 @@ impl DurableIlmRecordCheckpoint {
|
||||
..
|
||||
},
|
||||
) => previous_identity == terminal_identity,
|
||||
(
|
||||
Self::TierDeleteDispatchParent {
|
||||
identity_sha256: previous_identity,
|
||||
revision: previous_revision,
|
||||
next_chunk_sequence: previous_sequence,
|
||||
completed_journal_count: previous_completed_journals,
|
||||
active_chunk_identity_sha256: previous_active,
|
||||
completed: false,
|
||||
..
|
||||
},
|
||||
Self::TierDeleteDispatchParent {
|
||||
identity_sha256: terminal_identity,
|
||||
revision: terminal_revision,
|
||||
next_chunk_sequence: terminal_sequence,
|
||||
completed_journal_count: terminal_completed_journals,
|
||||
active_chunk_identity_sha256: None,
|
||||
completed: true,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
previous_identity == terminal_identity
|
||||
&& terminal_revision > previous_revision
|
||||
&& tier_delete_dispatch_parent_progress_delta(
|
||||
*previous_sequence,
|
||||
*previous_completed_journals,
|
||||
*terminal_sequence,
|
||||
*terminal_completed_journals,
|
||||
)
|
||||
.is_some_and(|(sequence_delta, completed_journal_delta)| {
|
||||
if sequence_delta == 0 && completed_journal_delta == 0 {
|
||||
previous_active.is_none()
|
||||
} else {
|
||||
sequence_delta > 0 && completed_journal_delta > 0
|
||||
}
|
||||
})
|
||||
}
|
||||
(
|
||||
Self::TierProbeIntent {
|
||||
identity_sha256: previous_identity,
|
||||
remote_version_sha256: previous_remote_version,
|
||||
remote_version_known: previous_remote_version_known,
|
||||
owner_fence_sha256: previous_owner_fence,
|
||||
revision: previous_revision,
|
||||
state: previous_state,
|
||||
..
|
||||
},
|
||||
Self::TierProbeIntent {
|
||||
identity_sha256: terminal_identity,
|
||||
remote_version_sha256: terminal_remote_version,
|
||||
owner_fence_sha256: terminal_owner_fence,
|
||||
revision: terminal_revision,
|
||||
state: terminal_state,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
previous_identity == terminal_identity
|
||||
&& previous_owner_fence == terminal_owner_fence
|
||||
&& matches!(
|
||||
terminal_state,
|
||||
tier_probe_intent::TierProbeIntentState::AbortedNoRemote
|
||||
| tier_probe_intent::TierProbeIntentState::Completed
|
||||
)
|
||||
&& terminal_revision
|
||||
.checked_sub(*previous_revision)
|
||||
.is_some_and(|distance| tier_probe_state_reaches(*previous_state, *terminal_state, distance))
|
||||
&& (!previous_remote_version_known || previous_remote_version == terminal_remote_version)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_delete_dispatch_parent_progress_delta(
|
||||
previous_sequence: u64,
|
||||
previous_completed_journals: u64,
|
||||
next_sequence: u64,
|
||||
next_completed_journals: u64,
|
||||
) -> Option<(u64, u64)> {
|
||||
let sequence_delta = next_sequence.checked_sub(previous_sequence)?;
|
||||
let completed_journal_delta = next_completed_journals.checked_sub(previous_completed_journals)?;
|
||||
(sequence_delta <= completed_journal_delta).then_some((sequence_delta, completed_journal_delta))
|
||||
}
|
||||
|
||||
fn transition_state_distance(
|
||||
from: transition_transaction::TransitionTransactionState,
|
||||
to: transition_transaction::TransitionTransactionState,
|
||||
@@ -502,6 +690,23 @@ fn transition_state_distance(
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_probe_state_reaches(
|
||||
from: tier_probe_intent::TierProbeIntentState,
|
||||
to: tier_probe_intent::TierProbeIntentState,
|
||||
revision_distance: u64,
|
||||
) -> bool {
|
||||
use tier_probe_intent::TierProbeIntentState::{AbortedNoRemote, CleanupPending, Completed, UploadOutcomeUnknown, Uploaded};
|
||||
|
||||
match (from, to) {
|
||||
(UploadOutcomeUnknown, Uploaded | CleanupPending | AbortedNoRemote) => revision_distance == 1,
|
||||
(UploadOutcomeUnknown, Completed) => matches!(revision_distance, 2 | 3),
|
||||
(Uploaded, CleanupPending) => revision_distance == 1,
|
||||
(Uploaded, Completed) => revision_distance == 2,
|
||||
(CleanupPending, Completed) => revision_distance == 1,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn manual_job_state_reaches(
|
||||
from: manual_transition_job::ManualTransitionJobState,
|
||||
to: manual_transition_job::ManualTransitionJobState,
|
||||
@@ -913,17 +1118,42 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
|
||||
)
|
||||
}
|
||||
DurableIlmRecordKind::TierDeleteDispatchManifest => {
|
||||
let (operation_id, identity_sha256, state) =
|
||||
tier_delete_journal::validate_tier_delete_dispatch_manifest_record(path, data)?;
|
||||
(
|
||||
"operation_id",
|
||||
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
|
||||
DurableIlmRecordCheckpoint::TierDeleteDispatchManifest {
|
||||
content_sha256,
|
||||
match tier_delete_journal::validate_tier_delete_dispatch_manifest_record(path, data)? {
|
||||
tier_delete_journal::TierDeleteDispatchDurableRecord::Manifest {
|
||||
operation_id,
|
||||
identity_sha256,
|
||||
state,
|
||||
},
|
||||
)
|
||||
} => (
|
||||
"operation_id",
|
||||
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
|
||||
DurableIlmRecordCheckpoint::TierDeleteDispatchManifest {
|
||||
content_sha256,
|
||||
identity_sha256,
|
||||
state,
|
||||
},
|
||||
),
|
||||
tier_delete_journal::TierDeleteDispatchDurableRecord::Parent {
|
||||
operation_id,
|
||||
identity_sha256,
|
||||
revision,
|
||||
next_chunk_sequence,
|
||||
completed_journal_count,
|
||||
active_chunk_identity_sha256,
|
||||
completed,
|
||||
} => (
|
||||
"operation_id",
|
||||
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
|
||||
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
|
||||
content_sha256,
|
||||
identity_sha256,
|
||||
revision,
|
||||
next_chunk_sequence,
|
||||
completed_journal_count,
|
||||
active_chunk_identity_sha256,
|
||||
completed,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
DurableIlmRecordKind::TransitionTransaction => {
|
||||
let transaction = transition_transaction::decode_transition_transaction_record(path, data)
|
||||
@@ -953,6 +1183,42 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
|
||||
},
|
||||
)
|
||||
}
|
||||
DurableIlmRecordKind::TierProbeIntent => {
|
||||
let probe_id = tier_probe_intent::tier_probe_intent_id_from_record_object_name(path)
|
||||
.map_err(|err| Error::other(err.to_string()))?;
|
||||
let intent =
|
||||
tier_probe_intent::TierProbeIntent::decode(probe_id, data).map_err(|err| Error::other(err.to_string()))?;
|
||||
let canonical =
|
||||
tier_probe_intent::tier_probe_intent_record_object_name(probe_id).map_err(|err| Error::other(err.to_string()))?;
|
||||
if canonical != path {
|
||||
return Err(Error::other("tier probe intent path is not canonical"));
|
||||
}
|
||||
let identity_sha256 = checkpoint_hash(&(
|
||||
intent.probe_id,
|
||||
&intent.operation,
|
||||
&intent.tier_name,
|
||||
intent.destination_id,
|
||||
&intent.probe_object,
|
||||
&intent.creator_id,
|
||||
intent.creator_epoch,
|
||||
intent.created_at_unix_nanos,
|
||||
))?;
|
||||
let remote_version_sha256 = checkpoint_hash(&intent.remote_version)?;
|
||||
let owner_fence_sha256 = checkpoint_hash(&intent.owner)?;
|
||||
(
|
||||
"probe_id",
|
||||
probe_id.to_string(),
|
||||
DurableIlmRecordCheckpoint::TierProbeIntent {
|
||||
content_sha256,
|
||||
identity_sha256,
|
||||
remote_version_sha256,
|
||||
remote_version_known: !intent.remote_version.is_unknown(),
|
||||
owner_fence_sha256,
|
||||
revision: intent.revision,
|
||||
state: intent.state,
|
||||
},
|
||||
)
|
||||
}
|
||||
DurableIlmRecordKind::ManualTransitionJob => {
|
||||
let job_id = manual_transition_job::manual_transition_job_id_from_record_object_name(path)
|
||||
.map_err(|err| Error::other(err.to_string()))?;
|
||||
@@ -1108,6 +1374,102 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_probe_intent_fixture() -> tier_probe_intent::TierProbeIntent {
|
||||
let probe_id = Uuid::parse_str("36e2220e-9ad2-495b-b3bc-c4d2caf70a31").expect("fixture uuid should parse");
|
||||
tier_probe_intent::TierProbeIntent {
|
||||
probe_id,
|
||||
revision: 1,
|
||||
state: tier_probe_intent::TierProbeIntentState::UploadOutcomeUnknown,
|
||||
operation: tier_probe_intent::TierProbeOperationIdentity::Verify {
|
||||
config_etag: "config-etag".to_string(),
|
||||
backend_identity: [1; 32],
|
||||
},
|
||||
tier_name: "COLD-A".to_string(),
|
||||
destination_id: [1; 32],
|
||||
probe_object: tier_probe_intent::tier_probe_object_name(probe_id),
|
||||
creator_id: "node-a".to_string(),
|
||||
creator_epoch: Uuid::parse_str("76746062-c05a-40b7-9e38-d2722d7e0332").expect("fixture creator epoch should parse"),
|
||||
created_at_unix_nanos: 1_780_000_000_000_000_000,
|
||||
owner: tier_probe_intent::TierProbeOwnerFence {
|
||||
owner_id: "node-a".to_string(),
|
||||
owner_epoch: Uuid::parse_str("76746062-c05a-40b7-9e38-d2722d7e0332").expect("fixture owner epoch should parse"),
|
||||
not_after_unix_nanos: 1_780_000_900_000_000_000,
|
||||
},
|
||||
remote_version: tier_probe_intent::TierProbeRemoteVersion::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_probe_checkpoint(intent: &tier_probe_intent::TierProbeIntent) -> DurableIlmRecordCheckpoint {
|
||||
let path =
|
||||
tier_probe_intent::tier_probe_intent_record_object_name(intent.probe_id).expect("tier probe path should build");
|
||||
let encoded = intent.encode().expect("tier probe intent should encode");
|
||||
let namespace = classify_durable_ilm_record(&path)
|
||||
.expect("tier probe namespace should classify")
|
||||
.expect("tier probe intent should be durable");
|
||||
assert_eq!(namespace, &TIER_PROBE_INTENT_NAMESPACE);
|
||||
validate_durable_ilm_record(&path, &encoded)
|
||||
.expect("tier probe intent should validate")
|
||||
.checkpoint
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_probe_intent_checkpoint_tracks_exact_monotonic_generations() {
|
||||
let initial_intent = tier_probe_intent_fixture();
|
||||
let initial = tier_probe_checkpoint(&initial_intent);
|
||||
|
||||
let mut uploaded_intent = initial_intent;
|
||||
uploaded_intent
|
||||
.advance(
|
||||
tier_probe_intent::TierProbeIntentState::Uploaded,
|
||||
tier_probe_intent::TierProbeRemoteVersion::versioned("opaque-v1"),
|
||||
)
|
||||
.expect("uploaded state should advance");
|
||||
let uploaded = tier_probe_checkpoint(&uploaded_intent);
|
||||
initial
|
||||
.validate_successor(&uploaded)
|
||||
.expect("durable receipt may adopt the exact uploaded generation");
|
||||
|
||||
let mut cleanup_intent = uploaded_intent.clone();
|
||||
cleanup_intent
|
||||
.advance(
|
||||
tier_probe_intent::TierProbeIntentState::CleanupPending,
|
||||
uploaded_intent.remote_version.clone(),
|
||||
)
|
||||
.expect("cleanup state should advance");
|
||||
let cleanup = tier_probe_checkpoint(&cleanup_intent);
|
||||
uploaded
|
||||
.validate_successor(&cleanup)
|
||||
.expect("durable receipt may adopt the exact cleanup generation");
|
||||
|
||||
let mut completed_intent = cleanup_intent.clone();
|
||||
completed_intent
|
||||
.advance(tier_probe_intent::TierProbeIntentState::Completed, cleanup_intent.remote_version.clone())
|
||||
.expect("completed state should advance");
|
||||
let completed = tier_probe_checkpoint(&completed_intent);
|
||||
cleanup
|
||||
.validate_successor(&completed)
|
||||
.expect("durable receipt may adopt the exact terminal generation");
|
||||
assert!(
|
||||
initial.is_predecessor_of_terminal(&completed),
|
||||
"terminal cleanup must recognize the full acknowledged-PUT path"
|
||||
);
|
||||
assert!(
|
||||
initial.validate_successor(&completed).is_err(),
|
||||
"ordinary receipt advancement must not skip intermediate generations"
|
||||
);
|
||||
assert!(
|
||||
!initial.is_predecessor_of_terminal(&uploaded),
|
||||
"a nonterminal generation must not be accepted as terminal proof"
|
||||
);
|
||||
|
||||
let mut rebound = uploaded_intent;
|
||||
rebound.owner.owner_epoch = Uuid::new_v4();
|
||||
assert!(
|
||||
rebound.encode().is_err(),
|
||||
"dormant v1 must reject owner takeover before producing a checkpoint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_dispatch_manifest_namespace_validates_monotonic_branches() {
|
||||
use tier_delete_journal::TierDeleteDispatchManifestState::{Aborted, Aborting, Completed, DispatchAuthorized, Preparing};
|
||||
@@ -1143,6 +1505,85 @@ mod tests {
|
||||
assert!(aborted.validate_successor(&preparing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_dispatch_parent_checkpoint_is_monotonic_across_chunks() {
|
||||
let identity = "a".repeat(64);
|
||||
let checkpoint = |revision, sequence, completed_journals, active: Option<&str>, completed| {
|
||||
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
|
||||
content_sha256: format!("{revision:064x}"),
|
||||
identity_sha256: identity.clone(),
|
||||
revision,
|
||||
next_chunk_sequence: sequence,
|
||||
completed_journal_count: completed_journals,
|
||||
active_chunk_identity_sha256: active.map(ToOwned::to_owned),
|
||||
completed,
|
||||
}
|
||||
};
|
||||
let idle = checkpoint(0, 0, 0, None, false);
|
||||
let first_child = "b".repeat(64);
|
||||
let second_child = "c".repeat(64);
|
||||
let bound = checkpoint(1, 0, 0, Some(&first_child), false);
|
||||
let advanced = checkpoint(2, 1, 2, None, false);
|
||||
let next_bound = checkpoint(3, 1, 2, Some(&second_child), false);
|
||||
let completed = checkpoint(4, 2, 3, None, true);
|
||||
let terminal_after_more_chunks = checkpoint(6, 4, 7, None, true);
|
||||
|
||||
idle.validate_successor(&bound).expect("an idle parent may bind one child");
|
||||
bound
|
||||
.validate_successor(&advanced)
|
||||
.expect("a completed child may advance the parent sequence");
|
||||
advanced
|
||||
.validate_successor(&next_bound)
|
||||
.expect("the next sequence may bind a new immutable child");
|
||||
next_bound
|
||||
.validate_successor(&completed)
|
||||
.expect("receipt progress may skip directly to a later terminal checkpoint");
|
||||
assert!(
|
||||
bound.is_predecessor_of_terminal(&terminal_after_more_chunks),
|
||||
"terminal cleanup may still recognize a valid multi-chunk predecessor"
|
||||
);
|
||||
assert!(
|
||||
advanced.is_predecessor_of_terminal(&terminal_after_more_chunks),
|
||||
"terminal cleanup may still skip over later valid parent generations"
|
||||
);
|
||||
assert!(
|
||||
idle.validate_successor(&checkpoint(1, 0, 1, Some(&first_child), false))
|
||||
.is_err()
|
||||
);
|
||||
assert!(bound.validate_successor(&checkpoint(2, 1, 0, None, false)).is_err());
|
||||
assert!(
|
||||
bound.validate_successor(&checkpoint(2, 2, 1, None, false)).is_err(),
|
||||
"sequence cannot advance beyond completed journal evidence"
|
||||
);
|
||||
assert!(
|
||||
advanced.validate_successor(&checkpoint(3, 1, 3, None, false)).is_err(),
|
||||
"completed journal count cannot grow without a completed child sequence"
|
||||
);
|
||||
assert!(
|
||||
bound.validate_successor(&checkpoint(2, 0, 0, None, true)).is_err(),
|
||||
"an active child cannot be marked completed without completion evidence"
|
||||
);
|
||||
assert!(
|
||||
bound
|
||||
.validate_successor(&checkpoint(2, 0, 0, Some(&second_child), false))
|
||||
.is_err(),
|
||||
"an active child cannot be replaced at the same parent sequence"
|
||||
);
|
||||
assert!(
|
||||
bound
|
||||
.validate_successor(&checkpoint(2, 1, 1, Some(&first_child), false))
|
||||
.is_err(),
|
||||
"sequence growth cannot retain the same active child identity"
|
||||
);
|
||||
assert!(
|
||||
!bound.is_predecessor_of_terminal(&checkpoint(2, 0, 0, None, true)),
|
||||
"terminal cleanup must not treat an active child as completed without count evidence"
|
||||
);
|
||||
assert!(completed.validate_successor(&checkpoint(5, 3, 4, None, true)).is_err());
|
||||
assert!(completed.validate_successor(&advanced).is_err());
|
||||
assert!(advanced.validate_successor(&idle).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_checkpoint_binds_dispatch_and_full_state_monotonically() {
|
||||
use crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::{Committed, Dispatched, Prepared};
|
||||
|
||||
@@ -1170,6 +1170,7 @@ pub async fn save_manual_transition_job_record_if_current(
|
||||
data.clone(),
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(current_etag.to_string()),
|
||||
..Default::default()
|
||||
@@ -1242,6 +1243,7 @@ pub(crate) async fn save_manual_transition_worker_result_if_absent(
|
||||
data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
@@ -1270,6 +1272,7 @@ pub(crate) async fn save_manual_transition_task_if_absent(
|
||||
data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
@@ -1621,6 +1624,7 @@ pub async fn save_manual_transition_scope_admission_if_absent(
|
||||
data.clone(),
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
@@ -1672,6 +1676,7 @@ pub async fn save_manual_transition_scope_admission_if_current(
|
||||
data.clone(),
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(current_etag.to_string()),
|
||||
..Default::default()
|
||||
|
||||
@@ -20,6 +20,7 @@ const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
|
||||
const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped";
|
||||
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
|
||||
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_audit::emit_noncurrent_expiration_event;
|
||||
use crate::bucket::lifecycle::lifecycle;
|
||||
use crate::bucket::lifecycle::replication_sink::{self, ReplicationObjectBridge};
|
||||
use crate::object_api::ObjectOptions;
|
||||
@@ -34,7 +35,23 @@ pub async fn delete_object_versions(
|
||||
to_del: &[ObjectToDelete],
|
||||
_lc_event: lifecycle::Event,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) {
|
||||
) -> usize {
|
||||
if to_del.iter().any(|target| {
|
||||
target.version_id.is_none()
|
||||
|| (target.version_id.is_some_and(|version_id| version_id.is_nil()) && target.expected_identity.is_none())
|
||||
}) {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_CLEANUP_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket,
|
||||
target_count = to_del.len(),
|
||||
reason = "incomplete_version_identity",
|
||||
"Skipped lifecycle noncurrent version cleanup"
|
||||
);
|
||||
return to_del.len();
|
||||
}
|
||||
|
||||
let delete_config_snapshot = match ReplicationObjectBridge::delete_request_config(api, bucket).await {
|
||||
Ok(snapshot) => Arc::new(snapshot),
|
||||
Err(err) => {
|
||||
@@ -47,10 +64,11 @@ pub async fn delete_object_versions(
|
||||
reason = "delete_config_snapshot_unavailable",
|
||||
"Skipped lifecycle noncurrent version cleanup"
|
||||
);
|
||||
return;
|
||||
return to_del.len();
|
||||
}
|
||||
};
|
||||
let mut remaining = to_del;
|
||||
let mut failed = 0;
|
||||
loop {
|
||||
let mut to_del = remaining;
|
||||
if to_del.len() > MAX_DELETE_LIST {
|
||||
@@ -71,6 +89,7 @@ pub async fn delete_object_versions(
|
||||
},
|
||||
)
|
||||
.await;
|
||||
failed += errors.iter().filter(|err| err.is_some()).count();
|
||||
|
||||
for (i, deleted_obj) in deleted_objs.iter_mut().enumerate() {
|
||||
if errors.get(i).and_then(|err| err.as_ref()).is_some() {
|
||||
@@ -80,6 +99,12 @@ pub async fn delete_object_versions(
|
||||
// version so it does not sit resident until TTL (ODC-26).
|
||||
if let Some(target) = to_del.get(i) {
|
||||
crate::object_api::notify_object_mutation(bucket, &target.object_name).await;
|
||||
// Announce the version this batch actually removed. Cache
|
||||
// eviction and replication scheduling keep their existing
|
||||
// order and admission; the event is derived from the committed
|
||||
// result, and a send failure never rolls back a delete that
|
||||
// already happened (backlog#2202).
|
||||
emit_noncurrent_expiration_event(bucket, target, deleted_obj, false);
|
||||
}
|
||||
if deleted_obj.replication_state.is_none() {
|
||||
continue;
|
||||
@@ -111,4 +136,5 @@ pub async fn delete_object_versions(
|
||||
break;
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||
use rustfs_scanner_metrics::metrics::IlmAction;
|
||||
|
||||
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
|
||||
use crate::bucket::replication::ReplicationLifecycleBridge;
|
||||
@@ -77,7 +77,7 @@ mod tests {
|
||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||
use crate::object_api::{ObjectInfo, ObjectOptions};
|
||||
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||
use rustfs_scanner_metrics::metrics::IlmAction;
|
||||
use s3s::dto::{
|
||||
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
|
||||
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,10 @@ use crate::store::ECStore;
|
||||
use rustfs_filemeta::FileInfo;
|
||||
|
||||
pub const DEFAULT_FREE_VERSION_RECOVERY_LIMIT: usize = 1_000;
|
||||
// These are page-wide repair budgets. Applying them per bucket would still let
|
||||
// one recovery pass walk an unbounded namespace before the scheduler can cool down.
|
||||
const DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT: usize = 10_000;
|
||||
const DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT: usize = 100;
|
||||
#[cfg(not(test))]
|
||||
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[cfg(test)]
|
||||
@@ -41,6 +44,12 @@ const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, crate::error::Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct RecoveryWorkBudget {
|
||||
pub(super) max_objects: usize,
|
||||
pub(super) max_buckets: usize,
|
||||
}
|
||||
|
||||
fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
|
||||
WalkOptions {
|
||||
include_free_versions: true,
|
||||
@@ -58,6 +67,7 @@ fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) enum RecoveryWalkTestAction {
|
||||
SendItems(Vec<ObjectInfo>),
|
||||
SendItemsThenError(Vec<ObjectInfo>, crate::error::Error),
|
||||
SendItemsThenHang(Vec<ObjectInfo>, Arc<tokio::sync::Notify>),
|
||||
SendItemsUntilReceiverCloses(Arc<tokio::sync::Notify>),
|
||||
@@ -277,6 +287,17 @@ pub(super) async fn list_tier_free_versions(
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<FreeVersionRecoveryPage> {
|
||||
list_tier_free_versions_with_budget(api, limit, bucket_marker, object_marker, cancel_token, recovery_work_budget(limit)).await
|
||||
}
|
||||
|
||||
pub(super) async fn list_tier_free_versions_with_budget(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
work_budget: RecoveryWorkBudget,
|
||||
) -> Result<FreeVersionRecoveryPage> {
|
||||
let mut page = FreeVersionRecoveryPage {
|
||||
items: Vec::new(),
|
||||
@@ -290,6 +311,9 @@ pub(super) async fn list_tier_free_versions(
|
||||
if limit == 0 {
|
||||
return Ok(page);
|
||||
}
|
||||
if work_budget.max_objects == 0 || work_budget.max_buckets == 0 {
|
||||
return Err(std::io::Error::other("free-version recovery work budget must be greater than zero").into());
|
||||
}
|
||||
|
||||
let bucket_options = BucketOptions::default();
|
||||
let list_buckets = async {
|
||||
@@ -313,7 +337,7 @@ pub(super) async fn list_tier_free_versions(
|
||||
};
|
||||
let mut bucket_seen = bucket_marker.is_none();
|
||||
let mut truncated_after: Option<RecoveryCursor> = None;
|
||||
let walk_scan_limit = recovery_walk_scan_limit(limit);
|
||||
let mut remaining_scan_objects = work_budget.max_objects;
|
||||
|
||||
for bucket in buckets {
|
||||
if cancel_token.is_cancelled() {
|
||||
@@ -329,12 +353,20 @@ pub(super) async fn list_tier_free_versions(
|
||||
bucket_seen = true;
|
||||
}
|
||||
|
||||
if page.buckets_scanned >= work_budget.max_buckets {
|
||||
page.truncated = true;
|
||||
page.next_bucket_marker = Some(bucket.name);
|
||||
page.next_object_marker = None;
|
||||
break;
|
||||
}
|
||||
|
||||
page.buckets_scanned += 1;
|
||||
let bucket_object_marker = if bucket_marker.as_deref() == Some(bucket.name.as_str()) {
|
||||
object_marker.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let bucket_walk_limit = remaining_scan_objects;
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<ObjectInfoOrErr>(100);
|
||||
let cancel = cancel_token.child_token();
|
||||
@@ -358,6 +390,21 @@ pub(super) async fn list_tier_free_versions(
|
||||
#[cfg(test)]
|
||||
if let Some(action) = test_action {
|
||||
match action {
|
||||
RecoveryWalkTestAction::SendItems(items) => {
|
||||
for item in items {
|
||||
if tx
|
||||
.send(ObjectInfoOrErr {
|
||||
item: Some(item),
|
||||
err: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
RecoveryWalkTestAction::SendItemsThenError(items, err) => {
|
||||
for item in items {
|
||||
if tx
|
||||
@@ -425,7 +472,7 @@ pub(super) async fn list_tier_free_versions(
|
||||
}
|
||||
}
|
||||
|
||||
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(walk_scan_limit, object_marker))
|
||||
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(bucket_walk_limit, object_marker))
|
||||
.await
|
||||
}
|
||||
});
|
||||
@@ -466,6 +513,18 @@ pub(super) async fn list_tier_free_versions(
|
||||
let Some(oi) = item.item else {
|
||||
continue;
|
||||
};
|
||||
if last_seen_object.as_deref() != Some(oi.name.as_str()) && scanned_objects >= bucket_walk_limit {
|
||||
// The disk listing limit follows S3-visible counting rules and
|
||||
// does not charge metadata containing only hidden/free versions.
|
||||
// Enforce the repair budget again at the decoded-object boundary.
|
||||
page.truncated = true;
|
||||
page.next_bucket_marker = Some(bucket.name.clone());
|
||||
page.next_object_marker = last_seen_object.clone();
|
||||
cancel.cancel();
|
||||
draining_after_truncation = true;
|
||||
drain_deadline = Some(tokio::time::Instant::now() + BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
|
||||
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())
|
||||
@@ -509,7 +568,8 @@ pub(super) async fn list_tier_free_versions(
|
||||
return Err(err);
|
||||
}
|
||||
walk_result?;
|
||||
mark_scan_truncated_if_needed(&mut page, scanned_objects, walk_scan_limit, &bucket.name, last_seen_object.as_deref());
|
||||
remaining_scan_objects = remaining_scan_objects.saturating_sub(scanned_objects);
|
||||
mark_scan_truncated_if_needed(&mut page, scanned_objects, bucket_walk_limit, &bucket.name, last_seen_object.as_deref());
|
||||
|
||||
if page.truncated {
|
||||
break;
|
||||
@@ -528,6 +588,13 @@ fn recovery_walk_scan_limit(limit: usize) -> usize {
|
||||
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT.max(limit.saturating_add(1))
|
||||
}
|
||||
|
||||
fn recovery_work_budget(limit: usize) -> RecoveryWorkBudget {
|
||||
RecoveryWorkBudget {
|
||||
max_objects: recovery_walk_scan_limit(limit),
|
||||
max_buckets: DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_scanned_object(last_seen_object: &mut Option<String>, scanned_objects: &mut usize, object: &str) {
|
||||
if last_seen_object.as_deref() == Some(object) {
|
||||
return;
|
||||
@@ -700,6 +767,13 @@ mod tests {
|
||||
recovery_walk_scan_limit(DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT),
|
||||
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT + 1
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_work_budget(DEFAULT_FREE_VERSION_RECOVERY_LIMIT),
|
||||
RecoveryWorkBudget {
|
||||
max_objects: DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT,
|
||||
max_buckets: DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use rustfs_data_usage::TierStats;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Sub;
|
||||
@@ -27,7 +28,24 @@ use tracing::{error, warn};
|
||||
|
||||
pub type DailyAllTierStats = HashMap<String, LastDayTierStats>;
|
||||
|
||||
#[derive(Clone)]
|
||||
/// One bin per hour of the rolling day. The bin index is the UTC hour, so the
|
||||
/// array is a ring the writer ages forward rather than a queue.
|
||||
pub const TIER_DAILY_STATS_BINS: usize = 24;
|
||||
|
||||
/// Interchange form of [`LastDayTierStats`] for the internode tier-stats RPC.
|
||||
///
|
||||
/// The in-memory type keeps its bins private because the ring is only
|
||||
/// meaningful together with `updated_at`; this type carries both across the
|
||||
/// wire and is validated back into the ring by [`LastDayTierStats::from_wire`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TierDailyStatsWire {
|
||||
pub bins: Vec<TierStats>,
|
||||
/// Seconds since the Unix epoch. Bins are hour-resolution, so a coarser
|
||||
/// timestamp than the in-memory `OffsetDateTime` loses nothing.
|
||||
pub updated_at_unix_secs: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LastDayTierStats {
|
||||
bins: [TierStats; 24],
|
||||
updated_at: OffsetDateTime,
|
||||
@@ -80,11 +98,57 @@ impl LastDayTierStats {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "asserted by this file's tests; the lib target cannot see test-only consumers (backlog#1823)"
|
||||
)]
|
||||
fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
|
||||
/// The rolling ring as observed, without aging it forward.
|
||||
///
|
||||
/// Only meaningful together with [`LastDayTierStats::updated_at`]: a bin
|
||||
/// belongs to the hour of its index within the day that ends at
|
||||
/// `updated_at`.
|
||||
pub fn bins(&self) -> &[TierStats; TIER_DAILY_STATS_BINS] {
|
||||
&self.bins
|
||||
}
|
||||
|
||||
pub fn updated_at(&self) -> OffsetDateTime {
|
||||
self.updated_at
|
||||
}
|
||||
|
||||
pub fn to_wire(&self) -> TierDailyStatsWire {
|
||||
TierDailyStatsWire {
|
||||
bins: self.bins.to_vec(),
|
||||
updated_at_unix_secs: self.updated_at.unix_timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the ring from a peer's response.
|
||||
///
|
||||
/// A ring of the wrong width or an unrepresentable timestamp is corrupt
|
||||
/// peer input, not a zero sample: it returns an error so the caller can
|
||||
/// report the node as non-reporting instead of merging a plausible but
|
||||
/// wrong day into a cluster total.
|
||||
pub fn from_wire(wire: TierDailyStatsWire) -> Result<Self, std::io::Error> {
|
||||
let bins: [TierStats; TIER_DAILY_STATS_BINS] = wire.bins.try_into().map_err(|bins: Vec<TierStats>| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("tier daily stats must carry {TIER_DAILY_STATS_BINS} bins, got {}", bins.len()),
|
||||
)
|
||||
})?;
|
||||
let updated_at = OffsetDateTime::from_unix_timestamp(wire.updated_at_unix_secs).map_err(|err| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("tier daily stats carry an unrepresentable timestamp: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self { bins, updated_at })
|
||||
}
|
||||
|
||||
/// Combine two independently observed rings.
|
||||
///
|
||||
/// Each node counts only the transitions it completed itself, so summing
|
||||
/// bins across nodes is a cluster total rather than a double count. The
|
||||
/// older ring is aged forward to the newer one's clock first, so a node
|
||||
/// that stopped transitioning hours ago contributes its still-current
|
||||
/// bins and not its expired ones.
|
||||
pub fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
|
||||
let mut cl = self.clone();
|
||||
let mut cm = m;
|
||||
let mut merged = LastDayTierStats::default();
|
||||
@@ -108,6 +172,7 @@ impl LastDayTierStats {
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use time::Duration;
|
||||
|
||||
#[test]
|
||||
fn total_sums_all_recorded_stats() {
|
||||
@@ -132,4 +197,83 @@ mod test {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn sample(total_size: u64) -> TierStats {
|
||||
TierStats {
|
||||
total_size,
|
||||
num_versions: 1,
|
||||
num_objects: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_round_trip_preserves_the_ring_and_its_clock() {
|
||||
let mut stats = LastDayTierStats::default();
|
||||
stats.add_stats(sample(10));
|
||||
|
||||
let restored = LastDayTierStats::from_wire(stats.to_wire()).expect("a ring this node produced must decode");
|
||||
|
||||
assert_eq!(restored.bins(), stats.bins(), "every bin must survive the wire");
|
||||
assert_eq!(
|
||||
restored.updated_at().unix_timestamp(),
|
||||
stats.updated_at().unix_timestamp(),
|
||||
"the ring's clock must survive the wire"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ring_of_the_wrong_width_is_rejected() {
|
||||
let mut wire = LastDayTierStats::default().to_wire();
|
||||
wire.bins.pop();
|
||||
|
||||
let err = LastDayTierStats::from_wire(wire).expect_err("a short ring must not decode as a zero day");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrepresentable_clock_is_rejected() {
|
||||
let mut wire = LastDayTierStats::default().to_wire();
|
||||
wire.updated_at_unix_secs = i64::MIN;
|
||||
|
||||
let err = LastDayTierStats::from_wire(wire).expect_err("an unrepresentable clock must not decode");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_sums_two_nodes_that_transitioned_in_the_same_hour() {
|
||||
let mut left = LastDayTierStats::default();
|
||||
left.add_stats(sample(10));
|
||||
let mut right = LastDayTierStats::default();
|
||||
right.add_stats(sample(20));
|
||||
|
||||
assert_eq!(
|
||||
left.merge(right).total(),
|
||||
TierStats {
|
||||
total_size: 30,
|
||||
num_versions: 2,
|
||||
num_objects: 2,
|
||||
},
|
||||
"each node counts only its own completions, so a merge is a cluster total"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_ages_out_a_peer_ring_older_than_a_day() {
|
||||
let mut stale = LastDayTierStats::default();
|
||||
stale.add_stats(sample(10));
|
||||
stale.updated_at -= Duration::days(2);
|
||||
|
||||
let mut fresh = LastDayTierStats::default();
|
||||
fresh.add_stats(sample(20));
|
||||
|
||||
assert_eq!(
|
||||
fresh.merge(stale).total(),
|
||||
TierStats {
|
||||
total_size: 20,
|
||||
num_versions: 1,
|
||||
num_objects: 1,
|
||||
},
|
||||
"a node that stopped transitioning more than a day ago must not keep contributing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
#![allow(unused_variables)]
|
||||
#![allow(unused_mut)]
|
||||
#![allow(unused_assignments)]
|
||||
#![allow(unused_must_use)]
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use super::runtime_boundary as runtime_sources;
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
|
||||
@@ -72,9 +70,11 @@ static REMOTE_DELETE_BREAKER: LazyLock<Mutex<RemoteDeleteBreaker>> = LazyLock::n
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
static REMOTE_TIER_DELETE_TEST_HOOK: std::sync::LazyLock<
|
||||
std::sync::Mutex<Option<Box<dyn Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync>>>,
|
||||
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
|
||||
type RemoteTierDeleteTestHook = Box<dyn Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync>;
|
||||
|
||||
#[cfg(test)]
|
||||
static REMOTE_TIER_DELETE_TEST_HOOK: std::sync::LazyLock<std::sync::Mutex<Option<RemoteTierDeleteTestHook>>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RemoteDeleteBreaker {
|
||||
@@ -107,7 +107,7 @@ impl RemoteDeleteBreaker {
|
||||
fn prune(&mut self, now: Instant) {
|
||||
while let Some(ts) = self.failures.front().copied() {
|
||||
if now.duration_since(ts) > self.window {
|
||||
self.failures.pop_front();
|
||||
let _ = self.failures.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
@@ -137,10 +137,10 @@ fn is_signer_header_error(err: &std::io::Error) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(source) = err.get_ref() {
|
||||
if error_chain_contains_signer_header_marker(source) {
|
||||
return true;
|
||||
}
|
||||
if let Some(source) = err.get_ref()
|
||||
&& error_chain_contains_signer_header_marker(source)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let message = err.to_string().to_ascii_lowercase();
|
||||
@@ -205,7 +205,7 @@ impl ObjSweeper {
|
||||
|
||||
#[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")]
|
||||
pub fn with_version(&mut self, vid: Option<Uuid>) -> &Self {
|
||||
self.version_id = vid.clone();
|
||||
self.version_id = vid;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ impl ObjSweeper {
|
||||
#[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")]
|
||||
pub fn get_opts(&self) -> lifecycle::ObjectOpts {
|
||||
let mut opts = ObjectOpts {
|
||||
version_id: self.version_id.clone(),
|
||||
version_id: self.version_id,
|
||||
versioned: self.versioned,
|
||||
version_suspended: self.suspended,
|
||||
..Default::default()
|
||||
@@ -388,8 +388,8 @@ impl Jentry {
|
||||
impl ExpiryOp for Jentry {
|
||||
fn op_hash(&self) -> u64 {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("{}", self.tier_name).as_bytes());
|
||||
hasher.update(format!("{}", self.obj_name).as_bytes());
|
||||
hasher.update(self.tier_name.as_bytes());
|
||||
hasher.update(self.obj_name.as_bytes());
|
||||
xxh64::xxh64(hasher.finalize().as_slice(), XXHASH_SEED)
|
||||
}
|
||||
|
||||
@@ -436,7 +436,7 @@ async fn delete_object_from_remote_tier_raw_with_manager(
|
||||
tier_name: &str,
|
||||
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let lease = TierConfigMgr::acquire_operation_lease(&tier_config_mgr, tier_name)
|
||||
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, true).await
|
||||
@@ -575,6 +575,7 @@ pub(crate) async fn delete_confirmed_transition_candidate_exact_with_lease_idemp
|
||||
#[cfg(test)]
|
||||
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
|
||||
@@ -25,14 +25,17 @@ use crate::bucket::lifecycle::durable_namespace::TRANSITION_TRANSACTION_NAMESPAC
|
||||
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
||||
use crate::bucket::lifecycle::tier_sweeper::{
|
||||
delete_confirmed_transition_candidate_exact_with_lease_idempotent,
|
||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||
};
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result as EcstoreResult};
|
||||
use crate::object_api::ObjectOptions;
|
||||
use crate::services::tier::{tier::TierConfigMgr, warm_backend::TransitionCandidateProbe};
|
||||
use crate::storage_api_contracts::{list::ListOperations as _, object::ObjectOperations as _};
|
||||
use crate::storage_api_contracts::{
|
||||
list::ListOperations as _,
|
||||
namespace::NamespaceLocking as _,
|
||||
object::{HTTPPreconditions, ObjectOperations as _},
|
||||
};
|
||||
use crate::store::ECStore;
|
||||
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
@@ -110,7 +113,7 @@ pub struct TransitionRemoteVersion {
|
||||
impl TransitionRemoteVersion {
|
||||
pub fn known_from_put_response(version_id: impl Into<String>) -> Self {
|
||||
let version_id = version_id.into();
|
||||
if version_id.is_empty() || Uuid::parse_str(&version_id).is_ok_and(|parsed| parsed.is_nil()) {
|
||||
if version_id.is_empty() {
|
||||
Self::unversioned()
|
||||
} else {
|
||||
Self::versioned(version_id)
|
||||
@@ -307,10 +310,16 @@ impl TransitionTransaction {
|
||||
if self.write_id.is_nil() {
|
||||
return Err(TransitionTransactionError::Corrupt("write_id is nil"));
|
||||
}
|
||||
if self.not_after_unix_nanos <= 0 {
|
||||
return Err(TransitionTransactionError::Corrupt("ownership deadline is not positive"));
|
||||
}
|
||||
self.source.validate()?;
|
||||
if self.tier_name.is_empty() {
|
||||
return Err(TransitionTransactionError::Corrupt("tier name is empty"));
|
||||
}
|
||||
if self.backend_fingerprint == [0; 32] {
|
||||
return Err(TransitionTransactionError::Corrupt("backend fingerprint is empty"));
|
||||
}
|
||||
if self.remote_object
|
||||
!= canonical_transition_remote_object(self.deployment_id, &self.source.bucket, self.transaction_id, self.write_id)?
|
||||
{
|
||||
@@ -476,6 +485,18 @@ impl TransitionTransaction {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_same_immutable_identity(&self, other: &Self) -> bool {
|
||||
self.deployment_id == other.deployment_id
|
||||
&& self.transaction_id == other.transaction_id
|
||||
&& self.owner_epoch == other.owner_epoch
|
||||
&& self.write_id == other.write_id
|
||||
&& self.source == other.source
|
||||
&& self.tier_name == other.tier_name
|
||||
&& self.backend_fingerprint == other.backend_fingerprint
|
||||
&& self.remote_object == other.remote_object
|
||||
&& self.not_after_unix_nanos == other.not_after_unix_nanos
|
||||
}
|
||||
|
||||
fn validate_cleanup_proof(&self, proof: &TransitionCleanupProof) -> Result<()> {
|
||||
if proof.transaction_id != self.transaction_id
|
||||
|| proof.write_id != self.write_id
|
||||
@@ -585,20 +606,93 @@ pub(crate) async fn save_transition_transaction_record(
|
||||
let object =
|
||||
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
|
||||
let data = transaction.encode().map_err(transition_transaction_store_error)?;
|
||||
config_boundary::save_config(api.clone(), &object, data.clone()).await?;
|
||||
config_boundary::save_config_with_opts(
|
||||
api.clone(),
|
||||
&object,
|
||||
data.clone(),
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// Box::pin: the durable-receipt state machine is large and sits on the
|
||||
// already-deep transition worker poll chain; keeping it inline overflows
|
||||
// the default 2 MiB tokio worker stack in debug builds.
|
||||
Box::pin(api.record_durable_ilm_decommission_progress(&object, &data)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_transition_transaction_record_if_current(
|
||||
api: Arc<ECStore>,
|
||||
expected: &TransitionTransaction,
|
||||
next: &TransitionTransaction,
|
||||
) -> EcstoreResult<()> {
|
||||
let object = transition_transaction_record_object_name(next.transaction_id).map_err(transition_transaction_store_error)?;
|
||||
let revision_is_next = expected.revision.checked_add(1) == Some(next.revision);
|
||||
let state_is_next = state_change_allowed(expected.state, next.state)
|
||||
|| matches!(
|
||||
(expected.state, next.state),
|
||||
(
|
||||
TransitionTransactionState::Uploaded
|
||||
| TransitionTransactionState::UploadOutcomeUnknown
|
||||
| TransitionTransactionState::LocalCommitStarted,
|
||||
TransitionTransactionState::CleanupPending
|
||||
)
|
||||
);
|
||||
let remote_version_is_monotonic = expected.remote_version.is_unknown() || expected.remote_version == next.remote_version;
|
||||
if !expected.has_same_immutable_identity(next) || !revision_is_next || !state_is_next || !remote_version_is_monotonic {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let (current, etag) = load_transition_transaction_record_with_etag(api.clone(), expected.transaction_id).await?;
|
||||
if ¤t != expected {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let data = next.encode().map_err(transition_transaction_store_error)?;
|
||||
config_boundary::save_config_with_opts(
|
||||
api.clone(),
|
||||
&object,
|
||||
data.clone(),
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(etag),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// Box::pin: see save_transition_transaction_record.
|
||||
Box::pin(api.record_durable_ilm_decommission_progress(&object, &data)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn load_transition_transaction_record(
|
||||
api: Arc<ECStore>,
|
||||
transaction_id: Uuid,
|
||||
) -> EcstoreResult<TransitionTransaction> {
|
||||
load_transition_transaction_record_with_etag(api, transaction_id)
|
||||
.await
|
||||
.map(|(transaction, _)| transaction)
|
||||
}
|
||||
|
||||
async fn load_transition_transaction_record_with_etag(
|
||||
api: Arc<ECStore>,
|
||||
transaction_id: Uuid,
|
||||
) -> EcstoreResult<(TransitionTransaction, String)> {
|
||||
let object = transition_transaction_record_object_name(transaction_id).map_err(transition_transaction_store_error)?;
|
||||
let data = config_boundary::read_config(api, &object).await?;
|
||||
TransitionTransaction::decode(transaction_id, &data).map_err(transition_transaction_store_error)
|
||||
let (data, object_info) = config_boundary::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("transition transaction record is missing an ETag"))?;
|
||||
let transaction = TransitionTransaction::decode(transaction_id, &data).map_err(transition_transaction_store_error)?;
|
||||
Ok((transaction, etag))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_transition_transaction_record(
|
||||
@@ -607,10 +701,18 @@ pub(crate) async fn delete_transition_transaction_record(
|
||||
) -> EcstoreResult<()> {
|
||||
let object =
|
||||
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
|
||||
let data = transaction.encode().map_err(transition_transaction_store_error)?;
|
||||
let (current, etag) = match load_transition_transaction_record_with_etag(api.clone(), transaction.transaction_id).await {
|
||||
Ok(record) => record,
|
||||
Err(Error::ConfigNotFound) => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if ¤t != transaction {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let data = current.encode().map_err(transition_transaction_store_error)?;
|
||||
// Box::pin: see save_transition_transaction_record.
|
||||
Box::pin(api.record_durable_ilm_decommission_terminal(&object, &data)).await?;
|
||||
match config_boundary::delete_config(api, &object).await {
|
||||
match config_boundary::delete_config_if_match(api, &object, &etag).await {
|
||||
Ok(()) | Err(Error::ConfigNotFound) => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
@@ -637,6 +739,84 @@ pub enum TransitionTransactionRecoveryOutcome {
|
||||
Retained,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct TransitionRecoveryClaimBarrierState {
|
||||
transaction_id: Uuid,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct TransitionRecoveryClaimBarrier {
|
||||
state: Arc<TransitionRecoveryClaimBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static TRANSITION_RECOVERY_CLAIM_BARRIER: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<TransitionRecoveryClaimBarrierState>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl TransitionRecoveryClaimBarrier {
|
||||
pub(crate) fn install(transaction_id: Uuid) -> Self {
|
||||
let state = Arc::new(TransitionRecoveryClaimBarrierState {
|
||||
transaction_id,
|
||||
..Default::default()
|
||||
});
|
||||
let mut slot = TRANSITION_RECOVERY_CLAIM_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("transition recovery claim barrier mutex should not poison");
|
||||
assert!(
|
||||
slot.is_none(),
|
||||
"transition recovery claim barrier must be installed by one test at a time"
|
||||
);
|
||||
*slot = Some(Arc::clone(&state));
|
||||
drop(slot);
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("transition recovery should reach the cleanup claim CAS");
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TransitionRecoveryClaimBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
let mut slot = TRANSITION_RECOVERY_CLAIM_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("transition recovery claim barrier mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_before_transition_recovery_claim(transaction_id: Uuid) {
|
||||
let barrier = TRANSITION_RECOVERY_CLAIM_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("transition recovery claim barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.transaction_id == transaction_id)
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.arrived.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransitionOperatorProbe {
|
||||
@@ -858,49 +1038,129 @@ pub async fn process_transition_transaction_record(
|
||||
transaction: &TransitionTransaction,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
transaction.validate().map_err(transition_transaction_store_error)?;
|
||||
match transaction.state {
|
||||
// Box the expanded recovery state machine so callers on Tokio's default
|
||||
// worker stack do not inline its full future into an already-deep scan.
|
||||
Box::pin(process_transition_transaction_record_at(
|
||||
api,
|
||||
transaction,
|
||||
time::OffsetDateTime::now_utc().unix_timestamp_nanos(),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn process_transition_transaction_record_at(
|
||||
api: Arc<ECStore>,
|
||||
observed: &TransitionTransaction,
|
||||
now_unix_nanos: i128,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
let record_name =
|
||||
transition_transaction_record_object_name(observed.transaction_id).map_err(transition_transaction_store_error)?;
|
||||
// The synthetic key avoids nesting the recovery lock with the config
|
||||
// object's own I/O lock. Holding it across the bounded source proof and
|
||||
// remote DELETE elects one destructive recovery worker across nodes.
|
||||
let recovery_lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &format!("{record_name}.recovery-lock"))
|
||||
.await?;
|
||||
let _recovery_guard = recovery_lock
|
||||
.get_write_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await?;
|
||||
let current = match load_transition_transaction_record(api.clone(), observed.transaction_id).await {
|
||||
Ok(current) => current,
|
||||
Err(Error::ConfigNotFound) => return Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if ¤t != observed {
|
||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||
}
|
||||
|
||||
match current.state {
|
||||
TransitionTransactionState::Uploaded => {
|
||||
delete_transition_remote_candidate(api.clone(), transaction).await?;
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
TransitionTransactionState::CleanupPending => match local_commit_matches_transaction(api.clone(), transaction).await {
|
||||
Ok(true) => {
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
if transition_transaction_ownership_is_active(¤t, now_unix_nanos) {
|
||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||
}
|
||||
Ok(false) => {
|
||||
delete_transition_remote_candidate(api.clone(), transaction).await?;
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
Err(err) if transition_source_is_missing(&err) => {
|
||||
delete_transition_remote_candidate(api.clone(), transaction).await?;
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
TransitionTransactionState::LocalCommitStarted => {
|
||||
match local_commit_matches_transaction(api.clone(), transaction).await {
|
||||
Ok(true) => {
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
let mut cleanup = current.clone();
|
||||
cleanup
|
||||
.mark_cleanup_pending(
|
||||
current.fence(),
|
||||
TransitionCleanupProof {
|
||||
transaction_id: current.transaction_id,
|
||||
write_id: current.write_id,
|
||||
remote_object: current.remote_object.clone(),
|
||||
remote_version: current.remote_version.clone(),
|
||||
backend_fingerprint: current.backend_fingerprint,
|
||||
decision: TransitionCleanupDecision::UploadAbortedBeforeLocalCommit,
|
||||
},
|
||||
)
|
||||
.map_err(transition_transaction_store_error)?;
|
||||
#[cfg(test)]
|
||||
pause_before_transition_recovery_claim(current.transaction_id).await;
|
||||
match save_transition_transaction_record_if_current(api.clone(), ¤t, &cleanup).await {
|
||||
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
|
||||
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
TransitionTransactionState::CleanupPending => recover_cleanup_pending(api, ¤t).await,
|
||||
TransitionTransactionState::LocalCommitStarted => match local_commit_matches_transaction(api.clone(), ¤t).await {
|
||||
Ok(true) => {
|
||||
delete_transition_transaction_record(api, ¤t).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
TransitionTransactionState::AbortedNoRemote | TransitionTransactionState::Committed => {
|
||||
delete_transition_transaction_record(api, ¤t).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
TransitionTransactionState::UploadOutcomeUnknown => {
|
||||
if transition_transaction_ownership_is_active(¤t, now_unix_nanos) {
|
||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||
} else {
|
||||
recover_unknown_upload_outcome(api, ¤t).await
|
||||
}
|
||||
}
|
||||
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
}
|
||||
}
|
||||
|
||||
fn transition_transaction_ownership_is_active(transaction: &TransitionTransaction, now_unix_nanos: i128) -> bool {
|
||||
now_unix_nanos < i128::from(transaction.not_after_unix_nanos)
|
||||
}
|
||||
|
||||
async fn recover_cleanup_pending(
|
||||
api: Arc<ECStore>,
|
||||
transaction: &TransitionTransaction,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
match local_commit_matches_transaction(api.clone(), transaction).await {
|
||||
Ok(true) => {
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
TransitionTransactionState::UploadOutcomeUnknown => recover_unknown_upload_outcome(api, transaction).await,
|
||||
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Ok(false) => delete_unreferenced_transition_candidate(api, transaction).await,
|
||||
Err(err) if transition_source_is_missing(&err) => delete_unreferenced_transition_candidate(api, transaction).await,
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_unreferenced_transition_candidate(
|
||||
api: Arc<ECStore>,
|
||||
transaction: &TransitionTransaction,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
let current = match load_transition_transaction_record(api.clone(), transaction.transaction_id).await {
|
||||
Ok(current) => current,
|
||||
Err(Error::ConfigNotFound) => return Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if ¤t != transaction || current.state != TransitionTransactionState::CleanupPending {
|
||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||
}
|
||||
delete_transition_remote_candidate(api.clone(), ¤t).await?;
|
||||
delete_transition_transaction_record(api, ¤t).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
|
||||
async fn recover_unknown_upload_outcome(
|
||||
api: Arc<ECStore>,
|
||||
transaction: &TransitionTransaction,
|
||||
@@ -928,17 +1188,7 @@ async fn recover_unknown_upload_outcome(
|
||||
TransitionCandidateProbe::VersionedPresent(version_id)
|
||||
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
|
||||
{
|
||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
&transaction.remote_object,
|
||||
&version_id,
|
||||
&transaction.tier_name,
|
||||
transaction.backend_fingerprint,
|
||||
&api.tier_config_mgr(),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::other)?;
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||
}
|
||||
TransitionCandidateProbe::VersionedPresent(version_id) => {
|
||||
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
|
||||
@@ -968,10 +1218,11 @@ async fn cleanup_recovered_unknown_upload_candidate(
|
||||
},
|
||||
)
|
||||
.map_err(transition_transaction_store_error)?;
|
||||
save_transition_transaction_record(api.clone(), &cleanup).await?;
|
||||
delete_transition_remote_candidate(api.clone(), &cleanup).await?;
|
||||
delete_transition_transaction_record(api, &cleanup).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
match save_transition_transaction_record_if_current(api.clone(), transaction, &cleanup).await {
|
||||
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
|
||||
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn transition_source_is_missing(err: &Error) -> bool {
|
||||
@@ -986,13 +1237,7 @@ fn transition_source_is_missing(err: &Error) -> bool {
|
||||
}
|
||||
|
||||
async fn local_commit_matches_transaction(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<bool> {
|
||||
let opts = ObjectOptions {
|
||||
version_id: transaction.source.version_id.map(|version_id| version_id.to_string()),
|
||||
versioned: transaction.source.version_mode == TransitionSourceVersionMode::Versioned,
|
||||
version_suspended: transaction.source.version_mode == TransitionSourceVersionMode::VersionSuspended,
|
||||
metadata_cache_safe: false,
|
||||
..Default::default()
|
||||
};
|
||||
let opts = transition_source_lookup_options(transaction);
|
||||
let object = api
|
||||
.get_object_info(&transaction.source.bucket, &transaction.source.object, &opts)
|
||||
.await?;
|
||||
@@ -1003,6 +1248,23 @@ async fn local_commit_matches_transaction(api: Arc<ECStore>, transaction: &Trans
|
||||
&& transitioned.version_id == transaction.remote_version.tier_delete_version_id().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn transition_source_lookup_options(transaction: &TransitionTransaction) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
version_id: match transaction.source.version_mode {
|
||||
TransitionSourceVersionMode::Versioned => transaction.source.version_id.map(|version_id| version_id.to_string()),
|
||||
// Both modes identify the stored null version. Query it explicitly
|
||||
// so a later versioning change cannot redirect the proof to a new latest version.
|
||||
TransitionSourceVersionMode::Unversioned | TransitionSourceVersionMode::VersionSuspended => {
|
||||
Some(Uuid::nil().to_string())
|
||||
}
|
||||
},
|
||||
versioned: transaction.source.version_mode == TransitionSourceVersionMode::Versioned,
|
||||
version_suspended: transaction.source.version_mode == TransitionSourceVersionMode::VersionSuspended,
|
||||
metadata_cache_safe: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_transition_remote_candidate(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<()> {
|
||||
let version_id = transaction.remote_version.tier_delete_version_id().unwrap_or_default();
|
||||
let version_id_exact = transaction.remote_version.kind == TransitionRemoteVersionKind::Versioned;
|
||||
@@ -1023,6 +1285,25 @@ pub async fn recover_transition_transaction_records(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
marker: Option<String>,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
|
||||
recover_transition_transaction_records_with_now(api, limit, marker, None).await
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub async fn recover_transition_transaction_records_at(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
marker: Option<String>,
|
||||
now_unix_nanos: i128,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
|
||||
recover_transition_transaction_records_with_now(api, limit, marker, Some(now_unix_nanos)).await
|
||||
}
|
||||
|
||||
async fn recover_transition_transaction_records_with_now(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
marker: Option<String>,
|
||||
now_unix_nanos: Option<i128>,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
|
||||
if limit == 0 {
|
||||
return Err(Error::other("transition transaction recovery limit must be greater than zero"));
|
||||
@@ -1087,7 +1368,13 @@ pub async fn recover_transition_transaction_records(
|
||||
}
|
||||
};
|
||||
|
||||
match process_transition_transaction_record(api.clone(), &transaction).await {
|
||||
let recovery = match now_unix_nanos {
|
||||
Some(now_unix_nanos) => {
|
||||
Box::pin(process_transition_transaction_record_at(api.clone(), &transaction, now_unix_nanos)).await
|
||||
}
|
||||
None => process_transition_transaction_record(api.clone(), &transaction).await,
|
||||
};
|
||||
match recovery {
|
||||
Ok(
|
||||
TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted
|
||||
| TransitionTransactionRecoveryOutcome::RecordDeleted,
|
||||
@@ -1332,6 +1619,34 @@ mod tests {
|
||||
.expect("expired unknown upload outcome should be eligible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_ownership_window_expires_at_not_after() {
|
||||
let transaction = new_transaction();
|
||||
let deadline = i128::from(transaction.not_after_unix_nanos);
|
||||
|
||||
assert!(transition_transaction_ownership_is_active(&transaction, deadline - 1));
|
||||
assert!(!transition_transaction_ownership_is_active(&transaction, deadline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_transition_source_lookup_targets_the_exact_version_shape() {
|
||||
for mode in [
|
||||
TransitionSourceVersionMode::Unversioned,
|
||||
TransitionSourceVersionMode::VersionSuspended,
|
||||
] {
|
||||
let mut transaction = new_transaction();
|
||||
transaction.source.version_id = None;
|
||||
transaction.source.version_mode = mode;
|
||||
|
||||
let opts = transition_source_lookup_options(&transaction);
|
||||
|
||||
assert_eq!(opts.version_id, Some(Uuid::nil().to_string()));
|
||||
assert!(!opts.versioned);
|
||||
assert_eq!(opts.version_suspended, mode == TransitionSourceVersionMode::VersionSuspended);
|
||||
assert!(!opts.metadata_cache_safe);
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_proof(transaction: &TransitionTransaction, decision: TransitionCleanupDecision) -> TransitionCleanupProof {
|
||||
TransitionCleanupProof {
|
||||
transaction_id: transaction.transaction_id,
|
||||
@@ -1346,10 +1661,17 @@ mod tests {
|
||||
#[test]
|
||||
fn remote_version_distinguishes_unknown_unversioned_and_versioned() {
|
||||
assert_eq!(TransitionRemoteVersion::known_from_put_response("").tier_delete_version_id(), None);
|
||||
let nil_version = Uuid::nil().to_string();
|
||||
let invalid_nil = TransitionRemoteVersion::known_from_put_response(nil_version.clone());
|
||||
assert_eq!(
|
||||
TransitionRemoteVersion::known_from_put_response(Uuid::nil().to_string()).tier_delete_version_id(),
|
||||
None
|
||||
invalid_nil.tier_delete_version_id(),
|
||||
Some(nil_version.as_str()),
|
||||
"a non-empty version must never be downgraded to an unversioned DELETE"
|
||||
);
|
||||
assert!(matches!(
|
||||
invalid_nil.validate(),
|
||||
Err(TransitionTransactionError::Corrupt("versioned remote version is nil uuid"))
|
||||
));
|
||||
|
||||
let version_id = Uuid::new_v4().to_string();
|
||||
assert_eq!(
|
||||
@@ -1468,6 +1790,34 @@ mod tests {
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
TransitionTransaction::new(TransitionTransactionInit {
|
||||
deployment_id: Uuid::new_v4(),
|
||||
transaction_id: Uuid::new_v4(),
|
||||
owner_epoch: Uuid::new_v4(),
|
||||
write_id: Uuid::new_v4(),
|
||||
source: source_identity(TransitionSourceVersionMode::Unversioned),
|
||||
tier_name: "warm-tier".to_string(),
|
||||
backend_fingerprint: [0; 32],
|
||||
not_after_unix_nanos: 1,
|
||||
}),
|
||||
Err(TransitionTransactionError::Corrupt("backend fingerprint is empty"))
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
TransitionTransaction::new(TransitionTransactionInit {
|
||||
deployment_id: Uuid::new_v4(),
|
||||
transaction_id: Uuid::new_v4(),
|
||||
owner_epoch: Uuid::new_v4(),
|
||||
write_id: Uuid::new_v4(),
|
||||
source: source_identity(TransitionSourceVersionMode::Unversioned),
|
||||
tier_name: "warm-tier".to_string(),
|
||||
backend_fingerprint: BACKEND_FINGERPRINT,
|
||||
not_after_unix_nanos: 0,
|
||||
}),
|
||||
Err(TransitionTransactionError::Corrupt("ownership deadline is not positive"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -270,6 +270,7 @@ pub const BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG: &str = "public-access-block.xml";
|
||||
pub const BUCKET_ACL_CONFIG: &str = "bucket-acl.json";
|
||||
pub const BUCKET_TABLE_CONFIG: &str = "table-bucket.json";
|
||||
pub const BUCKET_DURABILITY_CONFIG: &str = "durability.json";
|
||||
pub const BUCKET_ON_DEMAND_MIGRATION_CONFIG: &str = "on-demand-migration.json";
|
||||
pub const BUCKET_TABLE_RESERVED_PREFIX: &str = ".rustfs-table";
|
||||
pub const BUCKET_TABLE_CATALOG_META_PREFIX: &str = "s3tables/catalog";
|
||||
pub const BUCKET_TABLE_CATALOG_TABLE_BUCKETS_PREFIX: &str = "table-buckets";
|
||||
@@ -321,6 +322,7 @@ pub struct BucketMetadata {
|
||||
pub bucket_acl_config_json: Vec<u8>,
|
||||
pub table_bucket_config_json: Vec<u8>,
|
||||
pub durability_config_json: Vec<u8>,
|
||||
pub on_demand_migration_config_json: Vec<u8>,
|
||||
|
||||
pub policy_config_updated_at: OffsetDateTime,
|
||||
pub object_lock_config_updated_at: OffsetDateTime,
|
||||
@@ -342,6 +344,7 @@ pub struct BucketMetadata {
|
||||
pub bucket_acl_config_updated_at: OffsetDateTime,
|
||||
pub table_bucket_config_updated_at: OffsetDateTime,
|
||||
pub durability_config_updated_at: OffsetDateTime,
|
||||
pub on_demand_migration_config_updated_at: OffsetDateTime,
|
||||
|
||||
pub new_field_updated_at: OffsetDateTime,
|
||||
|
||||
@@ -393,6 +396,7 @@ impl Default for BucketMetadata {
|
||||
bucket_acl_config_json: Default::default(),
|
||||
table_bucket_config_json: Default::default(),
|
||||
durability_config_json: Default::default(),
|
||||
on_demand_migration_config_json: Default::default(),
|
||||
policy_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
object_lock_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
encryption_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
@@ -413,6 +417,7 @@ impl Default for BucketMetadata {
|
||||
bucket_acl_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
table_bucket_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
durability_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
on_demand_migration_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
new_field_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
policy_config: Default::default(),
|
||||
notification_config: Default::default(),
|
||||
@@ -472,11 +477,40 @@ impl BucketMetadata {
|
||||
!self.table_bucket_config_json.is_empty()
|
||||
}
|
||||
|
||||
/// `bucket-targets.json` is stored for this bucket but this build cannot
|
||||
/// decode it.
|
||||
///
|
||||
/// Keeps "no replication targets configured" and "the target
|
||||
/// configuration cannot be read" apart, the same distinction the
|
||||
/// `fabricated` marker draws for the bucket metadata as a whole. Only
|
||||
/// meaningful after [`Self::parse_all_configs`] has run; readers must fail
|
||||
/// closed on `true` instead of serving an empty target set.
|
||||
pub fn bucket_targets_unreadable(&self) -> bool {
|
||||
!self.bucket_targets_config_json.is_empty() && self.bucket_target_config.is_none()
|
||||
}
|
||||
|
||||
/// Parsed per-bucket durability override, if a valid one is stored.
|
||||
///
|
||||
/// Absent/empty/unparsable payloads all mean "no override" (the bucket
|
||||
/// follows the global durability mode); a parse failure is logged so a
|
||||
/// corrupted entry cannot silently change fsync behavior.
|
||||
/// Parsed on-demand migration config, if one is stored.
|
||||
///
|
||||
/// `Ok(None)` means no config (absent or cleared). A stored payload that
|
||||
/// does not parse is an error, never a default: the runtime must not
|
||||
/// pull from a source it cannot describe.
|
||||
pub fn on_demand_migration_config(
|
||||
&self,
|
||||
) -> std::result::Result<
|
||||
Option<super::on_demand_migration::OnDemandMigrationConfig>,
|
||||
super::on_demand_migration::OnDemandMigrationConfigError,
|
||||
> {
|
||||
if self.on_demand_migration_config_json.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
super::on_demand_migration::OnDemandMigrationConfig::from_json(&self.on_demand_migration_config_json).map(Some)
|
||||
}
|
||||
|
||||
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
|
||||
if self.durability_config_json.is_empty() {
|
||||
return None;
|
||||
@@ -555,6 +589,9 @@ impl BucketMetadata {
|
||||
"BucketAclConfigJSON" | "BucketAclConfigJson" => self.bucket_acl_config_json = read_msgp_bin(rd)?,
|
||||
"TableBucketConfigJSON" | "TableBucketConfigJson" => self.table_bucket_config_json = read_msgp_bin(rd)?,
|
||||
"DurabilityConfigJSON" | "DurabilityConfigJson" => self.durability_config_json = read_msgp_bin(rd)?,
|
||||
"OnDemandMigrationConfigJSON" | "OnDemandMigrationConfigJson" => {
|
||||
self.on_demand_migration_config_json = read_msgp_bin(rd)?
|
||||
}
|
||||
"CorsConfigUpdatedAt" => self.cors_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"LoggingConfigUpdatedAt" => self.logging_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"WebsiteConfigUpdatedAt" => self.website_config_updated_at = read_msgp_time_value(rd)?,
|
||||
@@ -564,6 +601,7 @@ impl BucketMetadata {
|
||||
"BucketAclConfigUpdatedAt" => self.bucket_acl_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"TableBucketConfigUpdatedAt" => self.table_bucket_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"DurabilityConfigUpdatedAt" => self.durability_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"OnDemandMigrationConfigUpdatedAt" => self.on_demand_migration_config_updated_at = read_msgp_time_value(rd)?,
|
||||
other => {
|
||||
tracing::debug!(field = %other, "BucketMetadata decode_from: skipping unknown field");
|
||||
skip_msgp_value(rd)?;
|
||||
@@ -576,8 +614,8 @@ impl BucketMetadata {
|
||||
|
||||
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
|
||||
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
|
||||
// Map size: MinIO fields (25) + RustFS extensions (19)
|
||||
let map_len: u32 = 44;
|
||||
// Map size: MinIO fields (25) + RustFS extensions (21)
|
||||
let map_len: u32 = 46;
|
||||
rmp::encode::write_map_len(wr, map_len)?;
|
||||
|
||||
// MinIO field order (same as Go struct)
|
||||
@@ -637,6 +675,7 @@ impl BucketMetadata {
|
||||
write_bin_field(wr, "BucketAclConfigJSON", &self.bucket_acl_config_json)?;
|
||||
write_bin_field(wr, "TableBucketConfigJSON", &self.table_bucket_config_json)?;
|
||||
write_bin_field(wr, "DurabilityConfigJSON", &self.durability_config_json)?;
|
||||
write_bin_field(wr, "OnDemandMigrationConfigJSON", &self.on_demand_migration_config_json)?;
|
||||
rmp::encode::write_str(wr, "CorsConfigUpdatedAt")?;
|
||||
write_msgp_time(wr, self.cors_config_updated_at)?;
|
||||
rmp::encode::write_str(wr, "LoggingConfigUpdatedAt")?;
|
||||
@@ -655,6 +694,8 @@ impl BucketMetadata {
|
||||
write_msgp_time(wr, self.table_bucket_config_updated_at)?;
|
||||
rmp::encode::write_str(wr, "DurabilityConfigUpdatedAt")?;
|
||||
write_msgp_time(wr, self.durability_config_updated_at)?;
|
||||
rmp::encode::write_str(wr, "OnDemandMigrationConfigUpdatedAt")?;
|
||||
write_msgp_time(wr, self.on_demand_migration_config_updated_at)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -756,6 +797,9 @@ impl BucketMetadata {
|
||||
if self.durability_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
||||
self.durability_config_updated_at = self.created
|
||||
}
|
||||
if self.on_demand_migration_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
||||
self.on_demand_migration_config_updated_at = self.created
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||
@@ -871,6 +915,17 @@ impl BucketMetadata {
|
||||
self.durability_config_json = data;
|
||||
self.durability_config_updated_at = updated;
|
||||
}
|
||||
BUCKET_ON_DEMAND_MIGRATION_CONFIG => {
|
||||
// Structural check only (shape, unknown fields); the
|
||||
// deployment-relative rules run in the admin handler with a
|
||||
// `ValidationContext`. A blob this build cannot read must not
|
||||
// be persisted for every later reader to trip over.
|
||||
if !data.is_empty() {
|
||||
super::on_demand_migration::OnDemandMigrationConfig::from_json(&data).map_err(Error::other)?;
|
||||
}
|
||||
self.on_demand_migration_config_json = data;
|
||||
self.on_demand_migration_config_updated_at = updated;
|
||||
}
|
||||
_ => return Err(Error::other(format!("config file not found : {config_file}"))),
|
||||
}
|
||||
|
||||
@@ -921,7 +976,32 @@ impl BucketMetadata {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_all_configs(&mut self) -> Result<()> {
|
||||
/// Decode every stored sub-configuration into its typed field.
|
||||
///
|
||||
/// A decode failure never fails the whole load: this runs on every bucket
|
||||
/// metadata read, including startup and peer reload, so one bucket's
|
||||
/// corrupt sub-configuration must not make the bucket — or the node —
|
||||
/// unloadable. Instead the failure is *retained*: the raw bytes stay
|
||||
/// untouched and the typed field stays `None`, so `!raw.is_empty() &&
|
||||
/// typed.is_none()` is the durable "exists but cannot be read" signal that
|
||||
/// each accessor keys off. Which accessors must fail closed on it:
|
||||
///
|
||||
/// | Config | Verdict |
|
||||
/// |---|---|
|
||||
/// | policy | Fails closed: `get_bucket_policy` re-parses the raw JSON and propagates the error; `get_bucket_policy_raw` returns the stored bytes. |
|
||||
/// | object lock | Fails closed in `object_lock_config_state_from_authoritative_metadata`; a retention decision may never be taken on a guess. |
|
||||
/// | versioning | Fails closed in `get_versioning_config`; guessing Unversioned would make delete markers and version ids diverge from what is on disk. |
|
||||
/// | replication | Fails closed in `get_replication_config`. |
|
||||
/// | bucket targets | Fails closed in `get_bucket_targets_config`, and `sync_bucket_target_sys` marks the bucket unreadable in `BucketTargetSys` instead of publishing an empty target set (rustfs/backlog#2282). |
|
||||
/// | encryption | Fails closed in `get_sse_config`: degrading to "no default encryption" stores plaintext objects the operator required to be encrypted. |
|
||||
/// | public access block | Fails closed in `get_public_access_block_config`: degrading grants the anonymous access the operator asked to block. |
|
||||
/// | quota | Fails closed in `get_quota_config`; the enforcement path in `quota::checker` already re-parses the raw JSON and refuses on error. |
|
||||
/// | lifecycle | Safe to degrade: no rules means no expiration and no transition, so nothing is deleted or moved on the strength of an unreadable rule set. The bucket keeps serving reads and writes. |
|
||||
/// | notification | Safe to degrade: events are an outbound side channel; no consumer draws a durability or authorization conclusion from their absence. |
|
||||
/// | tagging | Safe to degrade: bucket tags are cost-allocation labels here; object-level tag conditions come from object metadata, not this blob. |
|
||||
/// | CORS | Safe to degrade: an absent CORS configuration rejects cross-origin browser requests, which is already the restrictive direction. |
|
||||
/// | logging, website, accelerate, request payment, bucket ACL | Safe to degrade: each only shapes an optional response or an optional side channel, and none of them authorizes an action or decides whether data is retained. |
|
||||
pub(super) fn parse_all_configs(&mut self) -> Result<()> {
|
||||
if let Err(e) = self.parse_policy_config() {
|
||||
tracing::warn!(
|
||||
event = "bucket_metadata_parse_failed",
|
||||
@@ -1045,20 +1125,26 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
// A stored targets blob that cannot be decoded must not collapse into
|
||||
// the empty target set: that is indistinguishable from "no replication
|
||||
// configured", so replication stops and no caller ever sees an error
|
||||
// (rustfs/backlog#2282). Leaving the typed field `None` while the raw
|
||||
// bytes stay non-empty is the retained parse failure every targets
|
||||
// reader keys off; the bytes are preserved so the configuration is
|
||||
// still recoverable.
|
||||
self.bucket_target_config = None;
|
||||
if !self.bucket_targets_config_json.is_empty() {
|
||||
if let Err(e) = serde_json::from_slice::<BucketTargets>(&self.bucket_targets_config_json)
|
||||
.map(|t| self.bucket_target_config = Some(t))
|
||||
{
|
||||
tracing::warn!(
|
||||
match serde_json::from_slice::<BucketTargets>(&self.bucket_targets_config_json) {
|
||||
Ok(targets) => self.bucket_target_config = Some(targets),
|
||||
Err(e) => tracing::error!(
|
||||
event = "bucket_metadata_parse_failed",
|
||||
component = "ecstore",
|
||||
subsystem = "bucket_metadata",
|
||||
bucket = %self.name,
|
||||
config = "bucket_targets",
|
||||
error = %e,
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
self.bucket_target_config = Some(BucketTargets::default());
|
||||
"Bucket replication targets are unreadable; replication for this bucket fails closed"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
self.bucket_target_config = Some(BucketTargets::default());
|
||||
@@ -1492,6 +1578,117 @@ mod test {
|
||||
assert_eq!(bucket_targets.targets[0].target_bucket, "target-bucket");
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2282: a stored targets blob this build cannot decode
|
||||
/// must not become the empty target set, and must stay distinguishable
|
||||
/// from a bucket that never configured a target.
|
||||
#[test]
|
||||
fn unreadable_bucket_targets_never_degrade_to_an_empty_target_set() {
|
||||
let truncated = br#"{"targets":[{"endpoint":"s3.example.com","#.to_vec();
|
||||
let mut corrupt = BucketMetadata::new("corrupt-targets");
|
||||
corrupt.bucket_targets_config_json = truncated.clone();
|
||||
|
||||
corrupt
|
||||
.parse_all_configs()
|
||||
.expect("one unreadable sub-config must not fail the whole metadata load");
|
||||
|
||||
assert!(
|
||||
corrupt.bucket_target_config.is_none(),
|
||||
"an undecodable targets blob must not produce a target set at all"
|
||||
);
|
||||
assert!(corrupt.bucket_targets_unreadable());
|
||||
assert_eq!(
|
||||
corrupt.bucket_targets_config_json, truncated,
|
||||
"the raw bytes must survive so the configuration stays recoverable"
|
||||
);
|
||||
|
||||
// The genuinely-absent case is unchanged, and the two now diverge.
|
||||
let mut absent = BucketMetadata::new("no-targets");
|
||||
absent.parse_all_configs().expect("absent targets parse");
|
||||
assert!(
|
||||
absent.bucket_target_config.as_ref().is_some_and(BucketTargets::is_empty),
|
||||
"a bucket that configured no target still reads as an empty target set"
|
||||
);
|
||||
assert!(!absent.bucket_targets_unreadable());
|
||||
}
|
||||
|
||||
/// `Credentials` carries no struct-level `serde(default)`, so one target
|
||||
/// missing `secretKey` is a hard parse error for the whole document. That
|
||||
/// must surface as "unreadable", never as "no targets configured".
|
||||
#[test]
|
||||
fn bucket_targets_missing_secret_key_are_unreadable_not_empty() {
|
||||
let mut bm = BucketMetadata::new("missing-secret-key");
|
||||
bm.bucket_targets_config_json = br#"{"targets":[{"endpoint":"s3.example.com","targetbucket":"remote","arn":"arn:rustfs:replication:us-east-1:src:1","credentials":{"accessKey":"AKIAEXAMPLE"}}]}"#.to_vec();
|
||||
|
||||
bm.parse_all_configs()
|
||||
.expect("a rejected targets document must not fail the whole metadata load");
|
||||
|
||||
assert!(
|
||||
bm.bucket_targets_unreadable(),
|
||||
"a targets document rejected for a missing secretKey is unreadable, not empty"
|
||||
);
|
||||
assert!(bm.bucket_target_config.is_none());
|
||||
}
|
||||
|
||||
/// The invariant every branch of `parse_all_configs` shares: a stored but
|
||||
/// undecodable payload keeps its raw bytes and leaves the typed field
|
||||
/// `None`, so no branch fabricates a value. What a reader may then do with
|
||||
/// that state is decided per config; see the table on `parse_all_configs`.
|
||||
#[test]
|
||||
fn every_config_branch_retains_its_parse_failure_instead_of_defaulting() {
|
||||
let malformed_xml = b"<not-a-valid-document".to_vec();
|
||||
let malformed_json = b"{not-json".to_vec();
|
||||
|
||||
let mut bm = BucketMetadata::new("all-configs-malformed");
|
||||
bm.policy_config_json = malformed_json.clone();
|
||||
bm.quota_config_json = malformed_json.clone();
|
||||
bm.bucket_targets_config_json = malformed_json.clone();
|
||||
bm.notification_config_xml = malformed_xml.clone();
|
||||
bm.lifecycle_config_xml = malformed_xml.clone();
|
||||
bm.object_lock_config_xml = malformed_xml.clone();
|
||||
bm.versioning_config_xml = malformed_xml.clone();
|
||||
bm.encryption_config_xml = malformed_xml.clone();
|
||||
bm.tagging_config_xml = malformed_xml.clone();
|
||||
bm.replication_config_xml = malformed_xml.clone();
|
||||
bm.cors_config_xml = malformed_xml.clone();
|
||||
bm.logging_config_xml = malformed_xml.clone();
|
||||
bm.website_config_xml = malformed_xml.clone();
|
||||
bm.accelerate_config_xml = malformed_xml.clone();
|
||||
bm.request_payment_config_xml = malformed_xml.clone();
|
||||
bm.public_access_block_config_xml = malformed_xml.clone();
|
||||
// `bucket_acl_config_json` is only checked for UTF-8, so only invalid
|
||||
// UTF-8 exercises its failure branch.
|
||||
bm.bucket_acl_config_json = vec![0xff, 0xfe];
|
||||
|
||||
bm.parse_all_configs()
|
||||
.expect("a bucket whose every config is corrupt must still load its metadata");
|
||||
|
||||
let cleared: [(&str, bool); 17] = [
|
||||
("policy", bm.policy_config.is_none()),
|
||||
("quota", bm.quota_config.is_none()),
|
||||
("bucket_targets", bm.bucket_target_config.is_none()),
|
||||
("notification", bm.notification_config.is_none()),
|
||||
("lifecycle", bm.lifecycle_config.is_none()),
|
||||
("object_lock", bm.object_lock_config.is_none()),
|
||||
("versioning", bm.versioning_config.is_none()),
|
||||
("encryption", bm.sse_config.is_none()),
|
||||
("tagging", bm.tagging_config.is_none()),
|
||||
("replication", bm.replication_config.is_none()),
|
||||
("cors", bm.cors_config.is_none()),
|
||||
("logging", bm.logging_config.is_none()),
|
||||
("website", bm.website_config.is_none()),
|
||||
("accelerate", bm.accelerate_config.is_none()),
|
||||
("request_payment", bm.request_payment_config.is_none()),
|
||||
("public_access_block", bm.public_access_block_config.is_none()),
|
||||
("bucket_acl", bm.bucket_acl_config.is_none()),
|
||||
];
|
||||
for (config, is_cleared) in cleared {
|
||||
assert!(is_cleared, "{config}: a corrupt payload must not be replaced by a default");
|
||||
}
|
||||
|
||||
assert_eq!(bm.bucket_targets_config_json, malformed_json, "raw bytes are retained");
|
||||
assert_eq!(bm.lifecycle_config_xml, malformed_xml, "raw bytes are retained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_update_config_clears_parsed_config_on_delete() {
|
||||
let mut bm = BucketMetadata::new("test-bucket");
|
||||
@@ -1779,6 +1976,117 @@ mod test {
|
||||
assert!(!bm.table_bucket_enabled());
|
||||
}
|
||||
|
||||
const ODM_JSON: &[u8] = br#"{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
||||
|
||||
/// rustfs/backlog#2148: the on-demand migration config is a RustFS
|
||||
/// extension entry that round-trips through `update_config` and the
|
||||
/// msgpack codec, clears on delete, and never parses corruption into a
|
||||
/// default.
|
||||
#[test]
|
||||
fn on_demand_migration_config_round_trips_and_tracks_updates() {
|
||||
use crate::bucket::on_demand_migration::{OnDemandMigrationConfig, OnDemandMigrationConfigError};
|
||||
|
||||
let mut bm = BucketMetadata::new("odm-bucket");
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(None), "fresh metadata carries no config");
|
||||
|
||||
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.expect("valid config is accepted");
|
||||
assert_ne!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(Some(expected.clone())));
|
||||
|
||||
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
||||
assert_eq!(back.on_demand_migration_config_json, bm.on_demand_migration_config_json);
|
||||
assert_eq!(
|
||||
back.on_demand_migration_config_updated_at.unix_timestamp(),
|
||||
bm.on_demand_migration_config_updated_at.unix_timestamp()
|
||||
);
|
||||
assert_eq!(back.on_demand_migration_config(), Ok(Some(expected)));
|
||||
|
||||
// A blob this build cannot read is rejected at the write boundary
|
||||
// rather than persisted for every reader to trip over.
|
||||
let before = bm.on_demand_migration_config_json.clone();
|
||||
assert!(
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(bm.on_demand_migration_config_json, before, "a rejected update leaves the blob untouched");
|
||||
|
||||
// Delete clears the entry.
|
||||
let stamped = bm.on_demand_migration_config_updated_at;
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
|
||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
||||
assert!(bm.on_demand_migration_config_updated_at >= stamped);
|
||||
|
||||
// Corruption that bypassed `update_config` (disk, another writer)
|
||||
// is a typed error, never a default.
|
||||
bm.on_demand_migration_config_json = b"not-json".to_vec();
|
||||
assert!(matches!(bm.on_demand_migration_config(), Err(OnDemandMigrationConfigError::Malformed(_))));
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
|
||||
/// migration keys existed decodes with an empty blob and an epoch
|
||||
/// timestamp that `default_timestamps` back-fills from `created`.
|
||||
#[test]
|
||||
fn on_demand_migration_config_absent_in_legacy_blob_defaults_to_created() {
|
||||
let blob = decode_hex(include_str!("../../tests/fixtures/minio/bucket_metadata.blob.hex"));
|
||||
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
|
||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
||||
assert_eq!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
||||
|
||||
bm.default_timestamps();
|
||||
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
|
||||
assert_eq!(bm.on_demand_migration_config_updated_at, bm.created);
|
||||
|
||||
// A metadata blob from this build with no config set stays
|
||||
// indistinguishable from the legacy one for these fields.
|
||||
let fresh = BucketMetadata::unmarshal(&BucketMetadata::new("fresh").marshal_msg().unwrap()).unwrap();
|
||||
assert!(fresh.on_demand_migration_config_json.is_empty());
|
||||
assert_eq!(fresh.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: a reader that predates the two on-demand
|
||||
/// migration keys takes `decode_from`'s unknown-field branch, which is
|
||||
/// `skip_msgp_value`. Walk the new-format blob with exactly that
|
||||
/// primitive and prove both keys are skipped without desynchronising the
|
||||
/// stream, so the fields that follow them still decode.
|
||||
#[test]
|
||||
fn old_decoder_skips_on_demand_migration_fields_without_desync() {
|
||||
let mut bm = BucketMetadata::new("odm-skip");
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.unwrap();
|
||||
bm.update_config(BUCKET_DURABILITY_CONFIG, br#"{"mode":"relaxed"}"#.to_vec())
|
||||
.unwrap();
|
||||
let buf = bm.marshal_msg().unwrap();
|
||||
|
||||
let mut rd = std::io::Cursor::new(buf.as_slice());
|
||||
let fields = rmp::decode::read_map_len(&mut rd).unwrap();
|
||||
let mut skipped = Vec::new();
|
||||
let mut durability_json = Vec::new();
|
||||
for _ in 0..fields {
|
||||
let key_len = rmp::decode::read_str_len(&mut rd).unwrap();
|
||||
let mut key = vec![0u8; key_len as usize];
|
||||
rd.read_exact(&mut key).unwrap();
|
||||
let key = String::from_utf8(key).unwrap();
|
||||
match key.as_str() {
|
||||
// The field an old reader knows that is encoded *after* the
|
||||
// unknown JSON key and *before* the unknown timestamp key.
|
||||
"DurabilityConfigJSON" => durability_json = read_msgp_bin(&mut rd).unwrap(),
|
||||
other => {
|
||||
if other.starts_with("OnDemandMigration") {
|
||||
skipped.push(other.to_string());
|
||||
}
|
||||
skip_msgp_value(&mut rd).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(skipped, ["OnDemandMigrationConfigJSON", "OnDemandMigrationConfigUpdatedAt"]);
|
||||
assert_eq!(durability_json, br#"{"mode":"relaxed"}"#);
|
||||
assert_eq!(rd.position() as usize, buf.len(), "old-style walk must consume the blob exactly");
|
||||
}
|
||||
|
||||
/// HP-5b (rustfs/backlog#938): the durability override is a RustFS
|
||||
/// extension entry and must survive an encode/decode round trip.
|
||||
#[test]
|
||||
|
||||
@@ -19,9 +19,10 @@ use super::quota::BucketQuota;
|
||||
use super::target::BucketTargets;
|
||||
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
||||
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
|
||||
use crate::bucket::on_demand_migration::{ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig};
|
||||
use crate::bucket::utils::is_meta_bucketname;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result, is_err_bucket_not_found};
|
||||
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
@@ -359,6 +360,16 @@ async fn refresh_buckets_metadata_once(sys: Arc<RwLock<BucketMetadataSys>>) {
|
||||
}
|
||||
|
||||
async fn sync_bucket_target_sys(bucket: &str, bm: &BucketMetadata) {
|
||||
if bm.bucket_targets_unreadable() {
|
||||
// "The configuration cannot be read" is not "no targets configured".
|
||||
// Publishing an empty snapshot here is what silently stopped
|
||||
// replication (rustfs/backlog#2282): mark the bucket instead, so every
|
||||
// targets reader gets a typed error, and leave any snapshot from an
|
||||
// earlier readable load in place rather than withdrawing it.
|
||||
BucketTargetSys::get().mark_targets_unreadable(bucket).await;
|
||||
return;
|
||||
}
|
||||
|
||||
BucketTargetSys::get()
|
||||
.update_all_targets(bucket, bm.bucket_target_config.as_ref())
|
||||
.await;
|
||||
@@ -384,6 +395,42 @@ fn clear_bucket_durability(bucket: &str) {
|
||||
crate::disk::local::bucket_durability::set(bucket, None);
|
||||
}
|
||||
|
||||
/// Publish the bucket's on-demand migration config (or its absence) to the
|
||||
/// runtime registered in `ON_DEMAND_MIGRATION_CONFIG_HOOK`.
|
||||
///
|
||||
/// Called from the same five cache-install paths as
|
||||
/// [`sync_bucket_durability`]. A stored payload this build cannot parse is
|
||||
/// published as `None`: the runtime must stop pulling for that bucket rather
|
||||
/// than keep an older config or guess.
|
||||
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
|
||||
let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() else {
|
||||
return;
|
||||
};
|
||||
match bm.on_demand_migration_config() {
|
||||
Ok(config) => hook(bucket, config.as_ref()),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = "bucket_metadata_parse_failed",
|
||||
component = "ecstore",
|
||||
subsystem = "bucket_metadata",
|
||||
bucket = %bucket,
|
||||
config = "on_demand_migration",
|
||||
error = %err,
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
hook(bucket, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Withdraw a bucket's on-demand migration config when its metadata leaves
|
||||
/// the cache.
|
||||
fn clear_on_demand_migration(bucket: &str) {
|
||||
if let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() {
|
||||
hook(bucket, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(bucket: &str) -> Result<Arc<BucketMetadata>> {
|
||||
let sys = get_bucket_metadata_sys()?;
|
||||
let lock = sys.read().await;
|
||||
@@ -608,6 +655,12 @@ pub struct BucketMetadataMutationGuard {
|
||||
}
|
||||
|
||||
impl BucketMetadataMutationGuard {
|
||||
/// Returns the storage-verified identity while both incarnation fences remain valid.
|
||||
pub fn checked_bucket_incarnation(&self) -> Result<(&str, Uuid)> {
|
||||
self.ensure_valid(&self.bucket)?;
|
||||
Ok((&self.bucket, self.incarnation_id))
|
||||
}
|
||||
|
||||
fn ensure_valid(&self, bucket: &str) -> Result<()> {
|
||||
if self.bucket != bucket {
|
||||
return Err(Error::other("bucket metadata mutation guard does not match bucket"));
|
||||
@@ -627,6 +680,29 @@ async fn acquire_config_write_guard_for_incarnation(
|
||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
||||
bucket: &str,
|
||||
expected_incarnation_id: Option<Uuid>,
|
||||
) -> Result<BucketMetadataMutationGuard> {
|
||||
acquire_config_write_guard_with_migration(sys, bucket, expected_incarnation_id, true).await
|
||||
}
|
||||
|
||||
/// Scanner probes must not create an incarnation to make a capability available.
|
||||
pub async fn acquire_scanner_bucket_incarnation_fence(
|
||||
bucket: &str,
|
||||
expected_incarnation_id: Uuid,
|
||||
expected_owner_id: Uuid,
|
||||
) -> Result<BucketMetadataMutationGuard> {
|
||||
super::utils::check_valid_bucket_name(bucket)?;
|
||||
let sys = get_bucket_metadata_sys()?;
|
||||
if expected_owner_id.is_nil() || sys.read().await.api.id != expected_owner_id || expected_incarnation_id.is_nil() {
|
||||
return Err(Error::other("scanner bucket incarnation owner does not match"));
|
||||
}
|
||||
acquire_config_write_guard_with_migration(sys, bucket, Some(expected_incarnation_id), false).await
|
||||
}
|
||||
|
||||
async fn acquire_config_write_guard_with_migration(
|
||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
||||
bucket: &str,
|
||||
expected_incarnation_id: Option<Uuid>,
|
||||
migrate: bool,
|
||||
) -> Result<BucketMetadataMutationGuard> {
|
||||
let metadata_sys = sys.read().await.clone();
|
||||
let lifecycle_guard = metadata_sys.api.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||
@@ -634,13 +710,15 @@ async fn acquire_config_write_guard_for_incarnation(
|
||||
// Legacy buckets are migrated while the lifecycle fence prevents a
|
||||
// same-name replacement. The second read under the write transaction is
|
||||
// the CAS source of truth for the actual rewrite.
|
||||
await_bucket_namespace_operation(
|
||||
Some(&lifecycle_guard),
|
||||
bucket,
|
||||
"bucket config incarnation migration",
|
||||
metadata_sys.get_bucket_incarnation_id(bucket),
|
||||
)
|
||||
.await?;
|
||||
if migrate {
|
||||
await_bucket_namespace_operation(
|
||||
Some(&lifecycle_guard),
|
||||
bucket,
|
||||
"bucket config incarnation migration",
|
||||
metadata_sys.get_bucket_incarnation_id(bucket),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let transaction_guard = await_bucket_namespace_operation(
|
||||
Some(&lifecycle_guard),
|
||||
bucket,
|
||||
@@ -659,13 +737,12 @@ async fn acquire_config_write_guard_for_incarnation(
|
||||
async {
|
||||
match metadata_sys
|
||||
.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(crate::disk::error::Error::VolumeNotFound) => Err(Error::BucketNotFound(bucket.to_string())),
|
||||
Err(err) => Err(err.into()),
|
||||
Err(err) if is_err_strict_volume_not_found(&err) => Err(Error::BucketNotFound(bucket.to_string())),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -971,6 +1048,16 @@ pub async fn get_durability_config(
|
||||
Ok((bm.durability_config(), bm.durability_config_updated_at))
|
||||
}
|
||||
|
||||
/// The bucket's on-demand migration config with its update time, or
|
||||
/// `Ok(None)` when the bucket has none. A stored payload that does not parse
|
||||
/// is a typed error (`OnDemandMigrationConfigError` inside `Error::Io`).
|
||||
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
bucket_meta_sys.get_on_demand_migration_config(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -1115,6 +1202,16 @@ pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfigur
|
||||
bucket_meta_sys.get_replication_config(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_replication_config_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
bucket_meta_sys.get_replication_config(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_notification_config(bucket: &str) -> Result<Option<NotificationConfiguration>> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -1274,7 +1371,6 @@ pub struct BucketMetadataSys {
|
||||
/// name floods while avoiding repeated namespace and erasure reads.
|
||||
missing_buckets: moka::future::Cache<String, ()>,
|
||||
api: Arc<ECStore>,
|
||||
initialized: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl BucketMetadataSys {
|
||||
@@ -1303,7 +1399,6 @@ impl BucketMetadataSys {
|
||||
.time_to_live(MISSING_BUCKET_TTL)
|
||||
.build(),
|
||||
api,
|
||||
initialized: Arc::new(RwLock::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1364,13 +1459,12 @@ impl BucketMetadataSys {
|
||||
await_bucket_namespace_operation(Some(namespace_guard), bucket, operation, async {
|
||||
match self
|
||||
.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(crate::disk::error::Error::VolumeNotFound) => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
Err(Error::VolumeNotFound) => Ok(false),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -1380,9 +1474,15 @@ impl BucketMetadataSys {
|
||||
let _ = self.init_internal(buckets).await;
|
||||
}
|
||||
async fn init_internal(&self, buckets: Vec<String>) -> Result<()> {
|
||||
let count = runtime_sources::endpoint_erasure_set_count()
|
||||
.map(|count| count * 10)
|
||||
.ok_or_else(|| Error::other("endpoint pools not initialized"))?;
|
||||
let count = self
|
||||
.api
|
||||
.pools
|
||||
.iter()
|
||||
.map(|pool| pool.disk_set.len())
|
||||
.sum::<usize>()
|
||||
.checked_mul(10)
|
||||
.filter(|count| *count != 0)
|
||||
.ok_or_else(|| Error::other("bucket metadata store has no erasure sets"))?;
|
||||
|
||||
let mut failed_buckets: HashSet<String> = HashSet::new();
|
||||
let mut buckets = buckets.as_slice();
|
||||
@@ -1400,9 +1500,6 @@ impl BucketMetadataSys {
|
||||
buckets = &buckets[count..]
|
||||
}
|
||||
|
||||
let mut initialized = self.initialized.write().await;
|
||||
*initialized = true;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1479,14 +1576,6 @@ impl BucketMetadataSys {
|
||||
expected: Option<&Arc<BucketMetadata>>,
|
||||
namespace_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
) -> Result<()> {
|
||||
await_bucket_namespace_operation(
|
||||
Some(namespace_guard),
|
||||
bucket,
|
||||
"bucket metadata heal",
|
||||
self.api.heal_bucket(bucket, &HealOpts::default()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !self
|
||||
.bucket_exists(bucket, namespace_guard, "bucket metadata existence check")
|
||||
.await?
|
||||
@@ -1501,11 +1590,26 @@ impl BucketMetadataSys {
|
||||
if removed {
|
||||
BucketTargetSys::get().delete(bucket).await;
|
||||
clear_bucket_durability(bucket);
|
||||
clear_on_demand_migration(bucket);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
await_bucket_namespace_operation(
|
||||
Some(namespace_guard),
|
||||
bucket,
|
||||
"bucket metadata heal",
|
||||
self.api.heal_bucket(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
recreate: true,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (bm, persisted) = await_bucket_namespace_operation(
|
||||
Some(namespace_guard),
|
||||
bucket,
|
||||
@@ -1524,6 +1628,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
sync_on_demand_migration(bucket, &bm);
|
||||
}
|
||||
MetadataLoadMode::Initial => {
|
||||
let _publish_guard = self
|
||||
@@ -1570,6 +1675,7 @@ impl BucketMetadataSys {
|
||||
if removed {
|
||||
BucketTargetSys::get().delete(bucket).await;
|
||||
clear_bucket_durability(bucket);
|
||||
clear_on_demand_migration(bucket);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1592,6 +1698,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &metadata).await;
|
||||
sync_bucket_durability(bucket, &metadata);
|
||||
sync_on_demand_migration(bucket, &metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1619,6 +1726,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(&bucket).await;
|
||||
sync_bucket_target_sys(&bucket, &bm).await;
|
||||
sync_bucket_durability(&bucket, &bm);
|
||||
sync_on_demand_migration(&bucket, &bm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1639,6 +1747,7 @@ impl BucketMetadataSys {
|
||||
if removed {
|
||||
BucketTargetSys::get().delete(bucket).await;
|
||||
clear_bucket_durability(bucket);
|
||||
clear_on_demand_migration(bucket);
|
||||
}
|
||||
removed || removed_fabricated
|
||||
}
|
||||
@@ -1887,23 +1996,13 @@ impl BucketMetadataSys {
|
||||
"lazy metadata IO must start while the bucket namespace read lock is held"
|
||||
);
|
||||
}
|
||||
let (bm, persisted) = match await_bucket_namespace_operation(
|
||||
let (bm, persisted) = await_bucket_namespace_operation(
|
||||
Some(&guard),
|
||||
bucket,
|
||||
"lazy bucket metadata load",
|
||||
Box::pin(load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
return if *self.initialized.read().await {
|
||||
Err(Error::other("errBucketMetadataNotInitialized"))
|
||||
} else {
|
||||
Err(err)
|
||||
};
|
||||
}
|
||||
};
|
||||
.await?;
|
||||
|
||||
let bm = Arc::new(bm);
|
||||
|
||||
@@ -1914,11 +2013,9 @@ impl BucketMetadataSys {
|
||||
"lazy bucket metadata existence check",
|
||||
Box::pin(async {
|
||||
self.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Into::into)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -1940,6 +2037,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
sync_on_demand_migration(bucket, &bm);
|
||||
} else {
|
||||
let exists = self
|
||||
.bucket_exists(bucket, &guard, "lazy bucket metadata existence check")
|
||||
@@ -2061,7 +2159,9 @@ impl BucketMetadataSys {
|
||||
pub async fn get_public_access_block_config(&self, bucket: &str) -> Result<(PublicAccessBlockConfiguration, OffsetDateTime)> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
if let Some(config) = &bm.public_access_block_config {
|
||||
if !bm.public_access_block_config_xml.is_empty() && bm.public_access_block_config.is_none() {
|
||||
Err(Error::other("persisted bucket public access block configuration is invalid"))
|
||||
} else if let Some(config) = &bm.public_access_block_config {
|
||||
Ok((config.clone(), bm.public_access_block_config_updated_at))
|
||||
} else {
|
||||
Err(Error::ConfigNotFound)
|
||||
@@ -2197,10 +2297,8 @@ impl BucketMetadataSys {
|
||||
"legacy bucket metadata existence check",
|
||||
async {
|
||||
self.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::StorageError::from)
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2280,6 +2378,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &metadata).await;
|
||||
sync_bucket_durability(bucket, &metadata);
|
||||
sync_on_demand_migration(bucket, &metadata);
|
||||
Ok(BucketMetadataAuthority::Authoritative(metadata))
|
||||
}
|
||||
|
||||
@@ -2302,10 +2401,8 @@ impl BucketMetadataSys {
|
||||
"bucket metadata snapshot existence check",
|
||||
async {
|
||||
self.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::StorageError::from)
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2375,7 +2472,9 @@ impl BucketMetadataSys {
|
||||
pub async fn get_sse_config(&self, bucket: &str) -> Result<(ServerSideEncryptionConfiguration, OffsetDateTime)> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
if let Some(config) = &bm.sse_config {
|
||||
if !bm.encryption_config_xml.is_empty() && bm.sse_config.is_none() {
|
||||
Err(Error::other("persisted bucket encryption configuration is invalid"))
|
||||
} else if let Some(config) = &bm.sse_config {
|
||||
Ok((config.clone(), bm.encryption_config_updated_at))
|
||||
} else {
|
||||
Err(Error::ConfigNotFound)
|
||||
@@ -2446,7 +2545,9 @@ impl BucketMetadataSys {
|
||||
pub async fn get_quota_config(&self, bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
if let Some(config) = &bm.quota_config {
|
||||
if !bm.quota_config_json.is_empty() && bm.quota_config.is_none() {
|
||||
Err(Error::other("persisted bucket quota configuration is invalid"))
|
||||
} else if let Some(config) = &bm.quota_config {
|
||||
Ok((config.clone(), bm.quota_config_updated_at))
|
||||
} else {
|
||||
Err(Error::ConfigNotFound)
|
||||
@@ -2468,12 +2569,25 @@ impl BucketMetadataSys {
|
||||
pub async fn get_bucket_targets_config(&self, bucket: &str) -> Result<BucketTargets> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
if let Some(config) = &bm.bucket_target_config {
|
||||
if bm.bucket_targets_unreadable() {
|
||||
Err(Error::other("persisted bucket replication target configuration is invalid"))
|
||||
} else if let Some(config) = &bm.bucket_target_config {
|
||||
Ok(config.clone())
|
||||
} else {
|
||||
Err(Error::ConfigNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`get_on_demand_migration_config`].
|
||||
pub async fn get_on_demand_migration_config(
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
let config = bm.on_demand_migration_config().map_err(Error::other)?;
|
||||
Ok(config.map(|config| (config, bm.on_demand_migration_config_updated_at)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only fixture shared with sibling modules (e.g. the quota checker
|
||||
@@ -2528,6 +2642,7 @@ pub(crate) mod test_support {
|
||||
mod tests {
|
||||
use super::test_support::isolated_store_over_temp_disks;
|
||||
use super::*;
|
||||
use crate::bucket::bucket_target_sys::BucketTargetError;
|
||||
use crate::bucket::metadata::{
|
||||
BUCKET_ACCELERATE_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_LOGGING_CONFIG, BUCKET_NOTIFICATION_CONFIG,
|
||||
BUCKET_POLICY_CONFIG, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_REPLICATION_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG,
|
||||
@@ -2723,6 +2838,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The `parse_all_configs` audit (rustfs/backlog#2282): every accessor
|
||||
/// whose configuration grants something — plaintext storage, anonymous
|
||||
/// access, capacity, replication targets — reports a corrupt payload as
|
||||
/// invalid rather than as absent, because "absent" is what grants it.
|
||||
#[tokio::test]
|
||||
async fn malformed_permissive_configs_are_not_reported_as_absent() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
let bucket = "malformed-permissive-config";
|
||||
let mut metadata = BucketMetadata::new(bucket);
|
||||
metadata.encryption_config_xml = b"<ServerSideEncryptionConfiguration".to_vec();
|
||||
metadata.public_access_block_config_xml = b"<PublicAccessBlockConfiguration".to_vec();
|
||||
metadata.quota_config_json = b"{not-json".to_vec();
|
||||
metadata.bucket_targets_config_json = b"{not-json".to_vec();
|
||||
metadata
|
||||
.parse_all_configs()
|
||||
.expect("a corrupt sub-config must not fail the load");
|
||||
sys.set(bucket.to_string(), Arc::new(metadata)).await;
|
||||
|
||||
for (config, result) in [
|
||||
("encryption", sys.get_sse_config(bucket).await.err()),
|
||||
("public access block", sys.get_public_access_block_config(bucket).await.err()),
|
||||
("quota", sys.get_quota_config(bucket).await.err()),
|
||||
("bucket targets", sys.get_bucket_targets_config(bucket).await.err()),
|
||||
] {
|
||||
let err = result.unwrap_or_else(|| panic!("malformed {config} metadata must not read as a value"));
|
||||
assert_ne!(err, Error::ConfigNotFound, "malformed {config} metadata must not be reported as absent");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_states_distinguish_authoritative_absence_from_fabricated_metadata() {
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -3062,6 +3207,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_dirty_usage_incarnation_probe_does_not_migrate_legacy_metadata() {
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(store.clone())));
|
||||
let bucket = "scoped-ack-legacy";
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("create legacy bucket");
|
||||
}
|
||||
let mut metadata = BucketMetadata::new(bucket);
|
||||
metadata.bucket_incarnation_id = Uuid::nil();
|
||||
sys.read()
|
||||
.await
|
||||
.persist_and_set(metadata)
|
||||
.await
|
||||
.expect("persist legacy metadata");
|
||||
assert!(
|
||||
acquire_config_write_guard_with_migration(sys.clone(), bucket, Some(Uuid::new_v4()), false)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(load_bucket_incarnation(store, bucket).await.expect("read sidecar").is_none());
|
||||
assert!(
|
||||
sys.read()
|
||||
.await
|
||||
.get_config_from_disk(bucket)
|
||||
.await
|
||||
.expect("read metadata")
|
||||
.bucket_incarnation_id
|
||||
.is_nil()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn scoped_dirty_usage_incarnation_rejects_deleted_and_recreated_bucket() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("metadata owner");
|
||||
let bucket = "scoped-ack-recreated";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
let old = store.bucket_incarnation_id_from_disk(bucket).await.expect("old incarnation");
|
||||
let guard = acquire_config_write_guard_with_migration(sys.clone(), bucket, Some(old), false)
|
||||
.await
|
||||
.expect("trusted incarnation fence");
|
||||
assert_eq!(guard.checked_bucket_incarnation().expect("valid fences"), (bucket, old));
|
||||
drop(guard);
|
||||
store
|
||||
.delete_bucket(bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("delete bucket");
|
||||
assert!(
|
||||
acquire_config_write_guard_with_migration(sys.clone(), bucket, Some(old), false)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("recreate bucket");
|
||||
let new = store.bucket_incarnation_id_from_disk(bucket).await.expect("new incarnation");
|
||||
assert_ne!(old, new);
|
||||
assert!(
|
||||
acquire_config_write_guard_with_migration(sys.clone(), bucket, Some(old), false)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
acquire_config_write_guard_with_migration(sys, bucket, Some(new), false)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_node_metadata_rewrite_cannot_replace_bucket_incarnation_sidecar() {
|
||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
@@ -4001,6 +4222,114 @@ mod tests {
|
||||
target_sys.delete(bucket).await;
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2282: an unreadable `bucket-targets.json` reaches every
|
||||
/// targets reader as a typed error; it neither withdraws a snapshot a
|
||||
/// previous readable load published, nor collapses into the "no targets
|
||||
/// configured" state that a bucket with an absent configuration reports.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn unreadable_bucket_targets_fail_closed_and_stay_distinct_from_absent() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
let target_sys = BucketTargetSys::get();
|
||||
let unreadable = "targets-unreadable";
|
||||
let absent = "targets-absent";
|
||||
target_sys.delete(unreadable).await;
|
||||
target_sys.delete(absent).await;
|
||||
|
||||
// A readable load publishes this bucket's targets.
|
||||
let mut readable = BucketMetadata::new(unreadable);
|
||||
readable.bucket_target_config = Some(BucketTargets {
|
||||
targets: vec![target(unreadable, "live")],
|
||||
});
|
||||
sync_bucket_target_sys(unreadable, &readable).await;
|
||||
assert_eq!(
|
||||
target_sys
|
||||
.list_bucket_targets(unreadable)
|
||||
.await
|
||||
.expect("readable targets publish")
|
||||
.targets
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// The same bucket reloaded with a blob that cannot be decoded.
|
||||
let mut corrupt = BucketMetadata::new(unreadable);
|
||||
corrupt.bucket_targets_config_json = br#"{"targets":[{"endpoint":"#.to_vec();
|
||||
corrupt
|
||||
.parse_all_configs()
|
||||
.expect("an unreadable targets blob must not fail the metadata load");
|
||||
sys.set(unreadable.to_string(), Arc::new(corrupt)).await;
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
target_sys.list_bucket_targets(unreadable).await,
|
||||
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. })
|
||||
),
|
||||
"an unreadable configuration must not read as an empty or a missing target set"
|
||||
);
|
||||
assert!(
|
||||
target_sys.list_targets(unreadable, "").await.is_err(),
|
||||
"the admin listing must surface the fault instead of an empty list"
|
||||
);
|
||||
let err = sys
|
||||
.get_bucket_targets_config(unreadable)
|
||||
.await
|
||||
.expect_err("an unreadable targets configuration must not read as a value");
|
||||
assert_ne!(err, Error::ConfigNotFound, "unreadable must not be reported as absent");
|
||||
|
||||
// A bucket that never configured a target keeps its previous behavior.
|
||||
let mut no_targets = BucketMetadata::new(absent);
|
||||
no_targets.parse_all_configs().expect("absent targets parse");
|
||||
sys.set(absent.to_string(), Arc::new(no_targets)).await;
|
||||
assert!(
|
||||
matches!(
|
||||
target_sys.list_bucket_targets(absent).await,
|
||||
Err(BucketTargetError::BucketRemoteTargetNotFound { .. })
|
||||
),
|
||||
"an absent configuration must still report as a missing target set"
|
||||
);
|
||||
assert!(
|
||||
target_sys
|
||||
.list_targets(absent, "")
|
||||
.await
|
||||
.expect("an absent configuration lists no targets")
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
sys.get_bucket_targets_config(absent)
|
||||
.await
|
||||
.expect("an absent targets configuration still reads as an empty set")
|
||||
.is_empty(),
|
||||
"the absent path must keep returning an empty target set, exactly as before"
|
||||
);
|
||||
|
||||
// One bucket's unreadable configuration does not reach another bucket.
|
||||
assert!(!matches!(
|
||||
target_sys.list_bucket_targets(absent).await,
|
||||
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. })
|
||||
));
|
||||
|
||||
// A repaired configuration takes effect on the next load, no restart.
|
||||
let mut repaired = BucketMetadata::new(unreadable);
|
||||
repaired.bucket_target_config = Some(BucketTargets {
|
||||
targets: vec![target(unreadable, "repaired")],
|
||||
});
|
||||
sync_bucket_target_sys(unreadable, &repaired).await;
|
||||
assert_eq!(
|
||||
target_sys
|
||||
.list_bucket_targets(unreadable)
|
||||
.await
|
||||
.expect("a repaired configuration clears the unreadable marker")
|
||||
.targets
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
target_sys.delete(unreadable).await;
|
||||
target_sys.delete(absent).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn metadata_reload_clears_stale_bucket_targets_when_config_is_removed() {
|
||||
@@ -4054,6 +4383,151 @@ mod tests {
|
||||
assert_eq!(bucket_durability::lookup(bucket), None);
|
||||
}
|
||||
|
||||
const ODM_JSON: &[u8] = br#"{"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
||||
|
||||
/// Every `(bucket, config)` the recording hook has seen. Tests filter by
|
||||
/// their own bucket name; the hook is process-wide and set once.
|
||||
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<(String, Option<OnDemandMigrationConfig>)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn install_recording_odm_hook() {
|
||||
ON_DEMAND_MIGRATION_CONFIG_HOOK.get_or_init(|| {
|
||||
Box::new(|bucket, config| {
|
||||
ODM_HOOK_CALLS.lock().unwrap().push((bucket.to_string(), config.cloned()));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn odm_hook_calls(bucket: &str) -> Vec<Option<OnDemandMigrationConfig>> {
|
||||
ODM_HOOK_CALLS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(name, _)| name == bucket)
|
||||
.map(|(_, config)| config.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: the accessor reports absence as `Ok(None)` and a
|
||||
/// stored payload it cannot parse as a typed error, never as a default
|
||||
/// and never as `ConfigNotFound`.
|
||||
#[tokio::test]
|
||||
async fn get_on_demand_migration_config_distinguishes_absent_from_corrupt() {
|
||||
use crate::bucket::on_demand_migration::OnDemandMigrationConfigError;
|
||||
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
let bucket = "odm-accessor";
|
||||
|
||||
sys.set(bucket.to_string(), Arc::new(BucketMetadata::new(bucket))).await;
|
||||
assert_eq!(sys.get_on_demand_migration_config(bucket).await.unwrap(), None);
|
||||
|
||||
let mut corrupt = BucketMetadata::new(bucket);
|
||||
corrupt.on_demand_migration_config_json = br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec();
|
||||
sys.set(bucket.to_string(), Arc::new(corrupt)).await;
|
||||
let err = sys
|
||||
.get_on_demand_migration_config(bucket)
|
||||
.await
|
||||
.expect_err("corrupt config must not read as a default");
|
||||
assert_ne!(err, Error::ConfigNotFound, "corruption must not be reported as absence");
|
||||
let typed = match &err {
|
||||
Error::Io(io) => io
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<OnDemandMigrationConfigError>()),
|
||||
_ => None,
|
||||
};
|
||||
assert!(
|
||||
matches!(typed, Some(OnDemandMigrationConfigError::Malformed(_))),
|
||||
"typed parse error must survive the Result boundary, got: {err:?}"
|
||||
);
|
||||
|
||||
let mut valid = BucketMetadata::new(bucket);
|
||||
valid
|
||||
.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.unwrap();
|
||||
let stamped = valid.on_demand_migration_config_updated_at;
|
||||
sys.set(bucket.to_string(), Arc::new(valid)).await;
|
||||
let (config, updated_at) = sys
|
||||
.get_on_demand_migration_config(bucket)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("stored config is returned");
|
||||
assert_eq!(config, OnDemandMigrationConfig::from_json(ODM_JSON).unwrap());
|
||||
assert_eq!(updated_at, stamped);
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: the publish hook fires on every path that
|
||||
/// installs bucket metadata into the cache (set, initial load, peer
|
||||
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring
|
||||
/// `sync_bucket_durability`.
|
||||
#[tokio::test]
|
||||
async fn on_demand_migration_hook_fires_on_every_cache_install_path() {
|
||||
install_recording_odm_hook();
|
||||
|
||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "odm-hook-paths";
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
|
||||
}
|
||||
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
||||
let expect_publish = |before: usize, label: &str| {
|
||||
let calls = odm_hook_calls(bucket);
|
||||
assert_eq!(calls.len(), before + 1, "{label} must publish exactly once");
|
||||
assert_eq!(calls.last().unwrap().as_ref(), Some(&expected), "{label} must publish the stored config");
|
||||
};
|
||||
|
||||
// set (via persist_new_and_set, which installs through `set`).
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.unwrap();
|
||||
let writer = BucketMetadataSys::new(ecstore.clone());
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
writer.persist_new_and_set(bm).await.expect("metadata should persist");
|
||||
expect_publish(before, "set");
|
||||
|
||||
// init (initial load on a cold system).
|
||||
let mut cold = BucketMetadataSys::new(ecstore.clone());
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
cold.init(vec![bucket.to_string()]).await;
|
||||
assert!(cold.get(bucket).await.is_ok(), "initial load must cache the bucket");
|
||||
expect_publish(before, "init");
|
||||
|
||||
// peer reload.
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
cold.reload_from_store(bucket).await.expect("peer reload should publish");
|
||||
expect_publish(before, "peer reload");
|
||||
|
||||
// refresh loop.
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
let mut failed = HashSet::new();
|
||||
cold.concurrent_load(&[bucket.to_string()], &mut failed, MetadataLoadMode::Refresh)
|
||||
.await;
|
||||
assert!(failed.is_empty(), "refresh must succeed");
|
||||
expect_publish(before, "refresh loop");
|
||||
|
||||
// lazy load on another cold system.
|
||||
let lazy = BucketMetadataSys::new(ecstore);
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
let (_, loaded) = lazy.get_config(bucket).await.expect("lazy load should publish");
|
||||
assert!(loaded, "the lazy path must have gone to disk");
|
||||
expect_publish(before, "lazy load");
|
||||
|
||||
// Removal withdraws the config.
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
assert!(lazy.remove(bucket).await);
|
||||
let calls = odm_hook_calls(bucket);
|
||||
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
|
||||
assert_eq!(calls.last().unwrap(), &None);
|
||||
|
||||
// A corrupt payload is withdrawn, never published as a config.
|
||||
let mut corrupt = BucketMetadata::new(bucket);
|
||||
corrupt.on_demand_migration_config_json = b"not-json".to_vec();
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
|
||||
let calls = odm_hook_calls(bucket);
|
||||
assert_eq!(calls.len(), before + 1);
|
||||
assert_eq!(calls.last().unwrap(), &None, "unreadable config must publish absence");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_wait_exits_when_cancelled() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
@@ -26,9 +26,12 @@ mod metadata_test;
|
||||
pub mod migration;
|
||||
mod msgp_decode;
|
||||
pub mod object_lock;
|
||||
pub mod on_demand_migration;
|
||||
pub mod policy_sys;
|
||||
pub mod quota;
|
||||
pub mod remote_s3_client;
|
||||
pub mod replication;
|
||||
pub mod sealed_credentials;
|
||||
pub mod tagging;
|
||||
pub mod target;
|
||||
pub mod utils;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
// 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.
|
||||
|
||||
//! Per-bucket three-state circuit breaker protecting an on-demand migration
|
||||
//! source (rustfs/backlog#2152).
|
||||
//!
|
||||
//! `Closed` lets every request through and counts consecutive failures
|
||||
//! inside a sliding window; reaching the threshold opens the breaker. `Open`
|
||||
//! rejects everything until the open duration elapses, then moves to
|
||||
//! `HalfOpen`, which admits a single probe: success closes the breaker,
|
||||
//! failure re-opens it. Timing uses `tokio::time::Instant` so tests can drive
|
||||
//! it with `tokio::time::pause`.
|
||||
//!
|
||||
//! Only transport-level failures count (`Throttled`, `Timeout`, `Connect`,
|
||||
//! `ServerError`). `NotFound` is a healthy answer and resets the failure
|
||||
//! streak; `AccessDenied`, `Unsupported` and `Other` are configuration or
|
||||
//! object problems that neither open nor close the breaker.
|
||||
|
||||
use super::source_client::SourceError;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// Consecutive counted failures that open the breaker.
|
||||
pub const BREAKER_FAILURE_THRESHOLD: u32 = 5;
|
||||
/// Failures further apart than this do not accumulate.
|
||||
pub const BREAKER_FAILURE_WINDOW: Duration = Duration::from_secs(30);
|
||||
/// How long an open breaker rejects before admitting a probe.
|
||||
pub const BREAKER_OPEN_DURATION: Duration = Duration::from_secs(30);
|
||||
/// Probes admitted while half-open.
|
||||
pub const BREAKER_HALF_OPEN_MAX_PROBES: u32 = 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BreakerState {
|
||||
Closed,
|
||||
Open,
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
impl BreakerState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
BreakerState::Closed => "closed",
|
||||
BreakerState::Open => "open",
|
||||
BreakerState::HalfOpen => "half_open",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A state change the caller may want to log.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct BreakerTransition {
|
||||
pub from: BreakerState,
|
||||
pub to: BreakerState,
|
||||
}
|
||||
|
||||
/// How a source result is scored by the breaker.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BreakerVerdict {
|
||||
/// Resets the failure streak; closes a half-open breaker.
|
||||
Success,
|
||||
/// Counts toward the threshold; re-opens a half-open breaker.
|
||||
Failure,
|
||||
/// Leaves the breaker untouched.
|
||||
Neutral,
|
||||
}
|
||||
|
||||
impl BreakerVerdict {
|
||||
/// `None` is a successful source call.
|
||||
pub fn for_result(error: Option<&SourceError>) -> Self {
|
||||
match error {
|
||||
None | Some(SourceError::NotFound) => BreakerVerdict::Success,
|
||||
Some(SourceError::Throttled | SourceError::Timeout | SourceError::Connect(_) | SourceError::ServerError(_)) => {
|
||||
BreakerVerdict::Failure
|
||||
}
|
||||
Some(
|
||||
SourceError::AccessDenied
|
||||
| SourceError::Unsupported(_)
|
||||
| SourceError::InvalidPagination(_)
|
||||
| SourceError::Other(_),
|
||||
) => BreakerVerdict::Neutral,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
state: BreakerState,
|
||||
consecutive_failures: u32,
|
||||
last_failure_at: Option<Instant>,
|
||||
opened_at: Option<Instant>,
|
||||
half_open_probes: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Breaker {
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
impl Default for Breaker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Breaker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(Inner {
|
||||
state: BreakerState::Closed,
|
||||
consecutive_failures: 0,
|
||||
last_failure_at: None,
|
||||
opened_at: None,
|
||||
half_open_probes: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Current state after applying the open-duration timeout.
|
||||
pub fn state(&self) -> BreakerState {
|
||||
let mut inner = self.inner.lock();
|
||||
Self::advance(&mut inner, Instant::now());
|
||||
inner.state
|
||||
}
|
||||
|
||||
/// Whether a request may reach the source right now. Consumes the
|
||||
/// half-open probe budget when it grants one.
|
||||
pub fn allow_request(&self) -> bool {
|
||||
let mut inner = self.inner.lock();
|
||||
Self::advance(&mut inner, Instant::now());
|
||||
match inner.state {
|
||||
BreakerState::Closed => true,
|
||||
BreakerState::Open => false,
|
||||
BreakerState::HalfOpen => {
|
||||
if inner.half_open_probes < BREAKER_HALF_OPEN_MAX_PROBES {
|
||||
inner.half_open_probes += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scores a source result; returns the transition it caused, if any.
|
||||
pub fn record(&self, verdict: BreakerVerdict) -> Option<BreakerTransition> {
|
||||
match verdict {
|
||||
BreakerVerdict::Success => self.record_success(),
|
||||
BreakerVerdict::Failure => self.record_failure(),
|
||||
BreakerVerdict::Neutral => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_success(&self) -> Option<BreakerTransition> {
|
||||
let mut inner = self.inner.lock();
|
||||
let now = Instant::now();
|
||||
Self::advance(&mut inner, now);
|
||||
inner.consecutive_failures = 0;
|
||||
inner.last_failure_at = None;
|
||||
match inner.state {
|
||||
BreakerState::Closed => None,
|
||||
// A success while open can only come from a request admitted
|
||||
// before the breaker opened; it says nothing about recovery.
|
||||
BreakerState::Open => None,
|
||||
BreakerState::HalfOpen => Some(Self::transition(&mut inner, BreakerState::Closed, now)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failure(&self) -> Option<BreakerTransition> {
|
||||
let mut inner = self.inner.lock();
|
||||
let now = Instant::now();
|
||||
Self::advance(&mut inner, now);
|
||||
match inner.state {
|
||||
BreakerState::Closed => {
|
||||
let within_window = inner
|
||||
.last_failure_at
|
||||
.is_some_and(|last| now.saturating_duration_since(last) <= BREAKER_FAILURE_WINDOW);
|
||||
inner.consecutive_failures = if within_window { inner.consecutive_failures + 1 } else { 1 };
|
||||
inner.last_failure_at = Some(now);
|
||||
if inner.consecutive_failures >= BREAKER_FAILURE_THRESHOLD {
|
||||
Some(Self::transition(&mut inner, BreakerState::Open, now))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
BreakerState::Open => None,
|
||||
BreakerState::HalfOpen => Some(Self::transition(&mut inner, BreakerState::Open, now)),
|
||||
}
|
||||
}
|
||||
|
||||
fn advance(inner: &mut Inner, now: Instant) {
|
||||
if inner.state == BreakerState::Open
|
||||
&& inner
|
||||
.opened_at
|
||||
.is_some_and(|opened| now.saturating_duration_since(opened) >= BREAKER_OPEN_DURATION)
|
||||
{
|
||||
Self::transition(inner, BreakerState::HalfOpen, now);
|
||||
}
|
||||
}
|
||||
|
||||
fn transition(inner: &mut Inner, to: BreakerState, now: Instant) -> BreakerTransition {
|
||||
let from = inner.state;
|
||||
inner.state = to;
|
||||
match to {
|
||||
BreakerState::Open => {
|
||||
inner.opened_at = Some(now);
|
||||
inner.half_open_probes = 0;
|
||||
}
|
||||
BreakerState::HalfOpen => {
|
||||
inner.half_open_probes = 0;
|
||||
}
|
||||
BreakerState::Closed => {
|
||||
inner.opened_at = None;
|
||||
inner.half_open_probes = 0;
|
||||
inner.consecutive_failures = 0;
|
||||
inner.last_failure_at = None;
|
||||
}
|
||||
}
|
||||
BreakerTransition { from, to }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn server_error() -> SourceError {
|
||||
SourceError::ServerError(503)
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn five_failures_open_then_half_open_after_timeout() {
|
||||
let breaker = Breaker::new();
|
||||
for i in 0..BREAKER_FAILURE_THRESHOLD - 1 {
|
||||
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&server_error()))), None, "failure {i}");
|
||||
assert_eq!(breaker.state(), BreakerState::Closed);
|
||||
}
|
||||
assert_eq!(
|
||||
breaker.record(BreakerVerdict::for_result(Some(&server_error()))),
|
||||
Some(BreakerTransition {
|
||||
from: BreakerState::Closed,
|
||||
to: BreakerState::Open
|
||||
})
|
||||
);
|
||||
assert_eq!(breaker.state(), BreakerState::Open);
|
||||
assert!(!breaker.allow_request());
|
||||
|
||||
tokio::time::advance(BREAKER_OPEN_DURATION - Duration::from_secs(1)).await;
|
||||
assert!(!breaker.allow_request());
|
||||
assert_eq!(breaker.state(), BreakerState::Open);
|
||||
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
assert_eq!(breaker.state(), BreakerState::HalfOpen);
|
||||
assert!(breaker.allow_request(), "one probe is admitted");
|
||||
assert!(!breaker.allow_request(), "second probe is rejected");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn half_open_probe_success_closes_and_failure_reopens() {
|
||||
let breaker = Breaker::new();
|
||||
for _ in 0..BREAKER_FAILURE_THRESHOLD {
|
||||
breaker.record_failure();
|
||||
}
|
||||
tokio::time::advance(BREAKER_OPEN_DURATION).await;
|
||||
assert!(breaker.allow_request());
|
||||
assert_eq!(
|
||||
breaker.record_failure(),
|
||||
Some(BreakerTransition {
|
||||
from: BreakerState::HalfOpen,
|
||||
to: BreakerState::Open
|
||||
})
|
||||
);
|
||||
assert!(!breaker.allow_request());
|
||||
|
||||
tokio::time::advance(BREAKER_OPEN_DURATION).await;
|
||||
assert!(breaker.allow_request());
|
||||
assert_eq!(
|
||||
breaker.record_success(),
|
||||
Some(BreakerTransition {
|
||||
from: BreakerState::HalfOpen,
|
||||
to: BreakerState::Closed
|
||||
})
|
||||
);
|
||||
assert_eq!(breaker.state(), BreakerState::Closed);
|
||||
assert!(breaker.allow_request());
|
||||
// The streak restarts from zero after closing.
|
||||
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
|
||||
assert_eq!(breaker.record_failure(), None);
|
||||
}
|
||||
assert_eq!(breaker.state(), BreakerState::Closed);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn failures_outside_window_do_not_accumulate() {
|
||||
let breaker = Breaker::new();
|
||||
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
|
||||
breaker.record_failure();
|
||||
}
|
||||
tokio::time::advance(BREAKER_FAILURE_WINDOW + Duration::from_secs(1)).await;
|
||||
assert_eq!(breaker.record_failure(), None, "stale streak restarts at one");
|
||||
assert_eq!(breaker.state(), BreakerState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_and_access_denied_do_not_count() {
|
||||
let breaker = Breaker::new();
|
||||
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
|
||||
breaker.record(BreakerVerdict::for_result(Some(&server_error())));
|
||||
}
|
||||
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::AccessDenied))), None);
|
||||
assert_eq!(breaker.state(), BreakerState::Closed);
|
||||
// AccessDenied is neutral: the streak is still one short of opening.
|
||||
assert_eq!(
|
||||
breaker.record(BreakerVerdict::for_result(Some(&SourceError::Unsupported("sse-c".into())))),
|
||||
None
|
||||
);
|
||||
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::Other("x".into())))), None);
|
||||
// NotFound is a healthy answer and resets the streak entirely.
|
||||
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::NotFound))), None);
|
||||
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
|
||||
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::Timeout))), None);
|
||||
}
|
||||
assert_eq!(breaker.state(), BreakerState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdicts_cover_every_source_error_class() {
|
||||
assert_eq!(BreakerVerdict::for_result(None), BreakerVerdict::Success);
|
||||
assert_eq!(BreakerVerdict::for_result(Some(&SourceError::NotFound)), BreakerVerdict::Success);
|
||||
for failure in [
|
||||
SourceError::Throttled,
|
||||
SourceError::Timeout,
|
||||
SourceError::Connect("refused".into()),
|
||||
SourceError::ServerError(500),
|
||||
] {
|
||||
assert_eq!(BreakerVerdict::for_result(Some(&failure)), BreakerVerdict::Failure, "{failure:?}");
|
||||
}
|
||||
for neutral in [
|
||||
SourceError::AccessDenied,
|
||||
SourceError::Unsupported("sse-c".into()),
|
||||
SourceError::Other("x".into()),
|
||||
] {
|
||||
assert_eq!(BreakerVerdict::for_result(Some(&neutral)), BreakerVerdict::Neutral, "{neutral:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_labels_are_stable() {
|
||||
assert_eq!(BreakerState::Closed.as_str(), "closed");
|
||||
assert_eq!(BreakerState::Open.as_str(), "open");
|
||||
assert_eq!(BreakerState::HalfOpen.as_str(), "half_open");
|
||||
assert_eq!(serde_json::to_string(&BreakerState::HalfOpen).unwrap(), "\"half_open\"");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
// 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.
|
||||
|
||||
//! On-Demand Migration (ODM): a bucket can name an external S3-compatible
|
||||
//! source bucket; GET misses are served from that source and backfilled
|
||||
//! locally. This module owns the bucket-level configuration model
|
||||
//! (`on-demand-migration.json` in the bucket metadata file), the source
|
||||
//! client, and the per-node runtime (`sys`) that turns configs into live
|
||||
//! clients guarded by a breaker, a negative cache, singleflight and a pull
|
||||
//! concurrency limit (rustfs/backlog#2147).
|
||||
|
||||
pub mod backfill;
|
||||
pub mod breaker;
|
||||
pub mod config;
|
||||
pub mod list_through;
|
||||
pub mod negative_cache;
|
||||
pub mod pull;
|
||||
pub mod source_client;
|
||||
pub mod stats;
|
||||
pub mod sys;
|
||||
|
||||
pub use breaker::{
|
||||
BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION, Breaker,
|
||||
BreakerState, BreakerTransition, BreakerVerdict,
|
||||
};
|
||||
pub use config::{
|
||||
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
|
||||
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy, SourceConfig,
|
||||
SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
|
||||
};
|
||||
pub use list_through::{
|
||||
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
|
||||
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
|
||||
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
|
||||
};
|
||||
pub use negative_cache::{NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache};
|
||||
pub use pull::{
|
||||
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS, PullCompletion,
|
||||
PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody, WriteBackError,
|
||||
WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with, idle_guarded_body,
|
||||
};
|
||||
pub use stats::{
|
||||
GaugeGuard, LastSourceError, LatencyBucketSnapshot, OdmOp, OdmOutcome, OdmStats, OdmStatsSnapshot, PullFailureReason,
|
||||
PullPath, SOURCE_LATENCY_BUCKET_BOUNDS_MS, SourceLatencySnapshot,
|
||||
};
|
||||
pub use sys::{
|
||||
ApplyOutcome, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, OdmBucketSnapshot, OdmLookup, OdmStateError,
|
||||
OnDemandMigrationSys, PullError, PullFollower, PullLeader, PullOutcome, PullResult, PullSlot, source_client_spec,
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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.
|
||||
|
||||
//! Per-bucket cache of keys the source answered 404 for
|
||||
//! (rustfs/backlog#2152). A hit short-circuits the source lookup for
|
||||
//! `policy.negative_cache_ttl_secs`; a TTL of zero disables the cache.
|
||||
//!
|
||||
//! Entries are never invalidated on a local PUT: once the object exists
|
||||
//! locally the handler never consults ODM for it, so a stale negative entry
|
||||
//! is harmless.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Upper bound on remembered keys per bucket; LRU eviction beyond it.
|
||||
pub const NEGATIVE_CACHE_MAX_ENTRIES: u64 = 100_000;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NegativeCache {
|
||||
cache: Option<moka::sync::Cache<String, ()>>,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl NegativeCache {
|
||||
/// `ttl == 0` builds a disabled cache that never records anything.
|
||||
pub fn new(ttl: Duration) -> Self {
|
||||
Self::with_capacity(ttl, NEGATIVE_CACHE_MAX_ENTRIES)
|
||||
}
|
||||
|
||||
pub fn with_capacity(ttl: Duration, max_entries: u64) -> Self {
|
||||
let cache = (!ttl.is_zero()).then(|| {
|
||||
moka::sync::Cache::builder()
|
||||
.max_capacity(max_entries)
|
||||
.time_to_live(ttl)
|
||||
.build()
|
||||
});
|
||||
Self { cache, ttl }
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.cache.is_some()
|
||||
}
|
||||
|
||||
pub fn ttl(&self) -> Duration {
|
||||
self.ttl
|
||||
}
|
||||
|
||||
/// Whether `key` is currently remembered as absent on the source.
|
||||
pub fn contains(&self, key: &str) -> bool {
|
||||
self.cache.as_ref().is_some_and(|cache| cache.get(key).is_some())
|
||||
}
|
||||
|
||||
/// Remembers `key` as absent; no-op when disabled.
|
||||
pub fn insert(&self, key: &str) {
|
||||
if let Some(cache) = &self.cache {
|
||||
cache.insert(key.to_string(), ());
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets `key` (e.g. after an admin-triggered backfill found it).
|
||||
pub fn remove(&self, key: &str) {
|
||||
if let Some(cache) = &self.cache {
|
||||
cache.invalidate(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximate live entry count, for status snapshots only.
|
||||
pub fn len(&self) -> u64 {
|
||||
self.cache.as_ref().map_or(0, |cache| {
|
||||
cache.run_pending_tasks();
|
||||
cache.entry_count()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn entry_expires_after_ttl() {
|
||||
let cache = NegativeCache::new(Duration::from_millis(80));
|
||||
assert!(cache.is_enabled());
|
||||
cache.insert("a/x");
|
||||
assert!(cache.contains("a/x"));
|
||||
assert!(!cache.contains("a/y"));
|
||||
std::thread::sleep(Duration::from_millis(160));
|
||||
assert!(!cache.contains("a/x"), "entry must expire after the TTL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_ttl_disables_the_cache() {
|
||||
let cache = NegativeCache::new(Duration::ZERO);
|
||||
assert!(!cache.is_enabled());
|
||||
cache.insert("a/x");
|
||||
assert!(!cache.contains("a/x"));
|
||||
assert!(cache.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_forgets_a_key() {
|
||||
let cache = NegativeCache::new(Duration::from_secs(30));
|
||||
cache.insert("a/x");
|
||||
cache.remove("a/x");
|
||||
assert!(!cache.contains("a/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_bounds_entries() {
|
||||
let cache = NegativeCache::with_capacity(Duration::from_secs(30), 4);
|
||||
for i in 0..64 {
|
||||
cache.insert(&format!("k{i}"));
|
||||
}
|
||||
assert!(cache.len() <= 4, "len {} exceeds capacity", cache.len());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,531 @@
|
||||
// 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.
|
||||
|
||||
//! Per-bucket on-demand migration counters (rustfs/backlog#2152).
|
||||
//!
|
||||
//! `OdmStats` is lock-free and survives config rebuilds; `snapshot()` turns
|
||||
//! it into the serializable `OdmStatsSnapshot` that the metrics collector
|
||||
//! and the admin status route (ODM-10/14/15) consume. Field names and label
|
||||
//! values are a wire contract: the golden JSON test below pins them.
|
||||
|
||||
use super::breaker::BreakerState;
|
||||
use super::source_client::SourceError;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// Request operations that can enter ODM.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OdmOp {
|
||||
Get,
|
||||
Head,
|
||||
}
|
||||
|
||||
impl OdmOp {
|
||||
pub const ALL: [OdmOp; 2] = [OdmOp::Get, OdmOp::Head];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
OdmOp::Get => "get",
|
||||
OdmOp::Head => "head",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a request that entered ODM ended. `local_hit` is deliberately absent:
|
||||
/// requests served locally never reach the runtime.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OdmOutcome {
|
||||
SourceHit,
|
||||
SourceMiss,
|
||||
SourceError,
|
||||
BreakerOpen,
|
||||
NegativeCached,
|
||||
Filtered,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl OdmOutcome {
|
||||
pub const ALL: [OdmOutcome; 7] = [
|
||||
OdmOutcome::SourceHit,
|
||||
OdmOutcome::SourceMiss,
|
||||
OdmOutcome::SourceError,
|
||||
OdmOutcome::BreakerOpen,
|
||||
OdmOutcome::NegativeCached,
|
||||
OdmOutcome::Filtered,
|
||||
OdmOutcome::Unsupported,
|
||||
];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
OdmOutcome::SourceHit => "source_hit",
|
||||
OdmOutcome::SourceMiss => "source_miss",
|
||||
OdmOutcome::SourceError => "source_error",
|
||||
OdmOutcome::BreakerOpen => "breaker_open",
|
||||
OdmOutcome::NegativeCached => "negative_cached",
|
||||
OdmOutcome::Filtered => "filtered",
|
||||
OdmOutcome::Unsupported => "unsupported",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which pipeline stored a pulled object locally.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PullPath {
|
||||
/// Streamed to the client and written locally in one pass.
|
||||
Inline,
|
||||
/// Pulled by a background task after a partial/large read.
|
||||
Background,
|
||||
/// Pulled by the backfill job.
|
||||
Backfill,
|
||||
}
|
||||
|
||||
impl PullPath {
|
||||
pub const ALL: [PullPath; 3] = [PullPath::Inline, PullPath::Background, PullPath::Backfill];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PullPath::Inline => "inline",
|
||||
PullPath::Background => "background",
|
||||
PullPath::Backfill => "backfill",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a pull did not produce a local object.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PullFailureReason {
|
||||
SourceNotFound,
|
||||
SourceAccessDenied,
|
||||
SourceThrottled,
|
||||
SourceTimeout,
|
||||
SourceConnect,
|
||||
SourceServerError,
|
||||
SourceUnsupported,
|
||||
SourceOther,
|
||||
/// Source bytes did not match the ETag advertised by HEAD/GET.
|
||||
EtagMismatch,
|
||||
/// The local write (internal PUT) failed.
|
||||
LocalWrite,
|
||||
/// The bucket quota rejected the write-back.
|
||||
Quota,
|
||||
/// The bucket state was removed or the process is shutting down.
|
||||
Canceled,
|
||||
/// The background pull queue was full.
|
||||
QueueFull,
|
||||
}
|
||||
|
||||
impl PullFailureReason {
|
||||
pub const ALL: [PullFailureReason; 13] = [
|
||||
PullFailureReason::SourceNotFound,
|
||||
PullFailureReason::SourceAccessDenied,
|
||||
PullFailureReason::SourceThrottled,
|
||||
PullFailureReason::SourceTimeout,
|
||||
PullFailureReason::SourceConnect,
|
||||
PullFailureReason::SourceServerError,
|
||||
PullFailureReason::SourceUnsupported,
|
||||
PullFailureReason::SourceOther,
|
||||
PullFailureReason::EtagMismatch,
|
||||
PullFailureReason::LocalWrite,
|
||||
PullFailureReason::Quota,
|
||||
PullFailureReason::Canceled,
|
||||
PullFailureReason::QueueFull,
|
||||
];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PullFailureReason::SourceNotFound => "source_not_found",
|
||||
PullFailureReason::SourceAccessDenied => "source_access_denied",
|
||||
PullFailureReason::SourceThrottled => "source_throttled",
|
||||
PullFailureReason::SourceTimeout => "source_timeout",
|
||||
PullFailureReason::SourceConnect => "source_connect",
|
||||
PullFailureReason::SourceServerError => "source_server_error",
|
||||
PullFailureReason::SourceUnsupported => "source_unsupported",
|
||||
PullFailureReason::SourceOther => "source_other",
|
||||
PullFailureReason::EtagMismatch => "etag_mismatch",
|
||||
PullFailureReason::LocalWrite => "local_write",
|
||||
PullFailureReason::Quota => "quota",
|
||||
PullFailureReason::Canceled => "canceled",
|
||||
PullFailureReason::QueueFull => "queue_full",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SourceError> for PullFailureReason {
|
||||
fn from(err: &SourceError) -> Self {
|
||||
match err {
|
||||
SourceError::NotFound => PullFailureReason::SourceNotFound,
|
||||
SourceError::AccessDenied => PullFailureReason::SourceAccessDenied,
|
||||
SourceError::Throttled => PullFailureReason::SourceThrottled,
|
||||
SourceError::Timeout => PullFailureReason::SourceTimeout,
|
||||
SourceError::Connect(_) => PullFailureReason::SourceConnect,
|
||||
SourceError::ServerError(_) => PullFailureReason::SourceServerError,
|
||||
SourceError::Unsupported(_) => PullFailureReason::SourceUnsupported,
|
||||
SourceError::InvalidPagination(_) | SourceError::Other(_) => PullFailureReason::SourceOther,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bounds (milliseconds) of the source latency histogram buckets; the
|
||||
/// implicit last bucket is unbounded. Roughly logarithmic from 5 ms to 60 s.
|
||||
pub const SOURCE_LATENCY_BUCKET_BOUNDS_MS: [u64; 14] = [
|
||||
5, 10, 20, 50, 100, 200, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 60_000,
|
||||
];
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct LatencyHistogram {
|
||||
/// One counter per bound plus one for the overflow bucket.
|
||||
buckets: [AtomicU64; SOURCE_LATENCY_BUCKET_BOUNDS_MS.len() + 1],
|
||||
count: AtomicU64,
|
||||
sum_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl LatencyHistogram {
|
||||
fn observe(&self, latency: Duration) {
|
||||
let ms = u64::try_from(latency.as_millis()).unwrap_or(u64::MAX);
|
||||
let index = SOURCE_LATENCY_BUCKET_BOUNDS_MS
|
||||
.iter()
|
||||
.position(|bound| ms <= *bound)
|
||||
.unwrap_or(SOURCE_LATENCY_BUCKET_BOUNDS_MS.len());
|
||||
self.buckets[index].fetch_add(1, Ordering::Relaxed);
|
||||
self.count.fetch_add(1, Ordering::Relaxed);
|
||||
self.sum_ms.fetch_add(ms, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> SourceLatencySnapshot {
|
||||
let mut cumulative = 0;
|
||||
let buckets = SOURCE_LATENCY_BUCKET_BOUNDS_MS
|
||||
.iter()
|
||||
.zip(self.buckets.iter())
|
||||
.map(|(bound, counter)| {
|
||||
cumulative += counter.load(Ordering::Relaxed);
|
||||
LatencyBucketSnapshot {
|
||||
le_ms: *bound,
|
||||
count: cumulative,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
SourceLatencySnapshot {
|
||||
buckets,
|
||||
count: self.count.load(Ordering::Relaxed),
|
||||
sum_ms: self.sum_ms.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most recent source failure, kept for operators: class only, never the
|
||||
/// key or the message (which may echo attacker-controlled input).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LastSourceError {
|
||||
pub class: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OdmStats {
|
||||
requests_total: [[AtomicU64; OdmOutcome::ALL.len()]; OdmOp::ALL.len()],
|
||||
pulled_bytes_total: AtomicU64,
|
||||
pulled_objects_total: [AtomicU64; PullPath::ALL.len()],
|
||||
pull_failures_total: [AtomicU64; PullFailureReason::ALL.len()],
|
||||
inflight_pulls: AtomicU64,
|
||||
queue_depth: AtomicU64,
|
||||
source_latency: LatencyHistogram,
|
||||
last_source_error: Mutex<Option<LastSourceError>>,
|
||||
}
|
||||
|
||||
impl OdmStats {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn record_request(&self, op: OdmOp, outcome: OdmOutcome) {
|
||||
self.requests_total[op as usize][outcome as usize].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_pulled_bytes(&self, bytes: u64) {
|
||||
self.pulled_bytes_total.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_pulled_object(&self, path: PullPath) {
|
||||
self.pulled_objects_total[path as usize].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_pull_failure(&self, reason: PullFailureReason) {
|
||||
self.pull_failures_total[reason as usize].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_source_latency(&self, latency: Duration) {
|
||||
self.source_latency.observe(latency);
|
||||
}
|
||||
|
||||
pub fn record_source_error(&self, err: &SourceError) {
|
||||
self.record_source_error_at(err, OffsetDateTime::now_utc());
|
||||
}
|
||||
|
||||
pub fn record_source_error_at(&self, err: &SourceError, at: OffsetDateTime) {
|
||||
*self.last_source_error.lock() = Some(LastSourceError {
|
||||
class: err.class_label().to_string(),
|
||||
at,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn last_source_error(&self) -> Option<LastSourceError> {
|
||||
self.last_source_error.lock().clone()
|
||||
}
|
||||
|
||||
pub fn inflight_pulls(&self) -> u64 {
|
||||
self.inflight_pulls.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn queue_depth(&self) -> u64 {
|
||||
self.queue_depth.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// RAII increment of `inflight_pulls`.
|
||||
pub fn inflight_guard(self: &Arc<Self>) -> GaugeGuard {
|
||||
GaugeGuard::new(Arc::clone(self), OdmGauge::InflightPulls)
|
||||
}
|
||||
|
||||
/// RAII increment of `queue_depth`.
|
||||
pub fn queue_guard(self: &Arc<Self>) -> GaugeGuard {
|
||||
GaugeGuard::new(Arc::clone(self), OdmGauge::QueueDepth)
|
||||
}
|
||||
|
||||
fn gauge(&self, gauge: OdmGauge) -> &AtomicU64 {
|
||||
match gauge {
|
||||
OdmGauge::InflightPulls => &self.inflight_pulls,
|
||||
OdmGauge::QueueDepth => &self.queue_depth,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only, side-effect-free copy of every counter. The breaker lives
|
||||
/// next to the stats in the bucket state; its state is passed in so the
|
||||
/// snapshot stays a single document.
|
||||
pub fn snapshot(&self, breaker_state: BreakerState) -> OdmStatsSnapshot {
|
||||
let mut requests_total = BTreeMap::new();
|
||||
for op in OdmOp::ALL {
|
||||
let mut by_outcome = BTreeMap::new();
|
||||
for outcome in OdmOutcome::ALL {
|
||||
by_outcome.insert(
|
||||
outcome.as_str().to_string(),
|
||||
self.requests_total[op as usize][outcome as usize].load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
requests_total.insert(op.as_str().to_string(), by_outcome);
|
||||
}
|
||||
let pulled_objects_total = PullPath::ALL
|
||||
.iter()
|
||||
.map(|path| {
|
||||
(
|
||||
path.as_str().to_string(),
|
||||
self.pulled_objects_total[*path as usize].load(Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let pull_failures_total = PullFailureReason::ALL
|
||||
.iter()
|
||||
.map(|reason| {
|
||||
(
|
||||
reason.as_str().to_string(),
|
||||
self.pull_failures_total[*reason as usize].load(Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
OdmStatsSnapshot {
|
||||
requests_total,
|
||||
pulled_bytes_total: self.pulled_bytes_total.load(Ordering::Relaxed),
|
||||
pulled_objects_total,
|
||||
pull_failures_total,
|
||||
inflight_pulls: self.inflight_pulls(),
|
||||
queue_depth: self.queue_depth(),
|
||||
source_latency: self.source_latency.snapshot(),
|
||||
last_source_error: self.last_source_error(),
|
||||
breaker_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum OdmGauge {
|
||||
InflightPulls,
|
||||
QueueDepth,
|
||||
}
|
||||
|
||||
/// Increments a gauge on creation and decrements it on drop. Owns its
|
||||
/// `OdmStats` so it can live inside the pull slot handed to callers.
|
||||
#[derive(Debug)]
|
||||
pub struct GaugeGuard {
|
||||
stats: Arc<OdmStats>,
|
||||
gauge: OdmGauge,
|
||||
}
|
||||
|
||||
impl GaugeGuard {
|
||||
fn new(stats: Arc<OdmStats>, gauge: OdmGauge) -> Self {
|
||||
stats.gauge(gauge).fetch_add(1, Ordering::Relaxed);
|
||||
Self { stats, gauge }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GaugeGuard {
|
||||
fn drop(&mut self) {
|
||||
self.stats.gauge(self.gauge).fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LatencyBucketSnapshot {
|
||||
/// Upper bound of the bucket in milliseconds.
|
||||
pub le_ms: u64,
|
||||
/// Cumulative observations at or below `le_ms`.
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourceLatencySnapshot {
|
||||
pub buckets: Vec<LatencyBucketSnapshot>,
|
||||
/// Total observations, including those above the last bound.
|
||||
pub count: u64,
|
||||
pub sum_ms: u64,
|
||||
}
|
||||
|
||||
/// Serializable copy of [`OdmStats`]. Every key is snake_case and every
|
||||
/// label set is fixed, so consumers can rely on the document shape.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OdmStatsSnapshot {
|
||||
/// `op -> outcome -> count`.
|
||||
pub requests_total: BTreeMap<String, BTreeMap<String, u64>>,
|
||||
pub pulled_bytes_total: u64,
|
||||
/// `path -> count`.
|
||||
pub pulled_objects_total: BTreeMap<String, u64>,
|
||||
/// `reason -> count`.
|
||||
pub pull_failures_total: BTreeMap<String, u64>,
|
||||
pub inflight_pulls: u64,
|
||||
pub queue_depth: u64,
|
||||
pub source_latency: SourceLatencySnapshot,
|
||||
pub last_source_error: Option<LastSourceError>,
|
||||
pub breaker_state: BreakerState,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use time::macros::datetime;
|
||||
|
||||
#[test]
|
||||
fn snapshot_matches_golden_json() {
|
||||
let stats = Arc::new(OdmStats::new());
|
||||
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
|
||||
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
|
||||
stats.record_request(OdmOp::Head, OdmOutcome::NegativeCached);
|
||||
stats.record_pulled_bytes(4096);
|
||||
stats.record_pulled_object(PullPath::Inline);
|
||||
stats.record_pull_failure(PullFailureReason::from(&SourceError::Timeout));
|
||||
stats.record_source_latency(Duration::from_millis(3));
|
||||
stats.record_source_latency(Duration::from_millis(750));
|
||||
stats.record_source_latency(Duration::from_secs(90));
|
||||
stats.record_source_error_at(&SourceError::ServerError(502), datetime!(2026-09-02 10:00:00 UTC));
|
||||
let _inflight = stats.inflight_guard();
|
||||
let _queued = stats.queue_guard();
|
||||
|
||||
let snapshot = stats.snapshot(BreakerState::HalfOpen);
|
||||
let actual = serde_json::to_value(&snapshot).unwrap();
|
||||
let expected = json!({
|
||||
"requests_total": {
|
||||
"get": {
|
||||
"breaker_open": 0, "filtered": 0, "negative_cached": 0, "source_error": 0,
|
||||
"source_hit": 2, "source_miss": 0, "unsupported": 0
|
||||
},
|
||||
"head": {
|
||||
"breaker_open": 0, "filtered": 0, "negative_cached": 1, "source_error": 0,
|
||||
"source_hit": 0, "source_miss": 0, "unsupported": 0
|
||||
}
|
||||
},
|
||||
"pulled_bytes_total": 4096,
|
||||
"pulled_objects_total": { "backfill": 0, "background": 0, "inline": 1 },
|
||||
"pull_failures_total": {
|
||||
"canceled": 0, "etag_mismatch": 0, "local_write": 0, "queue_full": 0, "quota": 0,
|
||||
"source_access_denied": 0, "source_connect": 0, "source_not_found": 0, "source_other": 0,
|
||||
"source_server_error": 0, "source_throttled": 0, "source_timeout": 1, "source_unsupported": 0
|
||||
},
|
||||
"inflight_pulls": 1,
|
||||
"queue_depth": 1,
|
||||
"source_latency": {
|
||||
"buckets": [
|
||||
{ "le_ms": 5, "count": 1 }, { "le_ms": 10, "count": 1 }, { "le_ms": 20, "count": 1 },
|
||||
{ "le_ms": 50, "count": 1 }, { "le_ms": 100, "count": 1 }, { "le_ms": 200, "count": 1 },
|
||||
{ "le_ms": 500, "count": 1 }, { "le_ms": 1000, "count": 2 }, { "le_ms": 2000, "count": 2 },
|
||||
{ "le_ms": 5000, "count": 2 }, { "le_ms": 10000, "count": 2 }, { "le_ms": 20000, "count": 2 },
|
||||
{ "le_ms": 30000, "count": 2 }, { "le_ms": 60000, "count": 2 }
|
||||
],
|
||||
"count": 3,
|
||||
"sum_ms": 90753
|
||||
},
|
||||
"last_source_error": { "class": "server_error", "at": "2026-09-02T10:00:00Z" },
|
||||
"breaker_state": "half_open"
|
||||
});
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
let round_trip: OdmStatsSnapshot = serde_json::from_value(actual).unwrap();
|
||||
assert_eq!(round_trip, snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gauges_return_to_zero_when_guards_drop() {
|
||||
let stats = Arc::new(OdmStats::new());
|
||||
{
|
||||
let _a = stats.inflight_guard();
|
||||
let _b = stats.inflight_guard();
|
||||
let _c = stats.queue_guard();
|
||||
assert_eq!(stats.inflight_pulls(), 2);
|
||||
assert_eq!(stats.queue_depth(), 1);
|
||||
}
|
||||
assert_eq!(stats.inflight_pulls(), 0);
|
||||
assert_eq!(stats.queue_depth(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_failure_reason_covers_every_source_error_class() {
|
||||
let cases = [
|
||||
(SourceError::NotFound, PullFailureReason::SourceNotFound),
|
||||
(SourceError::AccessDenied, PullFailureReason::SourceAccessDenied),
|
||||
(SourceError::Throttled, PullFailureReason::SourceThrottled),
|
||||
(SourceError::Timeout, PullFailureReason::SourceTimeout),
|
||||
(SourceError::Connect("x".into()), PullFailureReason::SourceConnect),
|
||||
(SourceError::ServerError(500), PullFailureReason::SourceServerError),
|
||||
(SourceError::Unsupported("x".into()), PullFailureReason::SourceUnsupported),
|
||||
(SourceError::Other("x".into()), PullFailureReason::SourceOther),
|
||||
];
|
||||
for (err, reason) in cases {
|
||||
assert_eq!(PullFailureReason::from(&err), reason, "{err:?}");
|
||||
assert_eq!(serde_json::to_string(&reason).unwrap(), format!("\"{}\"", reason.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_lists_are_exhaustive_and_unique() {
|
||||
let outcomes: std::collections::BTreeSet<_> = OdmOutcome::ALL.iter().map(|o| o.as_str()).collect();
|
||||
assert_eq!(outcomes.len(), OdmOutcome::ALL.len());
|
||||
let reasons: std::collections::BTreeSet<_> = PullFailureReason::ALL.iter().map(|r| r.as_str()).collect();
|
||||
assert_eq!(reasons.len(), PullFailureReason::ALL.len());
|
||||
let paths: std::collections::BTreeSet<_> = PullPath::ALL.iter().map(|p| p.as_str()).collect();
|
||||
assert_eq!(paths.len(), PullPath::ALL.len());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
|
||||
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
|
||||
use crate::data_usage::get_bucket_usage_memory;
|
||||
use rustfs_config::QUOTA_CONFIG_FILE;
|
||||
use rustfs_scanner_contracts::metrics::Metric;
|
||||
use rustfs_scanner_metrics::metrics::Metric;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use time::OffsetDateTime;
|
||||
@@ -120,9 +120,9 @@ impl QuotaChecker {
|
||||
|
||||
let duration = start_time.elapsed();
|
||||
// inc_time is now a plain fn (not async) — no .await needed.
|
||||
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
|
||||
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
|
||||
if !allowed {
|
||||
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
|
||||
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
@@ -185,7 +185,7 @@ impl QuotaChecker {
|
||||
.await
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
|
||||
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
Ok(updated_at)
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ impl QuotaChecker {
|
||||
}
|
||||
.map_err(QuotaError::StorageError)?;
|
||||
|
||||
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
|
||||
Ok(updated_at)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,12 +53,12 @@ pub use replication_config_boundary::{
|
||||
replication_target_arns, should_remove_replication_target, site_replication_rule_deployment_id,
|
||||
unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns,
|
||||
};
|
||||
pub(crate) use replication_filemeta_boundary::version_purge_statuses_map;
|
||||
pub use replication_filemeta_boundary::{
|
||||
MrfOpKind, MrfReplicateEntry, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState,
|
||||
ReplicationStatusType, ReplicationType, VersionPurgeStatusType, replication_state_to_filemeta,
|
||||
replication_status_to_filemeta, replication_statuses_map, version_purge_status_to_filemeta,
|
||||
};
|
||||
pub(crate) use replication_filemeta_boundary::{ReplicationGenerationSnapshot, version_purge_statuses_map};
|
||||
pub(crate) use replication_filemeta_boundary::{
|
||||
replication_state_from_filemeta, replication_status_from_filemeta, version_purge_status_from_filemeta,
|
||||
};
|
||||
@@ -88,4 +88,5 @@ pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
|
||||
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
|
||||
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
|
||||
pub use replication_target_boundary::SsecPassthroughCapability;
|
||||
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
|
||||
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user