mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 20:46:11 +00:00
Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 787ee626fd | |||
| 8553853761 | |||
| 3b62044485 | |||
| 6a26f1aae5 | |||
| 91fccdcac2 | |||
| 6448fa54c4 | |||
| 29f47fda75 | |||
| ee73203791 | |||
| 46a387dffe | |||
| c9c6bb7a24 | |||
| ad0c44dc63 | |||
| 2d6f9417ff | |||
| 638da7a2e3 | |||
| 7c7b48aaca | |||
| c40988dae7 | |||
| 481c1b7939 | |||
| 26d827f946 | |||
| 376cb037a0 | |||
| 64829c704d | |||
| 17ba30f648 | |||
| 21015cfac8 | |||
| c04cd089e9 | |||
| bffdf0809f | |||
| 19a69ee897 | |||
| 1499295393 | |||
| 4c4dcb6f5e | |||
| 672087ec0d | |||
| 9dbeae1b45 | |||
| 6018dd372f | |||
| d633a635ec | |||
| fbd30a6f43 | |||
| a32360198c | |||
| 05efc584b0 | |||
| a70c96d520 | |||
| b0f68f5d0a | |||
| df554439b0 | |||
| 3b404e56c0 | |||
| ff00872922 | |||
| 4d1ce9618a | |||
| a2bad0953f | |||
| 14c99a994c | |||
| f6c2a9bfe0 | |||
| 640d7e0e3c | |||
| 409ac3de66 | |||
| 2a63fcbea6 | |||
| f4049598e4 | |||
| 1651541d38 | |||
| 85849788af | |||
| 975983abdd | |||
| cf1c45eb91 | |||
| 536c283716 | |||
| a04ddc237e | |||
| 7de6ac82e1 | |||
| 7993e11058 | |||
| 37ead2f69c | |||
| 1b1e590df7 | |||
| 086ee8e48a | |||
| 22bff27aee | |||
| 8a20498705 | |||
| c3e6d90c6f | |||
| d2af8f073e | |||
| 2baba1bda7 | |||
| bf2ca9113f | |||
| f474ea30d4 | |||
| 002ac9544c | |||
| d74d970e24 | |||
| cc15eae479 | |||
| c130d00d4b | |||
| e49d9cdea2 | |||
| f5778d8b97 | |||
| 9da42a899a | |||
| 3d46ed312a | |||
| 3496277e7c | |||
| 0b72f39023 | |||
| 30a0937a7d | |||
| 5b962b6c58 | |||
| 0ee5408b94 | |||
| cb3100a252 | |||
| 3a4afe9b38 | |||
| 71859ff83c | |||
| cb72df269a | |||
| d7b7d1835f | |||
| b92392a04a | |||
| a40ec8a6f3 | |||
| c99efd9477 | |||
| 27d593fa35 | |||
| 0137d14064 | |||
| 1dddf357cd | |||
| 395ba797fc | |||
| 51893abfbf | |||
| 92c17af8e3 | |||
| f17f31a3df | |||
| 1a88870809 | |||
| 760c9d65be | |||
| 08283d1fdc | |||
| 6a323c3e91 | |||
| 1650f3c2a6 | |||
| 38611d2510 | |||
| 1a459d650f | |||
| 5aef1796cc | |||
| a8aaadb886 | |||
| 71f1dcf859 | |||
| d44244f60f | |||
| d3884ed3ea | |||
| 282d6d5efe | |||
| f0d865728c | |||
| 3df39ef4d7 | |||
| b0c73c1224 | |||
| 54c11ef28b | |||
| e99c41a9bf | |||
| fddecf0afe | |||
| 55fd73ed48 | |||
| 1ae80c41ec | |||
| 9fc9b5e69c | |||
| 61e0edce16 | |||
| 2b5c739343 | |||
| 5dca076efe | |||
| 07833379b4 | |||
| 6655272c90 | |||
| 0a5d4cef0e | |||
| 941fae61a0 | |||
| 03fa62cc7d | |||
| b59dea826f | |||
| 081a8b61d8 | |||
| 159dc13548 | |||
| bde4b78a9f | |||
| c861fe3a57 | |||
| e1608fbd9c | |||
| eb1b17802c | |||
| 112f70914d | |||
| ea9aa53fd8 | |||
| dd368f0f5b | |||
| 6d8606412e | |||
| 037354cec0 | |||
| 30ab919bb3 | |||
| 8fc1c9281e | |||
| 14cef91423 | |||
| c9acc33720 | |||
| 955d491174 | |||
| 1c4e9f1b65 | |||
| a9f01dbbdb | |||
| 35aefbb2a5 | |||
| 8fb335cf19 | |||
| 8f763fb1a2 | |||
| a6b5da64f2 | |||
| 1210428b6d | |||
| d5426f59ec | |||
| f54323b062 | |||
| cc5060ac20 | |||
| 188f380b3b | |||
| e2a921bc16 | |||
| 447f3c704b | |||
| d915f9565e | |||
| 55ad7508b9 | |||
| 33fd056000 | |||
| af2e9df821 | |||
| 0a92a7d98c | |||
| c589fd2439 | |||
| 3e5d4ebb09 | |||
| 3677871468 | |||
| cc1ec6b992 | |||
| 9e2545244c | |||
| f053862aad | |||
| e8a7f4bc4a | |||
| 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 |
@@ -0,0 +1,2 @@
|
|||||||
|
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
|
||||||
|
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=a881fd7d3f5cb94654221ca85b8b30cce1b95e608824a55a15339cbc294e6d34
|
sha256-darwin=53b05ac745905809d3828c6994bdd8ecf9d20b2b61a8a9d80fe15eb62f932193
|
||||||
sha256-linux=e9a8d64e73f627c4d26c236dbbba690c9ee03a9e26d42a4244515b4439365535
|
sha256-linux=7c892afa4b9d1591b46bd79c976b647109a277284fddb3b98edced4b0297eda2
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=a2542dc86bbff56b2177efc621785c56fa7e8d813b209b7d935e1e41a9f0ad15
|
sha256=5db88c6fec94d4f269c7d9cfc128bd2adc27b3d7021127e2fa0b1daccc5f900f
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "odm-source-contract-s3",
|
||||||
|
"suite": "rustfs",
|
||||||
|
"name": "on_demand_migration::source_client::tests::s3_backend_satisfies_the_shared_backend_contract"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "odm-source-contract-azure",
|
||||||
|
"suite": "rustfs",
|
||||||
|
"name": "on_demand_migration::azure::tests::azure_backend_satisfies_the_shared_backend_contract"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "odm-source-contract-gcs",
|
||||||
|
"suite": "rustfs",
|
||||||
|
"name": "on_demand_migration::gcs::tests::gcs_native_backend_satisfies_the_shared_backend_contract"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
.NOTPARALLEL: pre-commit pre-pr dev-check
|
.NOTPARALLEL: pre-commit pre-pr dev-check
|
||||||
|
|
||||||
.PHONY: setup-hooks
|
.PHONY: setup-hooks
|
||||||
setup-hooks: ## Set up git hooks
|
setup-hooks: ## Install the configured pre-commit hooks
|
||||||
@echo "🔧 Setting up git hooks..."
|
@echo "🔧 Setting up git hooks..."
|
||||||
chmod +x .git/hooks/pre-commit
|
pre-commit validate-config
|
||||||
|
pre-commit install
|
||||||
@echo "✅ Git hooks setup complete!"
|
@echo "✅ Git hooks setup complete!"
|
||||||
|
|
||||||
.PHONY: doc-paths-check
|
.PHONY: doc-paths-check
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_object_batch_bench_enhanced.sh
|
./scripts/test_object_batch_bench_enhanced.sh
|
||||||
./scripts/test_hotpath_warp_ab_gate.sh
|
./scripts/test_hotpath_warp_ab_gate.sh
|
||||||
./scripts/test_hotpath_warp_abba.sh
|
./scripts/test_hotpath_warp_abba.sh
|
||||||
|
./scripts/test_scanner_validation_harness.sh
|
||||||
./scripts/test_exact_1mib_handoff_abba.sh
|
./scripts/test_exact_1mib_handoff_abba.sh
|
||||||
./scripts/test_pinned_paired_abba_bench.sh
|
./scripts/test_pinned_paired_abba_bench.sh
|
||||||
./scripts/test_manual_transition_runbooks.sh
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
@@ -40,6 +41,8 @@ script-tests: ## Run shell script tests
|
|||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
$(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/check_scheduled_validation_freshness.py --self-test
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# Committed floor for the number of tests selected by the migration-critical
|
# Committed floor for the number of tests selected by the migration-critical
|
||||||
# CI gate (see scripts/check_migration_gate_count.sh, backlog#1153 infra-12).
|
# CI gate (see scripts/check_migration_gate_count.sh, backlog#1153 infra-12).
|
||||||
#
|
#
|
||||||
# The floor equals the exact count of rustfs-ecstore --lib tests matching the
|
# The floor equals the exact count of rustfs-ecstore --lib tests, with the
|
||||||
# gate filter (name substrings: data_movement, rebalance, decommission,
|
# test-util feature enabled, matching the gate filter (name substrings:
|
||||||
# source_cleanup, delete_marker) at the time this file was last updated.
|
# data_movement, rebalance, decommission, source_cleanup, delete_marker) at
|
||||||
|
# the time this file was last updated.
|
||||||
# CI fails if the selected count drops below this number, so renames or
|
# CI fails if the selected count drops below this number, so renames or
|
||||||
# removals that thin the gate must update this file in the same PR.
|
# removals that thin the gate must update this file in the same PR.
|
||||||
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
||||||
571
|
946
|
||||||
|
|||||||
@@ -183,6 +183,13 @@ test-group = 'e2e-reliability'
|
|||||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||||
test-group = 'e2e-inline-boundaries'
|
test-group = 'e2e-inline-boundaries'
|
||||||
|
|
||||||
|
# 4-node 4-drive distributed Actions suite: each case starts four rustfs
|
||||||
|
# processes and up to sixteen data directories. Serialize across nextest's
|
||||||
|
# process boundary so several 4x4 clusters never overlap.
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||||
|
test-group = 'e2e-cluster-nightly'
|
||||||
|
|
||||||
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
||||||
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
||||||
# one group.
|
# one group.
|
||||||
@@ -526,6 +533,27 @@ path = "junit.xml"
|
|||||||
filter = 'package(e2e_test)'
|
filter = 'package(e2e_test)'
|
||||||
test-group = 'e2e-cluster-nightly'
|
test-group = 'e2e-cluster-nightly'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# e2e-distributed profile — 4-node 4-disk Actions suite
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Storage-sensitive PR / nightly / dispatch lane owned by
|
||||||
|
# .github/workflows/e2e-distributed.yml.
|
||||||
|
# Each case starts four rustfs processes (and for site replication, two
|
||||||
|
# clusters). Upgrade cases also require RUSTFS_UPGRADE_SOURCE_BINARY.
|
||||||
|
# Serialized via e2e-cluster-nightly with no retries.
|
||||||
|
[profile.e2e-distributed]
|
||||||
|
default-filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||||
|
fail-fast = false
|
||||||
|
# Decommission / rebalance cases poll for up to 180s with little stdout.
|
||||||
|
slow-timeout = { period = "120s", terminate-after = 6 }
|
||||||
|
|
||||||
|
[profile.e2e-distributed.junit]
|
||||||
|
path = "junit.xml"
|
||||||
|
|
||||||
|
[[profile.e2e-distributed.overrides]]
|
||||||
|
filter = 'package(e2e_test)'
|
||||||
|
test-group = 'e2e-cluster-nightly'
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
|
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -586,6 +614,10 @@ path = "junit.xml"
|
|||||||
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
|
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
|
||||||
# excluded here because backlog#2213 promotes core heal rebuild coverage to
|
# excluded here because backlog#2213 promotes core heal rebuild coverage to
|
||||||
# this merge/main lane while retaining nightly coverage.
|
# this merge/main lane while retaining nightly coverage.
|
||||||
|
# * distributed:: — 4-node 4-disk Actions suite (S3, lock, versioning,
|
||||||
|
# replication, quota, observability, expand/decommission/rebalance, site
|
||||||
|
# replication, chaos, upgrade history/IAM). Owns [profile.e2e-distributed] and
|
||||||
|
# .github/workflows/e2e-distributed.yml.
|
||||||
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
|
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
|
||||||
# cases, which are meaningless without a source: they run in the dedicated
|
# 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
|
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
|
||||||
@@ -607,6 +639,7 @@ default-filter = """
|
|||||||
package(e2e_test)
|
package(e2e_test)
|
||||||
& !test(/^protocols::/)
|
& !test(/^protocols::/)
|
||||||
& !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(/^(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(/^distributed::/)
|
||||||
& !test(/^replication_extension_test::/)
|
& !test(/^replication_extension_test::/)
|
||||||
& !test(/^replication_target_matrix_test::/)
|
& !test(/^replication_target_matrix_test::/)
|
||||||
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"cases": {
|
||||||
|
"background-target-restart": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart",
|
||||||
|
"oracle": "background-target-restart.json",
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 4, "drives_per_node": 1},
|
||||||
|
"scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"release_pending": {
|
||||||
|
"G01": "W02/W04 complete root and quota authority coverage",
|
||||||
|
"G02": "W03 bounded checkpoint progress and independent version inventory",
|
||||||
|
"G03": "W17/W18 exact scoped ACK with durable publication and mixed peers",
|
||||||
|
"G04": "W03/W15/W16 crash at every cache/root/floor/intent boundary",
|
||||||
|
"G05": "W06/W07 per-object outcomes and bounded terminal retention",
|
||||||
|
"G06": "W06/W08/W23 concurrent status, legacy clients and truncation",
|
||||||
|
"G07": "W12/W13/W14 durable MRF responsibility at every commit boundary",
|
||||||
|
"G08": "W12/W13/W14 MRF capacity, disk-full and replica-loss matrix",
|
||||||
|
"G09": "W13/W18/W23 actual mixed-version reader/writer and rollback payloads",
|
||||||
|
"G10": "W05/W09/W10/W11 bounded scheduling and pressure recovery",
|
||||||
|
"G11": "W04/W19/W24 maintenance and complete producer coverage",
|
||||||
|
"G12": "W02/W15/W16 both quota paths during reset and settlement",
|
||||||
|
"G13": "W07/W14 quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
|
||||||
|
"G14": "W20/W21 same-window field evidence; 3x4 EC8+4 and multi-set/pool coverage",
|
||||||
|
"P1": "W20 measured cold-walk share and foreground latency/throughput",
|
||||||
|
"P2": "W20/W24 measured post-stop convergence and cold segment reuse",
|
||||||
|
"P3": "W20 measured two-hour pressure/heal capacity and recovery window",
|
||||||
|
"P4": "W20 measured MRF scale and replay cost with retained responsibility",
|
||||||
|
"R-E": "W03/W05 fixed-budget real process restart through enumeration and classification",
|
||||||
|
"R-D": "W07/W14 manager-to-event-to-ledger exact disposition, including grace",
|
||||||
|
"R-L": "W13/W14 legacy source conflicts, migration gaps and crash-safe source retirement"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
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: Run script contract tests
|
||||||
|
shell: bash
|
||||||
|
run: make script-tests
|
||||||
|
|
||||||
|
- name: Check test wiring
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python3 ./scripts/check_test_wiring.py --self-test
|
||||||
|
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||||
|
python3 ./scripts/test_security_workflow.py
|
||||||
|
python3 ./scripts/test_nightly_candidate.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
|
## Summary of Changes
|
||||||
<!--
|
<!--
|
||||||
Briefly explain what changed and why reviewers should accept it.
|
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.
|
||||||
Focus on behavior, compatibility, and review-relevant context.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
<!--
|
<!--
|
||||||
List the commands or checks you ran, for example:
|
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.
|
||||||
- `make pre-commit`
|
|
||||||
|
|
||||||
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
|
## Impact
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
||||||
|
{
|
||||||
|
"workflow": ".github/workflows/e2e-distributed.yml",
|
||||||
|
"max_age_hours": 36,
|
||||||
|
"never_ran_grace_until": "2026-09-18T00:00:00Z"
|
||||||
|
},
|
||||||
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
||||||
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
||||||
|
|||||||
@@ -12,24 +12,10 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
# Companion to ci.yml for required status checks.
|
# Reports the existing required checks for paths excluded by ci.yml.
|
||||||
#
|
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
||||||
# ci.yml skips docs-only pull requests via paths-ignore, but the branch ruleset
|
# action to keep validation coverage aligned. Keep this paths list in sync with
|
||||||
# requires a check named "Test and Lint" — without this workflow a docs-only PR
|
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
||||||
# 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.
|
|
||||||
|
|
||||||
name: Continuous Integration (docs only)
|
name: Continuous Integration (docs only)
|
||||||
|
|
||||||
@@ -59,19 +45,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
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:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -82,63 +55,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Run shared quick checks
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: ./.github/actions/quick-checks
|
||||||
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
|
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Test and Lint
|
||||||
|
|||||||
+21
-66
@@ -100,12 +100,7 @@ jobs:
|
|||||||
- name: Typos check with custom config file
|
- name: Typos check with custom config file
|
||||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||||
|
|
||||||
# Fast, compile-free checks that fail early so contributors get feedback in
|
# Fail early with compile-free checks shared with docs-only CI.
|
||||||
# ~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.
|
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -117,66 +112,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Run shared quick checks
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: ./.github/actions/quick-checks
|
||||||
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
|
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: 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' }}
|
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts/test-and-lint
|
mkdir -p artifacts/test-and-lint
|
||||||
|
rm -f target/nextest/ci/junit.xml
|
||||||
./scripts/ci/resource_sampler.sh start nextest
|
./scripts/ci/resource_sampler.sh start nextest
|
||||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||||
set +e
|
set +e
|
||||||
@@ -277,6 +215,12 @@ jobs:
|
|||||||
--status-level all --final-status-level all \
|
--status-level all --final-status-level all \
|
||||||
2>&1 | tee artifacts/test-and-lint/nextest.log
|
2>&1 | tee artifacts/test-and-lint/nextest.log
|
||||||
status=${PIPESTATUS[0]}
|
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 "command=cargo nextest run --profile ci --all --exclude e2e_test"
|
||||||
echo "exit_status=${status}"
|
echo "exit_status=${status}"
|
||||||
@@ -906,6 +850,17 @@ jobs:
|
|||||||
cache-save-if: 'false'
|
cache-save-if: 'false'
|
||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Install network fault-injection tools
|
||||||
|
run: |
|
||||||
|
sudo apt-get install -y iptables
|
||||||
|
sudo -n iptables --version
|
||||||
|
# The endpoint-blackhole heal scenario needs CAP_NET_ADMIN. Containerised
|
||||||
|
# runners can run iptables but not touch the rule set; the test then logs
|
||||||
|
# a skip instead of failing, so surface that here where it is visible.
|
||||||
|
if ! sudo -n iptables -w 5 -S OUTPUT >/dev/null 2>&1; then
|
||||||
|
echo "::warning::iptables cannot read the OUTPUT chain on this runner (no CAP_NET_ADMIN); the endpoint-blackhole heal scenario will be skipped"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -121,6 +121,22 @@ jobs:
|
|||||||
create_latest=false
|
create_latest=false
|
||||||
source_ref="$GITHUB_SHA"
|
source_ref="$GITHUB_SHA"
|
||||||
|
|
||||||
|
# Pre-GA policy: until the first stable (vX.Y.Z) tag exists, every
|
||||||
|
# prerelease (alpha/beta/rc) also moves `latest`, so users pulling
|
||||||
|
# `latest` get the newest test build. Once a stable tag is published
|
||||||
|
# this returns false and `latest` follows stable releases only.
|
||||||
|
prerelease_moves_latest() {
|
||||||
|
local stable_tags
|
||||||
|
stable_tags=$(git ls-remote --tags --refs origin 2>/dev/null \
|
||||||
|
| awk '{print $2}' \
|
||||||
|
| grep -E '^refs/tags/v?[0-9]+\.[0-9]+\.[0-9]+$' || true)
|
||||||
|
if [[ -z "$stable_tags" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "ℹ️ Stable release tag(s) already exist; prereleases no longer update latest"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||||
# Triggered by build workflow completion
|
# Triggered by build workflow completion
|
||||||
echo "🔗 Triggered by build workflow completion"
|
echo "🔗 Triggered by build workflow completion"
|
||||||
@@ -184,8 +200,8 @@ jobs:
|
|||||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
|
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||||
build_type="prerelease"
|
build_type="prerelease"
|
||||||
is_prerelease=true
|
is_prerelease=true
|
||||||
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta).
|
# Pre-GA policy: prereleases update latest until the first stable tag exists.
|
||||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then
|
if prerelease_moves_latest; then
|
||||||
create_latest=true
|
create_latest=true
|
||||||
echo "🧪 Building Docker image for prerelease: $version (creating latest tag)"
|
echo "🧪 Building Docker image for prerelease: $version (creating latest tag)"
|
||||||
else
|
else
|
||||||
@@ -243,8 +259,8 @@ jobs:
|
|||||||
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
||||||
build_type="prerelease"
|
build_type="prerelease"
|
||||||
is_prerelease=true
|
is_prerelease=true
|
||||||
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta).
|
# Pre-GA policy: prereleases update latest until the first stable tag exists.
|
||||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then
|
if prerelease_moves_latest; then
|
||||||
create_latest=true
|
create_latest=true
|
||||||
echo "🧪 Building with prerelease version: $input_version (creating latest tag)"
|
echo "🧪 Building with prerelease version: $input_version (creating latest tag)"
|
||||||
else
|
else
|
||||||
@@ -394,11 +410,13 @@ jobs:
|
|||||||
TAG_BASE="${VERSION}${VARIANT_SUFFIX}"
|
TAG_BASE="${VERSION}${VARIANT_SUFFIX}"
|
||||||
TAGS="${{ env.REGISTRY_DOCKERHUB }}:$TAG_BASE,${{ env.REGISTRY_GHCR }}:$TAG_BASE,${{ env.REGISTRY_QUAY }}:$TAG_BASE"
|
TAGS="${{ env.REGISTRY_DOCKERHUB }}:$TAG_BASE,${{ env.REGISTRY_GHCR }}:$TAG_BASE,${{ env.REGISTRY_QUAY }}:$TAG_BASE"
|
||||||
|
|
||||||
# Add channel tags for prereleases and latest for stable
|
# Add latest when requested (stable releases, and prereleases before GA)
|
||||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||||
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true.
|
|
||||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:latest${VARIANT_SUFFIX}"
|
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:latest${VARIANT_SUFFIX}"
|
||||||
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
fi
|
||||||
|
|
||||||
|
# Always add the channel tag for prereleases, independent of latest
|
||||||
|
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||||
# Prerelease channel tags (alpha, beta, rc)
|
# Prerelease channel tags (alpha, beta, rc)
|
||||||
if [[ "$VERSION" == *"alpha"* ]]; then
|
if [[ "$VERSION" == *"alpha"* ]]; then
|
||||||
CHANNEL="alpha"
|
CHANNEL="alpha"
|
||||||
@@ -555,7 +573,7 @@ jobs:
|
|||||||
"prerelease")
|
"prerelease")
|
||||||
echo "🧪 Prerelease Docker image has been built with ${VERSION} tags"
|
echo "🧪 Prerelease Docker image has been built with ${VERSION} tags"
|
||||||
echo "⚠️ This is a prerelease image - use with caution"
|
echo "⚠️ This is a prerelease image - use with caution"
|
||||||
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true.
|
# Prereleases move latest until the first stable tag exists (pre-GA policy).
|
||||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||||
echo "🏷️ Latest tag has been created for prerelease: $VERSION"
|
echo "🏷️ Latest tag has been created for prerelease: $VERSION"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# 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/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.
|
||||||
|
|
||||||
|
# 4-node 4-disk distributed e2e lane.
|
||||||
|
#
|
||||||
|
# Each selected test starts a real localhost cluster via
|
||||||
|
# `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the
|
||||||
|
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
|
||||||
|
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
|
||||||
|
# nightly runs, and manual dispatches all execute the same fail-closed suite.
|
||||||
|
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
|
||||||
|
#
|
||||||
|
# Isolated pool filesystems: expand/decommission/rebalance cases require
|
||||||
|
# independent `statfs` capacity. This job runs on GitHub-hosted
|
||||||
|
# `ubuntu-latest` because the self-hosted `sm-standard-4` ARC pods cannot
|
||||||
|
# create filesystems: `mount -o loop` fails with ENOENT (no
|
||||||
|
# `/dev/loop-control`), and `mount -t tmpfs` fails with "cannot mount tmpfs
|
||||||
|
# read-only" (no `CAP_SYS_ADMIN` in the initial namespace). The same reason
|
||||||
|
# `uring-integration` and `e2e-s3tests.yml` left that label. The prepare
|
||||||
|
# step mounts four 1 GiB tmpfs instances and exports `RUSTFS_E2E_POOL_ROOTS`.
|
||||||
|
|
||||||
|
name: e2e-distributed
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- ".config/nextest.toml"
|
||||||
|
- ".github/workflows/e2e-distributed.yml"
|
||||||
|
- "crates/audit/**"
|
||||||
|
- "crates/common/**"
|
||||||
|
- "crates/config/**"
|
||||||
|
- "crates/e2e_test/**"
|
||||||
|
- "crates/ecstore/**"
|
||||||
|
- "crates/filemeta/**"
|
||||||
|
- "crates/heal/**"
|
||||||
|
- "crates/iam/**"
|
||||||
|
- "crates/lock/**"
|
||||||
|
- "crates/madmin/**"
|
||||||
|
- "crates/notify/**"
|
||||||
|
- "crates/replication/**"
|
||||||
|
- "crates/s3-client/**"
|
||||||
|
- "crates/s3-ops/**"
|
||||||
|
- "crates/s3-types/**"
|
||||||
|
- "crates/scanner/**"
|
||||||
|
- "crates/storage-api/**"
|
||||||
|
- "crates/utils/**"
|
||||||
|
- "rustfs/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
filter:
|
||||||
|
description: "Optional nextest -E filter (default: the whole e2e-distributed profile)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
schedule:
|
||||||
|
# 05:53 UTC nightly — clear of e2e-nightly (04:29) and ODM interop (05:23).
|
||||||
|
- cron: "53 5 * * *"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
distributed:
|
||||||
|
name: Distributed 4-node 4-disk e2e
|
||||||
|
# GitHub-hosted VM: loop and tmpfs mounts work here. sm-standard-4 is an
|
||||||
|
# ARC pod and rejects both (`mount -o loop` ENOENT, tmpfs "read-only").
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 180
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
NO_PROXY: 127.0.0.1,localhost
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
|
||||||
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||||
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||||
|
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
# Dedicated key: ubuntu-latest and sm-standard-4 share runner.os, so
|
||||||
|
# a shared key would mix VM and ARC pod target/ artifacts.
|
||||||
|
cache-shared-key: ci-e2e-distributed-hosted
|
||||||
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Prepare isolated filesystems for pool movement
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||||
|
mkdir -p "${mount_base}"
|
||||||
|
roots=()
|
||||||
|
for pool in 0 1 2 3; do
|
||||||
|
mountpoint="${mount_base}/pool-${pool}"
|
||||||
|
mkdir -p "${mountpoint}"
|
||||||
|
# Sized tmpfs reports a distinct st_dev and independent 1G
|
||||||
|
# statfs capacity. Requires a VM runner (ubuntu-latest).
|
||||||
|
if ! sudo mount -t tmpfs -o size=1G,nosuid,nodev,mode=1777 tmpfs "${mountpoint}"; then
|
||||||
|
echo "tmpfs mount failed on $(uname -a)" >&2
|
||||||
|
findmnt || true
|
||||||
|
grep Cap /proc/self/status || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo chmod 1777 "${mountpoint}"
|
||||||
|
roots+=("${mountpoint}")
|
||||||
|
done
|
||||||
|
printf -v joined_roots '%s:' "${roots[@]}"
|
||||||
|
echo "RUSTFS_E2E_POOL_ROOTS=${joined_roots%:}" >> "${GITHUB_ENV}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[0]}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[1]}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[2]}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[3]}"
|
||||||
|
|
||||||
|
- name: Download pinned previous release
|
||||||
|
env:
|
||||||
|
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$SOURCE_DIR"
|
||||||
|
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
|
||||||
|
curl --fail --location --retry 3 --output "$archive" \
|
||||||
|
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||||
|
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||||
|
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||||
|
chmod +x "$SOURCE_DIR/rustfs"
|
||||||
|
test -x "$SOURCE_DIR/rustfs"
|
||||||
|
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build rustfs binary
|
||||||
|
run: |
|
||||||
|
cargo build -p rustfs --bins
|
||||||
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
|
- name: Verify distributed e2e membership
|
||||||
|
env:
|
||||||
|
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||||
|
run: |
|
||||||
|
cargo nextest list --profile e2e-distributed -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
||||||
|
python3 ./scripts/check_test_wiring.py --check-profile e2e-distributed "${NEXTEST_LISTING}"
|
||||||
|
|
||||||
|
- name: Run distributed 4-node e2e suite
|
||||||
|
env:
|
||||||
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
|
||||||
|
FILTER: ${{ inputs.filter }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -n "${FILTER}" ]; then
|
||||||
|
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||||
|
else
|
||||||
|
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload distributed e2e diagnostics
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: e2e-distributed-${{ github.run_number }}
|
||||||
|
path: |
|
||||||
|
target/nextest/e2e-distributed/junit.xml
|
||||||
|
${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||||
|
${{ runner.temp }}/rustfs-e2e-distributed-logs/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Unmount isolated pool filesystems
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||||
|
for pool in 0 1 2 3; do
|
||||||
|
mountpoint="${mount_base}/pool-${pool}"
|
||||||
|
if mountpoint --quiet "${mountpoint}"; then
|
||||||
|
sudo umount "${mountpoint}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
needs: [distributed]
|
||||||
|
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||||
|
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 }}
|
||||||
@@ -19,7 +19,9 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- ".github/workflows/e2e-upgrade.yml"
|
- ".github/workflows/e2e-upgrade.yml"
|
||||||
- "crates/e2e_test/src/common.rs"
|
- "crates/e2e_test/src/common.rs"
|
||||||
|
- "crates/e2e_test/src/fake_s3_target/**"
|
||||||
- "crates/e2e_test/src/lib.rs"
|
- "crates/e2e_test/src/lib.rs"
|
||||||
|
- "crates/e2e_test/src/replication_extension_test.rs"
|
||||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||||
- "crates/ecstore/**"
|
- "crates/ecstore/**"
|
||||||
- "crates/filemeta/**"
|
- "crates/filemeta/**"
|
||||||
@@ -44,9 +46,9 @@ concurrency:
|
|||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUST_BACKTRACE: 1
|
RUST_BACKTRACE: 1
|
||||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
|
||||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
|
||||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upgrade:
|
upgrade:
|
||||||
@@ -55,14 +57,31 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: Direct upgrade from rc.2
|
# The two `_from_rc2_` tests keep their names: they assert
|
||||||
|
# release-independent object contracts and pass unchanged against the
|
||||||
|
# newer pinned source, so renaming them would only churn history and
|
||||||
|
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
|
||||||
|
# single source of truth for which release they actually run against.
|
||||||
|
- name: Direct upgrade from the previous release
|
||||||
cache_key: e2e-direct-upgrade
|
cache_key: e2e-direct-upgrade
|
||||||
test: direct_upgrade_from_rc2_preserves_object_contracts
|
test: direct_upgrade_from_rc2_preserves_object_contracts
|
||||||
artifact: direct-upgrade
|
artifact: direct-upgrade
|
||||||
- name: Mixed-version rolling upgrade from rc.2
|
- name: Mixed-version rolling upgrade from the previous release
|
||||||
cache_key: e2e-mixed-version-upgrade
|
cache_key: e2e-mixed-version-upgrade
|
||||||
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
||||||
artifact: mixed-version-upgrade
|
artifact: mixed-version-upgrade
|
||||||
|
- name: Bucket configuration survives the upgrade
|
||||||
|
cache_key: e2e-bucket-config-upgrade
|
||||||
|
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
|
||||||
|
artifact: bucket-config-upgrade
|
||||||
|
- name: Rollback reads current bucket metadata
|
||||||
|
cache_key: e2e-bucket-config-rollback
|
||||||
|
test: rollback_to_previous_release_reads_current_bucket_metadata
|
||||||
|
artifact: bucket-config-rollback
|
||||||
|
- name: ODM configuration recovery after rc.5 rollback
|
||||||
|
cache_key: e2e-odm-config-rollback
|
||||||
|
test: rc5_rollback_requires_restoring_odm_configuration
|
||||||
|
artifact: odm-config-rollback
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -166,8 +166,9 @@ jobs:
|
|||||||
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
||||||
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
||||||
- name: Upload DEB to Cloudflare R2
|
- name: Upload DEB to Cloudflare R2
|
||||||
if: env.R2_ACCESS_KEY_ID != ''
|
id: publish
|
||||||
env:
|
env:
|
||||||
|
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
@@ -182,28 +183,70 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v aws >/dev/null 2>&1; then
|
|
||||||
sudo apt-get update && sudo apt-get install -y -qq awscli
|
|
||||||
fi
|
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||||
export AWS_DEFAULT_REGION="auto"
|
export AWS_DEFAULT_REGION="auto"
|
||||||
|
|
||||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
SOURCE_SHA="$(git rev-parse HEAD)"
|
||||||
|
if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then
|
||||||
|
echo "Checkout SHA does not match the nightly build run" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
|
||||||
|
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
|
||||||
|
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
|
||||||
|
|
||||||
|
# Old AWS CLI models lack conditional PutObject support. Never fall
|
||||||
|
# back to an overwriting upload for a candidate.
|
||||||
|
AWS_CLI=aws
|
||||||
|
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y -qq python3-venv
|
||||||
|
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/nightly-awscli.XXXXXX")"
|
||||||
|
trap 'rm -rf "${AWS_CLI_DIR}"' EXIT
|
||||||
|
python3 -m venv "${AWS_CLI_DIR}"
|
||||||
|
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
|
||||||
|
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
|
||||||
|
fi
|
||||||
|
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
|
||||||
|
"${AWS_CLI}" --version
|
||||||
|
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${CANDIDATE_KEY}" \
|
||||||
|
--body "${DEB_FILE}" --if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
|
||||||
|
PUBLISHED_SHA256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${CANDIDATE_URL}" | sha256sum | cut -d ' ' -f 1)"
|
||||||
|
if [[ "${PUBLISHED_SHA256}" != "${DEB_SHA256}" ]]; then
|
||||||
|
echo "Published candidate checksum does not match the built package" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
||||||
|
|
||||||
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
||||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||||
|
|
||||||
# Stable "latest" alias so tests can fetch the newest nightly
|
# Stable "latest" alias so tests can fetch the newest nightly
|
||||||
# without knowing today's date.
|
# without knowing today's date.
|
||||||
echo "📤 Uploading latest alias"
|
echo "📤 Uploading latest alias"
|
||||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
||||||
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||||
|
|
||||||
echo "✅ R2 upload complete"
|
echo "✅ R2 upload complete"
|
||||||
|
|
||||||
|
CANDIDATE_FILE="${RUNNER_TEMP}/nightly-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
|
||||||
|
jq -n --arg source_sha "${SOURCE_SHA}" \
|
||||||
|
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||||
|
--arg package_url "${CANDIDATE_URL}" --arg package_sha256 "${DEB_SHA256}" \
|
||||||
|
'{schema: 1, source_sha: $source_sha, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' \
|
||||||
|
> "${CANDIDATE_FILE}"
|
||||||
|
echo "candidate_file=${CANDIDATE_FILE}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Upload nightly candidate manifest
|
||||||
|
if: ${{ steps.publish.outputs.candidate_file != '' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: nightly-candidate-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: ${{ steps.publish.outputs.candidate_file }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
||||||
#
|
#
|
||||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
|
|
||||||
# Functional chain driver: runs the ten functional suites in a fixed order
|
# Functional chain driver: runs the ten functional suites in a fixed order
|
||||||
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
|
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
|
||||||
# replication, with performance on its own runner in parallel) and guarantees
|
# replication -> performance). Each suite attempts the next handoff even
|
||||||
# the chain keeps moving even when individual suites fail.
|
# when its tests fail.
|
||||||
#
|
#
|
||||||
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
|
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
|
||||||
# only chain-triggered runs forward to the next suite via repository_dispatch,
|
# only chain-triggered runs forward to the next suite via repository_dispatch,
|
||||||
@@ -59,16 +59,3 @@ jobs:
|
|||||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
-f event_type='rustfs-chain-upgrade' \
|
-f event_type='rustfs-chain-upgrade' \
|
||||||
-F 'client_payload[from_suite]=nightly-build'
|
-F 'client_payload[from_suite]=nightly-build'
|
||||||
|
|
||||||
- name: Dispatch performance suite (parallel, own runner)
|
|
||||||
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 performance" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
|
||||||
-f event_type='rustfs-chain-performance' \
|
|
||||||
-F 'client_payload[from_suite]=nightly-build'
|
|
||||||
|
|||||||
@@ -54,14 +54,26 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
heal-test:
|
heal-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
# Requirement: a failing suite must not fail the workflow; failures
|
|
||||||
# are filed to rustfs/backlog and the chain continues.
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 480
|
timeout-minutes: 480
|
||||||
# Standalone manual run, or one link of the nightly functional chain
|
# Standalone manual run, or one link of the nightly functional chain
|
||||||
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
|
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-heal-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'RUSTFS_WARP_LOG_FILE=%s/warp.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -117,7 +129,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Preflight checks
|
- name: Preflight checks
|
||||||
run: |
|
run: |
|
||||||
@@ -127,7 +139,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Run heal test (write -> outage -> heal -> verify)
|
- name: Run heal test (write -> outage -> heal -> verify)
|
||||||
id: test
|
id: test
|
||||||
@@ -137,13 +149,10 @@ jobs:
|
|||||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||||
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
||||||
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
||||||
--log-file /tmp/rustfs-heal-test.log
|
--log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-heal-test.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -152,8 +161,9 @@ jobs:
|
|||||||
else
|
else
|
||||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
fi
|
fi
|
||||||
STEPS_TABLE="/tmp/rustfs-heal-steps.md"
|
STEPS_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/steps.md"
|
||||||
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
|
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY' || CASE_RESULT=failure
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -165,6 +175,7 @@ jobs:
|
|||||||
|
|
||||||
steps = {}
|
steps = {}
|
||||||
order = []
|
order = []
|
||||||
|
status_rank = {'SKIP': 0, 'PASS': 1, 'FAIL': 2}
|
||||||
version = None
|
version = None
|
||||||
version_node = None
|
version_node = None
|
||||||
verdict = None
|
verdict = None
|
||||||
@@ -178,14 +189,15 @@ jobs:
|
|||||||
n, desc, status = m.group(1), m.group(2), m.group(3)
|
n, desc, status = m.group(1), m.group(2), m.group(3)
|
||||||
if n not in steps:
|
if n not in steps:
|
||||||
order.append(n)
|
order.append(n)
|
||||||
steps[n] = (desc, status) # later lines win (fail after pass)
|
if n not in steps or status_rank[status] > status_rank[steps[n][1]]:
|
||||||
|
steps[n] = (desc, status)
|
||||||
continue
|
continue
|
||||||
m = ver_re.match(line)
|
m = ver_re.match(line)
|
||||||
if m:
|
if m:
|
||||||
version, version_node = m.group(1), m.group(2)
|
version, version_node = m.group(1), m.group(2)
|
||||||
continue
|
continue
|
||||||
m = result_re.match(line)
|
m = result_re.match(line)
|
||||||
if m:
|
if m and verdict != 'FAIL':
|
||||||
verdict, verdict_detail = m.group(1), m.group(2)
|
verdict, verdict_detail = m.group(1), m.group(2)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
@@ -205,30 +217,43 @@ jobs:
|
|||||||
out.write(f'| {n} | {desc} | {status} |\n')
|
out.write(f'| {n} | {desc} | {status} |\n')
|
||||||
if not order:
|
if not order:
|
||||||
out.write('| - | - | NOT RUN (no step result lines found) |\n')
|
out.write('| - | - | NOT RUN (no step result lines found) |\n')
|
||||||
|
complete = set(steps) == {str(n) for n in range(1, 8)}
|
||||||
|
sys.exit(0 if complete and verdict != 'FAIL' and all(status == 'PASS' for _, status in steps.values()) else 1)
|
||||||
PY
|
PY
|
||||||
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
|
RESULT=success
|
||||||
|
fi
|
||||||
{
|
{
|
||||||
echo "# RustFS heal test report"
|
echo "# RustFS heal test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
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 "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${STEPS_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
|
cat "${STEPS_TABLE}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
tail -n 200 "${LOG_FILE}"
|
||||||
echo '```'
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial step results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
|
||||||
SUITE: heal
|
SUITE: heal
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -238,28 +263,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
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}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'heal'
|
SUITE: 'heal'
|
||||||
SUITE_LABEL: 'Heal'
|
SUITE_LABEL: 'Heal'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-heal-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-heal-test.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -287,14 +316,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -310,14 +341,16 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload test logs
|
- name: Upload test logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-heal-test-${{ github.run_id }}
|
name: rustfs-heal-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-heal-test*.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-warp.*.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/warp.log
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/steps.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||||
|
|||||||
@@ -49,10 +49,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
kms-test:
|
kms-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 420
|
timeout-minutes: 420
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-kms-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -109,9 +127,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run KMS suite
|
- name: Run KMS suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-kms.log
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-kms-test.sh
|
chmod +x auto-testing/rustfs-kms-test.sh
|
||||||
@@ -141,10 +156,7 @@ jobs:
|
|||||||
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-kms.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-kms-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -156,79 +168,43 @@ jobs:
|
|||||||
else
|
else
|
||||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-kms-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
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')
|
|
||||||
|
|
||||||
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 KMS test report"
|
echo "# RustFS KMS test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
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 "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
|
cat "${CASE_TABLE}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
tail -n 200 "${LOG_FILE}"
|
||||||
echo '```'
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-kms-report.md
|
|
||||||
SUITE: kms
|
SUITE: kms
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -238,28 +214,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
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}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'kms'
|
SUITE: 'kms'
|
||||||
SUITE_LABEL: 'KMS'
|
SUITE_LABEL: 'KMS'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-kms-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-kms.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -287,14 +267,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -310,14 +292,15 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-kms-test-${{ github.run_id }}
|
name: rustfs-kms-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-kms.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-kms-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -49,17 +49,16 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
repository_dispatch:
|
repository_dispatch:
|
||||||
# Chain entry: dispatched by rustfs-functional-chain.yml (runs on its own
|
# Chain handoff: dispatched when the replication suite finishes.
|
||||||
# pf-testing runner, in parallel with the shared-VM chain).
|
|
||||||
types: [rustfs-chain-performance]
|
types: [rustfs-chain-performance]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
# Dedicated pf-testing runner/environment: own concurrency group so perf runs
|
# The default performance nodes overlap the other suites' remote VMs, even
|
||||||
# never block (or are blocked by) the pool-expansion / heal tests.
|
# though the runner differs. Hold the shared lock through cleanup as well.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: rustfs-performance-test
|
group: rustfs-shared-functional-tests
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
@@ -76,22 +75,33 @@ env:
|
|||||||
# Package used by the nightly run (workflow_dispatch inputs are empty for
|
# Package used by the nightly run (workflow_dispatch inputs are empty for
|
||||||
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
||||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
# Fixed benchmark result directory so later steps can read summary.md
|
|
||||||
RUSTFS_RESULT_DIR: /tmp/rustfs-perf-results
|
|
||||||
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
|
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
|
||||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
performance-test:
|
performance-test:
|
||||||
runs-on: pf-testing
|
runs-on: pf-testing
|
||||||
# Requirement: a failing benchmark must not fail the workflow;
|
|
||||||
# failures are filed to rustfs/backlog.
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 900
|
timeout-minutes: 900
|
||||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||||
# Skipped when nightly failed.
|
# Skipped when nightly failed.
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-performance-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'RUSTFS_RESULT_DIR=%s/results\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'VERSION_FILE=%s/version.txt\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -123,7 +133,7 @@ jobs:
|
|||||||
if: ${{ inputs.cleanup_before != 'false' }}
|
if: ${{ inputs.cleanup_before != 'false' }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x auto-testing/rustfs_performance_test.sh
|
chmod +x auto-testing/rustfs_performance_test.sh
|
||||||
./auto-testing/rustfs_performance_test.sh --step 1 -y
|
./auto-testing/rustfs_performance_test.sh --step 1 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
- name: Install RustFS package & start cluster (4x4)
|
- name: Install RustFS package & start cluster (4x4)
|
||||||
run: |
|
run: |
|
||||||
@@ -133,7 +143,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Preflight checks
|
- name: Preflight checks
|
||||||
run: |
|
run: |
|
||||||
@@ -143,7 +153,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Run benchmark (GET/PUT/MIXED)
|
- name: Run benchmark (GET/PUT/MIXED)
|
||||||
id: benchmark
|
id: benchmark
|
||||||
@@ -156,17 +166,15 @@ jobs:
|
|||||||
--step 5 -y \
|
--step 5 -y \
|
||||||
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
||||||
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
||||||
--log-file /tmp/rustfs-perf-test.log
|
--log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Analyze results
|
- name: Analyze results
|
||||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||||
run: |
|
run: |
|
||||||
./auto-testing/rustfs_performance_test.sh --step 6 -y
|
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
- name: Collect RustFS version info
|
- name: Collect RustFS version info
|
||||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||||
env:
|
|
||||||
VERSION_FILE: /tmp/rustfs-version.txt
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
read -r -a NODES <<< "${RUSTFS_NODES}"
|
read -r -a NODES <<< "${RUSTFS_NODES}"
|
||||||
@@ -186,7 +194,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }}
|
RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }}
|
||||||
VERSION_FILE: /tmp/rustfs-version.txt
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -194,7 +201,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
SUMMARY="${RESULT_DIR}/summary.md"
|
SUMMARY="${RESULT_DIR}/summary.md"
|
||||||
[ -f "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
[ -s "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="reports/${DATE}.md"
|
REPORT_PATH="reports/${DATE}.md"
|
||||||
{
|
{
|
||||||
@@ -202,6 +209,8 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- **Date**: ${DATE}"
|
echo "- **Date**: ${DATE}"
|
||||||
echo "- **Run**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
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 "- **Trigger**: ${{ github.event_name }}"
|
||||||
echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -211,8 +220,8 @@ jobs:
|
|||||||
echo '```text'
|
echo '```text'
|
||||||
cat "${VERSION_FILE}"
|
cat "${VERSION_FILE}"
|
||||||
echo '```'
|
echo '```'
|
||||||
} > /tmp/rustfs-perf-report.md
|
} > "${REPORT_FILE}"
|
||||||
CONTENT="$(python3 -c 'import base64; print(base64.b64encode(open("/tmp/rustfs-perf-report.md","rb").read()).decode())')"
|
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)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
@@ -231,11 +240,10 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'performance'
|
SUITE: 'performance'
|
||||||
SUITE_LABEL: 'Performance'
|
SUITE_LABEL: 'Performance'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-perf-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-perf-test.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -263,14 +271,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -286,20 +296,26 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload test logs & results
|
- name: Upload test logs & results
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-perf-test-${{ github.run_id }}
|
name: rustfs-perf-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-perf-test*.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-perf-results/**
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
/tmp/rustfs-version.txt
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/version.txt
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/master.log
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.md
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.tsv
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/get_*.txt
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/put_*.txt
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/mixed_*.txt
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Reset test environment (after)
|
- name: Reset test environment (after)
|
||||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||||
run: |
|
run: |
|
||||||
./auto-testing/rustfs_performance_test.sh --step 7 -y
|
./auto-testing/rustfs_performance_test.sh --step 7 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
@@ -76,9 +76,6 @@ jobs:
|
|||||||
pool-expansion-test:
|
pool-expansion-test:
|
||||||
name: Pool expansion / decommission test
|
name: Pool expansion / decommission test
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
# Requirement: a failing suite must not fail the workflow; failures
|
|
||||||
# are filed to rustfs/backlog and the chain continues.
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
env:
|
env:
|
||||||
@@ -542,17 +539,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
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}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ on:
|
|||||||
- site
|
- site
|
||||||
default: all
|
default: all
|
||||||
repository_dispatch:
|
repository_dispatch:
|
||||||
# Chain handoff: dispatched when the security suite finishes. This is the
|
# Chain handoff: dispatched when the security suite finishes.
|
||||||
# last link of the functional chain.
|
|
||||||
types: [rustfs-chain-replication]
|
types: [rustfs-chain-replication]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -62,12 +61,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
replication-test:
|
replication-test:
|
||||||
runs-on: smoke-testing
|
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
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-replication-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -116,9 +131,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run replication suite
|
- name: Run replication suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-replication.log
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-replication-test.sh
|
chmod +x auto-testing/rustfs-replication-test.sh
|
||||||
@@ -141,10 +153,7 @@ jobs:
|
|||||||
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-replication.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -166,80 +175,44 @@ jobs:
|
|||||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-replication-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
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 "# RustFS replication test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
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 "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
|
cat "${CASE_TABLE}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
tail -n 200 "${LOG_FILE}"
|
||||||
echo '```'
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
|
||||||
SUITE: replication
|
SUITE: replication
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -249,28 +222,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
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}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'replication'
|
SUITE: 'replication'
|
||||||
SUITE_LABEL: 'Replication (bucket + site)'
|
SUITE_LABEL: 'Replication (bucket + site)'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -298,14 +275,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -321,14 +300,15 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-replication-${{ github.run_id }}
|
name: rustfs-replication-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-replication.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-replication-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
@@ -349,13 +329,50 @@ jobs:
|
|||||||
'
|
'
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Chain complete
|
- name: "Continue functional chain (next: Performance)"
|
||||||
# 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' }}
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "Functional chain complete: replication (final suite) finished."
|
set -uo pipefail
|
||||||
echo "from_suite=security trigger=${{ github.event_name }} outcome=${{ steps.test.outcome }}"
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DISPATCHED=0
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
|
-f event_type='rustfs-chain-performance' \
|
||||||
|
-F 'client_payload[from_suite]=replication'; then
|
||||||
|
echo "dispatched next suite Performance (attempt ${attempt})"
|
||||||
|
DISPATCHED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||||
|
sleep "${attempt}0"
|
||||||
|
done
|
||||||
|
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||||
|
echo "ERROR: functional chain stalled: could not dispatch Performance after 3 attempts" >&2
|
||||||
|
TITLE="[functional][chain] stalled after replication (run ${GITHUB_RUN_ID})"
|
||||||
|
BODY_FILE="$(mktemp)"
|
||||||
|
trap 'rm -f "${BODY_FILE}"' EXIT
|
||||||
|
{
|
||||||
|
echo "The functional chain could not hand off from **replication** to **Performance** after 3 attempts."
|
||||||
|
echo ""
|
||||||
|
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
|
echo "- Expected next event: 'rustfs-chain-performance'"
|
||||||
|
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"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-performance'"
|
||||||
|
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
|
||||||
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
@@ -37,10 +37,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
s3-compat-test:
|
s3-compat-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-s3-compat-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -88,9 +106,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run S3 compatibility suite
|
- name: Run S3 compatibility suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-s3-compat.log
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-s3-compat-test.sh
|
chmod +x auto-testing/rustfs-s3-compat-test.sh
|
||||||
@@ -107,10 +122,7 @@ jobs:
|
|||||||
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-s3-compat.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -132,83 +144,44 @@ jobs:
|
|||||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-s3-compat-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
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 = {}
|
|
||||||
current = None
|
|
||||||
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)
|
|
||||||
current = case_id
|
|
||||||
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
|
|
||||||
current = None
|
|
||||||
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 S3 compatibility test report"
|
echo "# RustFS S3 compatibility test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
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 "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
|
cat "${CASE_TABLE}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
tail -n 200 "${LOG_FILE}"
|
||||||
echo '```'
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
|
|
||||||
SUITE: s3
|
SUITE: s3
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -218,28 +191,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
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}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 's3'
|
SUITE: 's3'
|
||||||
SUITE_LABEL: 'S3 compatibility'
|
SUITE_LABEL: 'S3 compatibility'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-s3-compat-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-s3-compat.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -267,14 +244,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -290,14 +269,15 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-s3-compat-${{ github.run_id }}
|
name: rustfs-s3-compat-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-s3-compat.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-s3-compat-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -74,10 +74,27 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
security-test:
|
security-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
# Checkout the repository into its own subdirectory. Checking out at
|
||||||
|
# the workspace root would wipe the auto-testing clone above (that is
|
||||||
|
# exactly how run 33934141181 lost rustfs-security-test.sh).
|
||||||
|
- name: Checkout repository (for the OIDC live gate script)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
path: rustfs-repo
|
||||||
|
|
||||||
|
- 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}" "${SECURITY_ARTIFACTS_DIR}-scratch"
|
||||||
|
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -98,11 +115,6 @@ jobs:
|
|||||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Checkout repository (for the OIDC live gate script)
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Show environment
|
- name: Show environment
|
||||||
run: |
|
run: |
|
||||||
uname -a
|
uname -a
|
||||||
@@ -135,8 +147,9 @@ jobs:
|
|||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
REPORT_FILE: /tmp/rustfs-security-report.md
|
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||||
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/scripts/test/oidc_keycloak_live.sh
|
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}-scratch
|
||||||
|
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/rustfs-repo/scripts/test/oidc_keycloak_live.sh
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-security-test.sh
|
chmod +x auto-testing/rustfs-security-test.sh
|
||||||
@@ -159,29 +172,48 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
fi
|
fi
|
||||||
./auto-testing/rustfs-security-test.sh "${ARGS[@]}"
|
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}" 2>&1 | tee "${SECURITY_ARTIFACTS_DIR}/suite.log"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
id: report
|
||||||
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
|
env:
|
||||||
|
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ ! -f /tmp/rustfs-security-report.md ]; then
|
RESULT=failure
|
||||||
|
if [ "${TEST_OUTCOME}" = "success" ] && [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
|
||||||
|
RESULT=success
|
||||||
|
fi
|
||||||
{
|
{
|
||||||
echo "# RustFS security test report"
|
echo "# RustFS security test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
echo "- Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
echo "- Trigger: ${{ github.event_name }}"
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
echo "- Test Step Outcome: failure (suite did not produce a report)"
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
} > /tmp/rustfs-security-report.md
|
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
|
fi
|
||||||
cat /tmp/rustfs-security-report.md >> "${GITHUB_STEP_SUMMARY}"
|
} > "${SECURITY_ARTIFACTS_DIR}/report.md"
|
||||||
|
cat "${SECURITY_ARTIFACTS_DIR}/report.md" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-security-report.md
|
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||||
SUITE: security
|
SUITE: security
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -191,17 +223,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
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}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
@@ -210,8 +247,9 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
SUITE: 'security'
|
SUITE: 'security'
|
||||||
SUITE_LABEL: 'Security'
|
SUITE_LABEL: 'Security'
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
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: ''
|
LOG_FILE: ''
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -245,7 +283,7 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
@@ -263,14 +301,15 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-security-test-${{ github.run_id }}
|
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-security-report.md
|
${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-security.*/*
|
${{ env.SECURITY_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: ignore
|
${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||||
|
if-no-files-found: error
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
|
|||||||
@@ -46,10 +46,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
storage-test:
|
storage-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-storage-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -97,9 +115,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Run storage engine suite
|
- name: Run storage engine suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-storage.log
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-storage-test.sh
|
chmod +x auto-testing/rustfs-storage-test.sh
|
||||||
@@ -122,10 +137,7 @@ jobs:
|
|||||||
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-storage.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
PACKAGE_URL='${{ inputs.package_url }}'
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
@@ -147,83 +159,44 @@ jobs:
|
|||||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-storage-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
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 = {}
|
|
||||||
current = None
|
|
||||||
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)
|
|
||||||
current = case_id
|
|
||||||
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
|
|
||||||
current = None
|
|
||||||
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 storage engine test report"
|
echo "# RustFS storage engine test report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
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 "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- Package: ${PACKAGE_SOURCE}"
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
|
cat "${CASE_TABLE}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
tail -n 200 "${LOG_FILE}"
|
||||||
echo '```'
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
|
||||||
SUITE: storage
|
SUITE: storage
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -233,28 +206,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
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}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'storage'
|
SUITE: 'storage'
|
||||||
SUITE_LABEL: 'Storage engine'
|
SUITE_LABEL: 'Storage engine'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-storage-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-storage.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -282,14 +259,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -305,14 +284,15 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-storage-${{ github.run_id }}
|
name: rustfs-storage-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-storage.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-storage-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
tier-test:
|
tier-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
# Requirement: a failing suite must not fail the workflow; failures
|
|
||||||
# are filed to rustfs/backlog and the chain continues.
|
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 420
|
timeout-minutes: 420
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
@@ -380,17 +377,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
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}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: Verify required tier evidence
|
- name: Verify required tier evidence
|
||||||
id: evidence_verify
|
id: evidence_verify
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
from_version:
|
from_version:
|
||||||
description: 'OLD RustFS release tag (must ship a .deb asset, e.g. 1.0.0-rc.3)'
|
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
|
required: false
|
||||||
default: '1.0.0-rc.3'
|
default: '1.0.0-rc.3'
|
||||||
from_url:
|
from_url:
|
||||||
@@ -26,7 +26,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
to_version:
|
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
|
required: false
|
||||||
to_url:
|
to_url:
|
||||||
description: 'NEW .deb URL. Overrides to_version / nightly default.'
|
description: 'NEW .deb URL. Overrides to_version / nightly default.'
|
||||||
@@ -79,10 +79,28 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
upgrade-test:
|
upgrade-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 420
|
timeout-minutes: 420
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository (for report parser)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-upgrade-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
# auto-testing is private: clone it with the dedicated PF token (not
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
- name: Checkout auto-testing scripts (with retry)
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
@@ -142,9 +160,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Run upgrade compatibility suite
|
- name: Run upgrade compatibility suite
|
||||||
id: test
|
id: test
|
||||||
continue-on-error: true
|
|
||||||
env:
|
env:
|
||||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
chmod +x auto-testing/rustfs-upgrade-test.sh
|
chmod +x auto-testing/rustfs-upgrade-test.sh
|
||||||
@@ -175,13 +192,33 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
fi
|
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[@]}"
|
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
env:
|
|
||||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
|
||||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
FROM_URL='${{ inputs.from_url }}'
|
FROM_URL='${{ inputs.from_url }}'
|
||||||
@@ -202,103 +239,47 @@ jobs:
|
|||||||
else
|
else
|
||||||
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="/tmp/rustfs-upgrade-cases.md"
|
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||||
MATRIX_TABLE="/tmp/rustfs-upgrade-matrix.md"
|
MATRIX_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/matrix.md"
|
||||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" <<'PY'
|
CASE_RESULT=success
|
||||||
import re
|
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" || CASE_RESULT=failure
|
||||||
import sys
|
RESULT=failure
|
||||||
|
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||||
log_file, out_file, matrix_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
RESULT=success
|
||||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
fi
|
||||||
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 = 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 = []
|
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
# 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"
|
echo "# RustFS upgrade compatibility report"
|
||||||
echo ""
|
echo ""
|
||||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
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 "- Trigger: ${{ github.event_name }}"
|
||||||
echo "- From: ${FROM_SOURCE}"
|
echo "- From: ${FROM_SOURCE}"
|
||||||
echo "- To: ${TO_SOURCE}"
|
echo "- To: ${TO_SOURCE}"
|
||||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
echo "- Test Step Outcome: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${MATRIX_TABLE}" || true
|
if [ "${RESULT}" = "success" ]; then
|
||||||
|
cat "${MATRIX_TABLE}"
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}" || true
|
cat "${CASE_TABLE}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}" || true
|
tail -n 200 "${LOG_FILE}"
|
||||||
echo '```'
|
echo '```'
|
||||||
|
else
|
||||||
|
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||||
|
fi
|
||||||
} | tee "${REPORT_FILE}"
|
} | tee "${REPORT_FILE}"
|
||||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
[ "${RESULT}" = "success" ]
|
||||||
|
|
||||||
- name: Upload functional report to dashboard
|
- name: Upload functional report to dashboard
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
|
||||||
SUITE: upgrade
|
SUITE: upgrade
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -308,28 +289,32 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DATE="$(date -u +%Y-%m-%d)"
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
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}")"
|
# Base64-encode the report into a temp file and feed it to jq via
|
||||||
|
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||||
|
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||||
|
B64_FILE="$(mktemp)"
|
||||||
|
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||||
if [ -n "${SHA}" ]; then
|
if [ -n "${SHA}" ]; then
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:$content, sha:$sha}' \
|
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
else
|
else
|
||||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||||
'{message:$msg, content:$content}' \
|
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
fi
|
fi
|
||||||
|
rm -f "${B64_FILE}"
|
||||||
|
|
||||||
- name: File failure issue in rustfs/backlog
|
- name: File failure issue in rustfs/backlog
|
||||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||||
SUITE: 'upgrade'
|
SUITE: 'upgrade'
|
||||||
SUITE_LABEL: 'Upgrade compatibility'
|
SUITE_LABEL: 'Upgrade compatibility'
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
REPORT_FILE: '/tmp/rustfs-upgrade-report.md'
|
|
||||||
LOG_FILE: '/tmp/rustfs-upgrade.log'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
@@ -357,14 +342,16 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
echo "- Suite: \`${SUITE}\`"
|
echo "- Suite: \`${SUITE}\`"
|
||||||
echo "- Run: ${RUN_URL}"
|
echo "- Run: ${RUN_URL}"
|
||||||
|
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||||
|
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Report (errors and symptoms)"
|
echo "## Report (errors and symptoms)"
|
||||||
echo ""
|
echo ""
|
||||||
if [ -s "${REPORT_FILE}" ]; then
|
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ -s "${LOG_FILE:-}" ]; then
|
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||||
echo "(report file missing; log tail below)"
|
echo "(report file missing; log tail below)"
|
||||||
echo ""
|
echo ""
|
||||||
tail -n 200 "${LOG_FILE}" | redact
|
tail -n 200 "${LOG_FILE}" | redact
|
||||||
@@ -380,14 +367,16 @@ jobs:
|
|||||||
echo "filed backlog issue for suite ${SUITE}"
|
echo "filed backlog issue for suite ${SUITE}"
|
||||||
|
|
||||||
- name: Upload report and logs
|
- name: Upload report and logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-upgrade-test-${{ github.run_id }}
|
name: rustfs-upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-upgrade-report.md
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-upgrade.*/*
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: ignore
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/matrix.md
|
||||||
|
if-no-files-found: error
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ jobs:
|
|||||||
- name: Check latest scheduled runs
|
- name: Check latest scheduled runs
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
RUSTFS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
python3 scripts/check_scheduled_validation_freshness.py \
|
python3 scripts/check_scheduled_validation_freshness.py \
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ on:
|
|||||||
- "Continuous Integration"
|
- "Continuous Integration"
|
||||||
- "coverage"
|
- "coverage"
|
||||||
- "e2e-nightly"
|
- "e2e-nightly"
|
||||||
|
- "e2e-distributed"
|
||||||
- "e2e-s3tests"
|
- "e2e-s3tests"
|
||||||
- "Fuzz"
|
- "Fuzz"
|
||||||
- "mint"
|
- "mint"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ profile.json
|
|||||||
*.zst
|
*.zst
|
||||||
.secrets
|
.secrets
|
||||||
*.go
|
*.go
|
||||||
|
!crates/zip/tests/fixtures/snowball/**/generate/*.go
|
||||||
*.pb
|
*.pb
|
||||||
*.svg
|
*.svg
|
||||||
deploy/logs/*.log.*
|
deploy/logs/*.log.*
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: rustfs-dev-check
|
- id: rustfs-fmt-check
|
||||||
name: rustfs dev-check
|
name: Rust formatting
|
||||||
entry: make dev-check
|
entry: cargo fmt --all --check
|
||||||
language: system
|
language: system
|
||||||
types: [rust]
|
types: [rust]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|||||||
+4
-1
@@ -18,7 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- 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`
|
- 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
|
- 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
|
- 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
|
- Listings: `ListObjects` v1 remains local with ordinary key markers. `ListObjectsV2` can merge source objects when `policy.list_through = true`; this is off by default
|
||||||
|
- Upgrade and rollback: finish upgrading every node before enabling ODM. An rc.5 node that writes bucket configuration drops the ODM fields from metadata; neither a later restart nor moving the service out of ECStore recovers them. Before rollback, disable ODM and securely retain the original full configuration and credentials. After every node returns to a compatible version, restore and validate that configuration. Redacted exports cannot replace the credential backup; source-only objects are unavailable through RustFS while ODM is disabled. See the upgrade and rollback section of `docs/operations/on-demand-migration.md`
|
||||||
|
- Optional Google dependencies: default and `full` server builds retain native GCS support. `cargo build -p rustfs --no-default-features --features ftps,webdav` excludes Google SDKs while preserving configuration decoding and redaction; native GCS ODM and tier operations require the `gcs` feature. Do not use that build with existing GCS-tiered data
|
||||||
|
- Limitations: 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.
|
- **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
|
- 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
|
- 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
|
||||||
|
|||||||
+11
-37
@@ -109,24 +109,17 @@ affected boundaries and risks. CI still runs its configured repository gates.
|
|||||||
|
|
||||||
### 🔒 Git Pre-commit Hooks (optional)
|
### 🔒 Git Pre-commit Hooks (optional)
|
||||||
|
|
||||||
Git hooks are **not** versioned in this repository, so a fresh clone has no
|
The optional hook uses the checked-in `.pre-commit-config.yaml`. Install [pre-commit](https://pre-commit.com/#installation), then run this from the checkout or a linked worktree:
|
||||||
active pre-commit hook. If you add your own `.git/hooks/pre-commit` (a good
|
|
||||||
choice is a one-liner that runs `make pre-commit`), you can mark it executable
|
|
||||||
with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make setup-hooks
|
make setup-hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
Or manually:
|
The hook runs `cargo fmt --all --check` when staged files include Rust source. It does not compile the workspace or run tests. Fix formatting with `cargo fmt --all`, inspect and stage the result, then commit again.
|
||||||
|
|
||||||
```bash
|
`pre-commit install` resolves Git's hook directory for linked worktrees and preserves an existing hook in migration mode. If you use `core.hooksPath`, keep that hook manager and integrate `pre-commit run` there; the installer refuses to silently replace that configuration.
|
||||||
chmod +x .git/hooks/pre-commit
|
|
||||||
```
|
|
||||||
|
|
||||||
With or without a hook, follow the verification tiers in `AGENTS.md`. Run the
|
A local hook provides early formatting feedback. With or without it, follow the verification tiers in `AGENTS.md`, run relevant behavioral tests, and satisfy the CI merge gates. `make pre-commit` and `make dev-check` remain explicit broader commands.
|
||||||
applicable scoped checks, and reserve `make pre-pr` for broad cross-module
|
|
||||||
changes whose impact cannot be bounded by those checks.
|
|
||||||
|
|
||||||
### 📝 Formatting Configuration
|
### 📝 Formatting Configuration
|
||||||
|
|
||||||
@@ -138,31 +131,11 @@ fn_call_width = 90
|
|||||||
single_line_let_else_max_width = 100
|
single_line_let_else_max_width = 100
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚫 Commit Prevention
|
|
||||||
|
|
||||||
If you set up a pre-commit hook and your code doesn't meet the formatting requirements, the hook will:
|
|
||||||
|
|
||||||
1. **Block the commit** and show clear error messages
|
|
||||||
2. **Provide exact commands** to fix the issues
|
|
||||||
3. **Guide you through** the resolution process
|
|
||||||
|
|
||||||
Example output when formatting fails:
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ Code formatting check failed!
|
|
||||||
💡 Please run 'cargo fmt --all' to format your code before committing.
|
|
||||||
|
|
||||||
🔧 Quick fix:
|
|
||||||
cargo fmt --all
|
|
||||||
git add .
|
|
||||||
git commit
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔄 Development Workflow
|
### 🔄 Development Workflow
|
||||||
|
|
||||||
1. **Make your changes**
|
1. **Make your changes**
|
||||||
2. **Format your code**: `make fmt` or `cargo fmt --all`
|
2. **Format your code**: `make fmt` or `cargo fmt --all`
|
||||||
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
|
3. **Select relevant checks** using the validation tier in `AGENTS.md`; use `make pre-commit` when its broader fast gate adds useful coverage
|
||||||
4. **Commit your changes**: `git commit -m "your message"`
|
4. **Commit your changes**: `git commit -m "your message"`
|
||||||
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
|
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
|
||||||
6. **Run applicable scoped checks before opening/updating a PR**; consider
|
6. **Run applicable scoped checks before opening/updating a PR**; consider
|
||||||
@@ -206,11 +179,12 @@ Configure your IDE to:
|
|||||||
#### Pre-commit hook not running?
|
#### Pre-commit hook not running?
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if hook is executable
|
pre-commit validate-config
|
||||||
ls -la .git/hooks/pre-commit
|
pre-commit run --all-files
|
||||||
|
# Inspect any configured hook manager; do not overwrite it.
|
||||||
# Make it executable if needed
|
git config --get core.hooksPath
|
||||||
chmod +x .git/hooks/pre-commit
|
# Install if no separate hook manager is configured.
|
||||||
|
make setup-hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Formatting issues?
|
#### Formatting issues?
|
||||||
|
|||||||
Generated
+385
-138
File diff suppressed because it is too large
Load Diff
+23
-17
@@ -168,7 +168,7 @@ reqwest = "0.13.4"
|
|||||||
rustfs-kafka-async = { version = "1.3.1" }
|
rustfs-kafka-async = { version = "1.3.1" }
|
||||||
socket2 = { version = "0.6.5" }
|
socket2 = { version = "0.6.5" }
|
||||||
tokio = { version = "1.53.1" }
|
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-stream = { version = "0.1.19" }
|
||||||
tokio-test = "0.4.5"
|
tokio-test = "0.4.5"
|
||||||
tokio-util = { version = "0.7.19" }
|
tokio-util = { version = "0.7.19" }
|
||||||
@@ -191,6 +191,7 @@ rmp = { version = "0.8.15" }
|
|||||||
rmp-serde = { version = "1.3.1" }
|
rmp-serde = { version = "1.3.1" }
|
||||||
serde = { version = "1.0.229" }
|
serde = { version = "1.0.229" }
|
||||||
serde_ignored = { version = "0.1" }
|
serde_ignored = { version = "0.1" }
|
||||||
|
serde_with = { version = "3", default-features = false, features = ["macros", "std"] }
|
||||||
serde_json = { version = "1.0.151" }
|
serde_json = { version = "1.0.151" }
|
||||||
serde_urlencoded = "0.7.1"
|
serde_urlencoded = "0.7.1"
|
||||||
|
|
||||||
@@ -199,10 +200,10 @@ serde_urlencoded = "0.7.1"
|
|||||||
# matching stable releases are not available yet, while previous stable lines
|
# matching stable releases are not available yet, while previous stable lines
|
||||||
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
|
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
|
||||||
# releases.
|
# releases.
|
||||||
aes-gcm = { version = "=0.11.1" }
|
aes-gcm = { version = "0.11.1" }
|
||||||
argon2 = { version = "=0.6.0" }
|
argon2 = { version = "0.6.0" }
|
||||||
blake2 = "=0.11.0"
|
blake2 = "0.11.0"
|
||||||
chacha20poly1305 = { version = "=0.11.0" }
|
chacha20poly1305 = { version = "0.11.0" }
|
||||||
crc-fast = "1.10.0"
|
crc-fast = "1.10.0"
|
||||||
hmac = { version = "0.13.0" }
|
hmac = { version = "0.13.0" }
|
||||||
jsonwebtoken = { version = "11.0.0" }
|
jsonwebtoken = { version = "11.0.0" }
|
||||||
@@ -234,15 +235,19 @@ tokio-postgres-rustls = "0.14.0"
|
|||||||
# Utilities and Tools
|
# Utilities and Tools
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
arc-swap = "1.9.2"
|
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" }
|
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"
|
atoi = "3.1.0"
|
||||||
atomic_enum = "0.3.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-credential-types = { version = "1.3.0" }
|
||||||
aws-sdk-kms = { default-features = false, version = "1.117.0" }
|
aws-sdk-kms = { default-features = false, version = "1.118.0" }
|
||||||
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
|
aws-sdk-s3 = { default-features = false, version = "1.145.0" }
|
||||||
aws-sdk-sts = { default-features = false, version = "1.113.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-http-client = { default-features = false, version = "1.4.0" }
|
||||||
aws-smithy-runtime-api = { version = "1.16.0" }
|
aws-smithy-runtime-api = { version = "1.16.0" }
|
||||||
aws-smithy-types = { version = "1.6.3" }
|
aws-smithy-types = { version = "1.6.3" }
|
||||||
@@ -252,10 +257,10 @@ clap = { version = "4.6.6" }
|
|||||||
const-str = { version = "1.1.0" }
|
const-str = { version = "1.1.0" }
|
||||||
convert_case = "0.12.0"
|
convert_case = "0.12.0"
|
||||||
criterion = { version = "0.8" }
|
criterion = { version = "0.8" }
|
||||||
crossbeam-queue = "0.3.13"
|
crossbeam-queue = "0.3.14"
|
||||||
crossbeam-channel = "0.5.16"
|
crossbeam-channel = "0.5.17"
|
||||||
crossbeam-deque = "0.8.7"
|
crossbeam-deque = "0.8.8"
|
||||||
crossbeam-utils = "0.8.22"
|
crossbeam-utils = "0.8.23"
|
||||||
datafusion = { default-features = false, version = "55.0.0" }
|
datafusion = { default-features = false, version = "55.0.0" }
|
||||||
derive_builder = "0.20.2"
|
derive_builder = "0.20.2"
|
||||||
enumset = "1.1.14"
|
enumset = "1.1.14"
|
||||||
@@ -302,7 +307,7 @@ rustfs-erasure-codec = { version = "8.0.2" }
|
|||||||
reed-solomon-simd = "3.1.0"
|
reed-solomon-simd = "3.1.0"
|
||||||
regex = { version = "1.13.1" }
|
regex = { version = "1.13.1" }
|
||||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||||
redis = { version = "1.6.0" }
|
redis = { version = "1.7.0" }
|
||||||
rustify = { version = "0.7", default-features = false }
|
rustify = { version = "0.7", default-features = false }
|
||||||
rustix = { version = "1.1.4" }
|
rustix = { version = "1.1.4" }
|
||||||
rust-embed = { version = "8.12.0" }
|
rust-embed = { version = "8.12.0" }
|
||||||
@@ -339,7 +344,7 @@ windows = { version = "0.62.2" }
|
|||||||
windows-sys = "0.61.2"
|
windows-sys = "0.61.2"
|
||||||
xxhash-rust = { version = "0.8.18" }
|
xxhash-rust = { version = "0.8.18" }
|
||||||
zip = "8.6.0"
|
zip = "8.6.0"
|
||||||
zstd = "0.13.3"
|
zstd = "0.14.0"
|
||||||
|
|
||||||
# Observability and Metrics
|
# Observability and Metrics
|
||||||
metrics = "0.24.6"
|
metrics = "0.24.6"
|
||||||
@@ -367,7 +372,8 @@ dav-server = "0.11.0"
|
|||||||
|
|
||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
rustfs-mimalloc = { version = "0.5.3" }
|
rustfs-mimalloc = { version = "0.5.3" }
|
||||||
hotpath = { version = "0.25.0", default-features = false }
|
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||||
|
hotpath = { version = "=0.25.0", default-features = false }
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
|
|
||||||
|
|||||||
@@ -422,9 +422,9 @@ fn unix_now_ms() -> u64 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A repair the MRF consumer landed, fanned out so retry ledgers can drop
|
/// Legacy, unverified repair notice. Its identity lacks kind, set scope,
|
||||||
/// entries the journal no longer tracks (backlog#1894 axis B). The payload
|
/// bucket incarnation and responsibility generation. Consumers must not use
|
||||||
/// mirrors the intent identity so consumers match without re-parsing.
|
/// it to discharge persisted repair responsibility.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct MrfRepairedEvent {
|
pub struct MrfRepairedEvent {
|
||||||
pub bucket: Arc<str>,
|
pub bucket: Arc<str>,
|
||||||
@@ -439,8 +439,8 @@ const MRF_REPAIRED_EVENT_CAP: usize = 4096;
|
|||||||
|
|
||||||
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
||||||
|
|
||||||
/// Record that the MRF consumer landed a repair. Never blocks: the critical
|
/// Record a legacy notification for compatibility. This is not an
|
||||||
/// section is a deque push under a std mutex.
|
/// acknowledgement of storage verification or durable repair completion.
|
||||||
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
||||||
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||||
let Ok(mut events) = registry.lock() else {
|
let Ok(mut events) = registry.lock() else {
|
||||||
@@ -515,6 +515,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
coalescer_release(&key, Some(lease));
|
coalescer_release(&key, Some(lease));
|
||||||
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
|
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
|
||||||
|
assert_ne!(lease, retry_lease);
|
||||||
|
coalescer_release(&key, Some(lease));
|
||||||
|
assert_eq!(coalescer_admit(key.clone()), Err(MrfIngressResult::Coalesced));
|
||||||
coalescer_release(&key, Some(retry_lease));
|
coalescer_release(&key, Some(retry_lease));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,52 @@ Scanner cycle budget controls:
|
|||||||
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
|
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
|
||||||
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
|
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
|
||||||
|
|
||||||
|
## Foreground write admission environment variables
|
||||||
|
|
||||||
|
Large direct `PutObject` requests and multipart `UploadPart` requests share one
|
||||||
|
per-process permit pool that bounds how many bodies are ingested and written
|
||||||
|
concurrently. Small direct PUTs stay on the legacy path.
|
||||||
|
|
||||||
|
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE`
|
||||||
|
- enables the default-on pool; `false` keeps only the soft request counter.
|
||||||
|
- default is `true`.
|
||||||
|
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT`
|
||||||
|
- permits in the pool; `0` derives half of `RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS`, clamped to `32`.
|
||||||
|
- default is `0` (32 permits at stock settings).
|
||||||
|
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES`
|
||||||
|
- smallest direct `PutObject` that takes a permit; unknown-size requests always do.
|
||||||
|
- default is `33554432` (32 MiB).
|
||||||
|
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||||
|
- how long a direct `PutObject` waits for a permit before returning S3 `SlowDown`.
|
||||||
|
- default is `250`.
|
||||||
|
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES`
|
||||||
|
- smallest `UploadPart` that takes a permit; `0` gates every part.
|
||||||
|
- default is `0`.
|
||||||
|
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||||
|
- how long an `UploadPart` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
|
||||||
|
- default is `30000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing.
|
||||||
|
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING`
|
||||||
|
- maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
|
||||||
|
- default is `0`, which derives 16 times the permit limit (512 at stock settings).
|
||||||
|
- `RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE`, `RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT`, `RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||||
|
- experimental strict gate that applies to every foreground write regardless of size and replaces the pool above when enabled.
|
||||||
|
- default is disabled; enabling it with limit `0` disables foreground write admission entirely.
|
||||||
|
|
||||||
|
## Remote tier timeout environment variables
|
||||||
|
|
||||||
|
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
|
||||||
|
- remote tier TCP connect timeout.
|
||||||
|
- default is `10`.
|
||||||
|
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
|
||||||
|
- `RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS`
|
||||||
|
- remote tier request timeout through response headers.
|
||||||
|
- default is `86400` so large transition uploads keep a production-safe budget.
|
||||||
|
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default. Very large values are accepted and act as a correspondingly long budget.
|
||||||
|
- `RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS`
|
||||||
|
- maximum idle time between remote tier response-body chunks.
|
||||||
|
- default is `60`; the timer resets only when non-empty body data keeps progressing.
|
||||||
|
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
|
||||||
|
|
||||||
## Drive timeout environment variables
|
## Drive timeout environment variables
|
||||||
|
|
||||||
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
|
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
|
||||||
@@ -157,6 +203,14 @@ Drive timeout profile preset:
|
|||||||
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
||||||
- Then the profile-derived default (`default` or `high_latency`).
|
- Then the profile-derived default (`default` or `high_latency`).
|
||||||
|
|
||||||
|
## Admin peer probe timeout
|
||||||
|
|
||||||
|
- `RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS`
|
||||||
|
- total per-peer budget for the `server_info`/`storage_info` admin probe round; `server_info` may reconnect once and `storage_info` remains a single attempt.
|
||||||
|
- default is `10` seconds, preserving the previous two-attempt worst-case budget.
|
||||||
|
- values must be positive; `0` or an invalid value falls back to the default, and values above `60` are clamped to `60`.
|
||||||
|
- the setting is read by the aggregating node only; it does not change the internode RPC wire contract. Any retry shares one round deadline rather than receiving a fresh timeout.
|
||||||
|
|
||||||
## Startup filesystem boundary policy
|
## Startup filesystem boundary policy
|
||||||
|
|
||||||
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
|
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ pub const DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS: u64 = 20;
|
|||||||
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
|
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
|
||||||
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 30;
|
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
|
/// Total budget for one admin peer probe round, including any reconnect retry.
|
||||||
|
///
|
||||||
|
/// This is intentionally separate from the transport-level RPC timeout: admin
|
||||||
|
/// probes may retry once, but the retry must consume the same round budget.
|
||||||
|
pub const ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS: &str = "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS";
|
||||||
|
pub const DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 10;
|
||||||
|
pub const MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 60;
|
||||||
|
const _: () = assert!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS <= MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS);
|
||||||
|
|
||||||
// ── Client-side internode gRPC channel tuning (P0) ──
|
// ── Client-side internode gRPC channel tuning (P0) ──
|
||||||
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
|
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
|
||||||
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
|
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
|
||||||
@@ -312,6 +321,7 @@ mod tests {
|
|||||||
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5);
|
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5);
|
||||||
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 20);
|
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 20);
|
||||||
assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 30);
|
assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 30);
|
||||||
|
assert_eq!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS, 10);
|
||||||
assert_eq!(DEFAULT_INTERNODE_HTTP_TUNING_PROFILE, "legacy");
|
assert_eq!(DEFAULT_INTERNODE_HTTP_TUNING_PROFILE, "legacy");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +422,7 @@ mod tests {
|
|||||||
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
|
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
|
||||||
);
|
);
|
||||||
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
|
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
|
||||||
|
assert_eq!(ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS, "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS");
|
||||||
assert_eq!(ENV_INTERNODE_HTTP_TUNING_PROFILE, "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE");
|
assert_eq!(ENV_INTERNODE_HTTP_TUNING_PROFILE, "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE");
|
||||||
assert_eq!(ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST, "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST");
|
assert_eq!(ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST, "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST");
|
||||||
assert_eq!(ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS, "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS");
|
assert_eq!(ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS, "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS");
|
||||||
|
|||||||
@@ -137,6 +137,28 @@ pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
|
|||||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
||||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
||||||
|
|
||||||
|
/// Environment variable for remote tier TCP connect timeout in seconds.
|
||||||
|
pub const ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS";
|
||||||
|
/// Default remote tier TCP connect timeout in seconds.
|
||||||
|
pub const DEFAULT_TIER_REMOTE_CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||||
|
|
||||||
|
/// Environment variable for the remote tier request timeout in seconds.
|
||||||
|
///
|
||||||
|
/// This bounds upload/download request progress through response headers. The
|
||||||
|
/// default is intentionally large so multi-TiB transition uploads keep their
|
||||||
|
/// previous production budget while black-hole remotes no longer wait forever.
|
||||||
|
pub const ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS";
|
||||||
|
/// Default remote tier request timeout in seconds.
|
||||||
|
pub const DEFAULT_TIER_REMOTE_REQUEST_TIMEOUT_SECS: u64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// Environment variable for remote tier response-body idle timeout in seconds.
|
||||||
|
///
|
||||||
|
/// The timer is re-armed on every non-empty response-body chunk, so slow but
|
||||||
|
/// progressing remotes can continue while silent response bodies are cancelled.
|
||||||
|
pub const ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS";
|
||||||
|
/// Default remote tier response-body idle timeout in seconds.
|
||||||
|
pub const DEFAULT_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: u64 = 60;
|
||||||
|
|
||||||
/// Request the object-transaction fencing contract used by storage-owned
|
/// Request the object-transaction fencing contract used by storage-owned
|
||||||
/// cleanup receipts and lock-window optimizations.
|
/// cleanup receipts and lock-window optimizations.
|
||||||
///
|
///
|
||||||
@@ -343,13 +365,36 @@ pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: &str =
|
|||||||
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
|
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
|
||||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 0;
|
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 0;
|
||||||
|
|
||||||
/// Time in milliseconds an automatic foreground write waits for a permit.
|
/// Time in milliseconds an automatic foreground direct PutObject waits for a permit.
|
||||||
///
|
///
|
||||||
/// A short wait smooths transient bursts while still returning S3
|
/// A short wait smooths transient bursts while still returning S3
|
||||||
/// `SlowDown`/503 before body ingest when the node is already saturated.
|
/// `SlowDown`/503 before body ingest when the node is already saturated.
|
||||||
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
||||||
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
|
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
|
||||||
|
|
||||||
|
/// Time in milliseconds a multipart UploadPart waits for a foreground write permit.
|
||||||
|
///
|
||||||
|
/// SDK-default multipart clients send every part of an upload concurrently, so
|
||||||
|
/// a single node routinely sees several times more parts in flight than the
|
||||||
|
/// permit pool allows. Those parts have not ingested a body yet, so queueing
|
||||||
|
/// them costs a connection rather than memory or internode streams; the pool
|
||||||
|
/// still bounds the number of parts being written. The wait is long enough for
|
||||||
|
/// an ordinary queue to drain on modest hardware, and a part that cannot get a
|
||||||
|
/// permit within it fails with S3 `SlowDown`/503 for the client to retry.
|
||||||
|
/// `0` rejects immediately when the pool is full.
|
||||||
|
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str =
|
||||||
|
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
||||||
|
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 30_000;
|
||||||
|
|
||||||
|
/// Maximum multipart UploadPart requests waiting for a foreground write permit per process.
|
||||||
|
///
|
||||||
|
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
|
||||||
|
/// waiting, so a genuinely saturated node still fails fast instead of holding
|
||||||
|
/// an unbounded set of connections open for the whole wait timeout.
|
||||||
|
/// `0` derives the depth from the permit limit.
|
||||||
|
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: &str = "RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING";
|
||||||
|
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: usize = 0;
|
||||||
|
|
||||||
const _: () = assert!(DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE);
|
const _: () = assert!(DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE);
|
||||||
|
|
||||||
/// Environment variable for minimum GetObject timeout in seconds.
|
/// Environment variable for minimum GetObject timeout in seconds.
|
||||||
@@ -812,6 +857,16 @@ mod remote_version_state_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_tier_timeout_env_names_are_stable() {
|
||||||
|
assert_eq!(super::ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS");
|
||||||
|
assert_eq!(super::ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS");
|
||||||
|
assert_eq!(
|
||||||
|
super::ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS,
|
||||||
|
"RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
|
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
|
||||||
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
|
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
|||||||
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/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) |
|
| **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`, `tier_stats_cluster_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` |
|
||||||
|
| **distributed 4×4** | [`src/distributed/`](src/distributed) | Storage-sensitive PR and nightly `e2e-distributed` lane: S3, object lock/WORM, versioning, bucket/site replication, quota, expand/decommission/rebalance, concurrency, chaos, 4-node upgrade of historical data and IAM AK/SK. Map: [`docs/testing/distributed-e2e.md`](../../docs/testing/distributed-e2e.md) |
|
||||||
| **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 |
|
| **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 |
|
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ the same profile for membership and execution with one nightly worker.
|
|||||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
||||||
| Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
|
| Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
|
||||||
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
||||||
|
| Distributed 4-node 4-disk (`e2e-distributed` profile) | `.github/workflows/e2e-distributed.yml` | **Active** (storage-sensitive PR / nightly / dispatch) |
|
||||||
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
||||||
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
||||||
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
|
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
|
||||||
@@ -182,6 +184,8 @@ the wiring source of truth. Committed test-ID digests under
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Endpoint blackhole scenario skipped** — `heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_after_target_endpoint_blackhole` installs a loopback `iptables` DROP rule and therefore needs `CAP_NET_ADMIN` (root or passwordless `sudo -n iptables`). A host where `iptables` is missing or cannot read the OUTPUT chain (typical inside an unprivileged container, where the nf_tables backend reports "Permission denied" even under `sudo`) logs a `heal_interruption_skipped` warning and returns without exercising heal. Set `RUSTFS_E2E_REQUIRE_NET_FAULT_INJECTION=1` on lanes that do provision the capability so a broken runner fails instead of skipping.
|
||||||
|
|
||||||
**Reproduce a CI failure locally** — run the exact profile/lane:
|
**Reproduce a CI failure locally** — run the exact profile/lane:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -191,6 +195,9 @@ cargo nextest run --profile e2e-smoke -p e2e_test
|
|||||||
cargo nextest run --profile e2e-full -p e2e_test
|
cargo nextest run --profile e2e-full -p e2e_test
|
||||||
# Cluster fault nightly lane
|
# Cluster fault nightly lane
|
||||||
cargo nextest run --profile e2e-nightly -p e2e_test
|
cargo nextest run --profile e2e-nightly -p e2e_test
|
||||||
|
# 4-node 4-disk distributed lane (S3 / lock / versioning / replication / decommission / chaos / upgrade)
|
||||||
|
# Upgrade cases need RUSTFS_UPGRADE_SOURCE_BINARY; without it they fail closed.
|
||||||
|
cargo nextest run --profile e2e-distributed -p e2e_test
|
||||||
# Replication nightly lane; awscurl is required for STS paths
|
# Replication nightly lane; awscurl is required for STS paths
|
||||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
# Fixed-port protocol nightly lane
|
# Fixed-port protocol nightly lane
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
// Licensed under the Apache License, Version 2.0.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn git(root: &Path, args: &[&str]) -> Option<String> {
|
||||||
|
let output = Command::new("git").args(args).current_dir(root).output().ok()?;
|
||||||
|
output
|
||||||
|
.status
|
||||||
|
.success()
|
||||||
|
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(name: &str, value: &str) {
|
||||||
|
let value = if value.contains(['\n', '\r']) { "unknown" } else { value };
|
||||||
|
println!("cargo:rustc-env=RUSTFS_E2E_BUILD_{name}={value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||||
|
let root = Path::new(&manifest).join("../..");
|
||||||
|
// Cover dependency/common sources as well as this crate. HEAD/ref/index
|
||||||
|
// changes must refresh identity even when no Rust source mtime changes.
|
||||||
|
for path in [
|
||||||
|
"crates",
|
||||||
|
"rustfs",
|
||||||
|
"Cargo.toml",
|
||||||
|
"Cargo.lock",
|
||||||
|
"rust-toolchain.toml",
|
||||||
|
".cargo",
|
||||||
|
".config",
|
||||||
|
] {
|
||||||
|
println!("cargo:rerun-if-changed={}", root.join(path).display());
|
||||||
|
}
|
||||||
|
let mut git_paths = vec!["HEAD".to_owned(), "index".to_owned(), "packed-refs".to_owned()];
|
||||||
|
if let Some(reference) = git(&root, &["symbolic-ref", "-q", "HEAD"]) {
|
||||||
|
git_paths.push(reference);
|
||||||
|
}
|
||||||
|
for path in git_paths {
|
||||||
|
if let Some(path) = git(&root, &["rev-parse", "--git-path", &path]) {
|
||||||
|
let path = Path::new(&path);
|
||||||
|
let path = if path.is_absolute() {
|
||||||
|
path.to_owned()
|
||||||
|
} else {
|
||||||
|
root.join(path)
|
||||||
|
};
|
||||||
|
if path.exists() {
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
|
||||||
|
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=normal"]).is_none_or(|status| !status.is_empty());
|
||||||
|
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
|
||||||
|
let mut features = std::env::vars()
|
||||||
|
.filter_map(|(key, _)| {
|
||||||
|
key.strip_prefix("CARGO_FEATURE_")
|
||||||
|
.map(|name| name.to_ascii_lowercase().replace('_', "-"))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
features.sort();
|
||||||
|
emit("COMMIT", &revision);
|
||||||
|
emit("DIRTY", if dirty { "true" } else { "false" });
|
||||||
|
emit("LOCK", &lock);
|
||||||
|
emit("FEATURES", &features.join(","));
|
||||||
|
for name in ["TARGET", "PROFILE"] {
|
||||||
|
emit(name, &std::env::var(name).unwrap_or_else(|_| "unknown".to_owned()));
|
||||||
|
}
|
||||||
|
println!("cargo:rerun-if-env-changed=CARGO_ENCODED_RUSTFLAGS");
|
||||||
|
let flags = std::env::var("CARGO_ENCODED_RUSTFLAGS").unwrap_or_default();
|
||||||
|
let flags: String = flags.as_bytes().iter().map(|byte| format!("{byte:02x}")).collect();
|
||||||
|
emit("RUSTFLAGS_HEX", &flags);
|
||||||
|
}
|
||||||
@@ -55,18 +55,20 @@ type ChaosResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
|||||||
/// A successful S3 GET only proves that a quorum can serve an object. Replacement
|
/// A successful S3 GET only proves that a quorum can serve an object. Replacement
|
||||||
/// tests need this lower-level record to prove that the rebuilt target holds the
|
/// tests need this lower-level record to prove that the rebuilt target holds the
|
||||||
/// `xl.meta` selected for a specific version and every `part.N` it declares.
|
/// `xl.meta` selected for a specific version and every `part.N` it declares.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
pub(crate) struct VersionShardCensus {
|
pub(crate) struct VersionShardCensus {
|
||||||
pub version_id: Option<String>,
|
pub version_id: Option<String>,
|
||||||
pub has_xl_meta: bool,
|
pub has_xl_meta: bool,
|
||||||
pub data_dir: Option<String>,
|
pub data_dir: Option<String>,
|
||||||
pub erasure_index: Option<usize>,
|
pub erasure_index: Option<usize>,
|
||||||
|
pub data_blocks: Option<usize>,
|
||||||
|
pub parity_blocks: Option<usize>,
|
||||||
pub expected_part_numbers: BTreeSet<usize>,
|
pub expected_part_numbers: BTreeSet<usize>,
|
||||||
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>,
|
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>,
|
||||||
pub inline_data_fingerprint: Option<PartShardFingerprint>,
|
pub inline_data_fingerprint: Option<PartShardFingerprint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
pub(crate) struct PartShardFingerprint {
|
pub(crate) struct PartShardFingerprint {
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
@@ -88,13 +90,15 @@ impl VersionShardCensus {
|
|||||||
&& manifest.is_complete()
|
&& manifest.is_complete()
|
||||||
&& self.data_dir == manifest.data_dir
|
&& self.data_dir == manifest.data_dir
|
||||||
&& self.erasure_index == manifest.erasure_index
|
&& self.erasure_index == manifest.erasure_index
|
||||||
|
&& self.data_blocks == manifest.data_blocks
|
||||||
|
&& self.parity_blocks == manifest.parity_blocks
|
||||||
&& self.expected_part_numbers == manifest.expected_part_numbers
|
&& self.expected_part_numbers == manifest.expected_part_numbers
|
||||||
&& self.present_part_fingerprints == manifest.present_part_fingerprints
|
&& self.present_part_fingerprints == manifest.present_part_fingerprints
|
||||||
&& self.inline_data_fingerprint == manifest.inline_data_fingerprint
|
&& self.inline_data_fingerprint == manifest.inline_data_fingerprint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sha256_hex(data: &[u8]) -> String {
|
pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
||||||
let digest = Sha256::digest(data);
|
let digest = Sha256::digest(data);
|
||||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
}
|
}
|
||||||
@@ -313,6 +317,8 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
has_xl_meta: false,
|
has_xl_meta: false,
|
||||||
data_dir: None,
|
data_dir: None,
|
||||||
erasure_index: None,
|
erasure_index: None,
|
||||||
|
data_blocks: None,
|
||||||
|
parity_blocks: None,
|
||||||
expected_part_numbers: BTreeSet::new(),
|
expected_part_numbers: BTreeSet::new(),
|
||||||
present_part_fingerprints: BTreeMap::new(),
|
present_part_fingerprints: BTreeMap::new(),
|
||||||
inline_data_fingerprint: None,
|
inline_data_fingerprint: None,
|
||||||
@@ -360,6 +366,8 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
has_xl_meta: true,
|
has_xl_meta: true,
|
||||||
data_dir,
|
data_dir,
|
||||||
erasure_index,
|
erasure_index,
|
||||||
|
data_blocks: Some(file_info.erasure.data_blocks),
|
||||||
|
parity_blocks: Some(file_info.erasure.parity_blocks),
|
||||||
expected_part_numbers,
|
expected_part_numbers,
|
||||||
present_part_fingerprints,
|
present_part_fingerprints,
|
||||||
inline_data_fingerprint,
|
inline_data_fingerprint,
|
||||||
@@ -413,6 +421,8 @@ mod tests {
|
|||||||
has_xl_meta: true,
|
has_xl_meta: true,
|
||||||
data_dir: Some("data-dir".to_string()),
|
data_dir: Some("data-dir".to_string()),
|
||||||
erasure_index: Some(3),
|
erasure_index: Some(3),
|
||||||
|
data_blocks: Some(2),
|
||||||
|
parity_blocks: Some(2),
|
||||||
expected_part_numbers: BTreeSet::from([1]),
|
expected_part_numbers: BTreeSet::from([1]),
|
||||||
present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]),
|
present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]),
|
||||||
inline_data_fingerprint: None,
|
inline_data_fingerprint: None,
|
||||||
|
|||||||
@@ -1700,6 +1700,69 @@ impl RustFSTestClusterEnvironment {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append a new single-node erasure pool to a stopped multi-pool cluster.
|
||||||
|
///
|
||||||
|
/// Used to simulate pool expansion on localhost: every pool already owns
|
||||||
|
/// exactly one node with `drives_per_node >= 2` (the only multi-pool layout
|
||||||
|
/// the single-host `RUSTFS_VOLUMES` syntax can express). The new node is
|
||||||
|
/// allocated a fresh port and empty drive directories; callers must
|
||||||
|
/// [`Self::start`] afterwards so every process picks up the extended
|
||||||
|
/// volumes argument. Existing data directories are left untouched.
|
||||||
|
pub async fn append_single_node_pool(&mut self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
if self.nodes.iter().any(|node| node.process.is_some()) {
|
||||||
|
return Err("stop the cluster before appending a pool".into());
|
||||||
|
}
|
||||||
|
if self.topology.drives_per_node < 2 {
|
||||||
|
return Err(
|
||||||
|
"append_single_node_pool requires drives_per_node >= 2 (the server parser rejects a single-drive ellipses pool)"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pools = self.topology.normalized_pools();
|
||||||
|
for (pool_idx, nodes) in pools.iter().enumerate() {
|
||||||
|
if nodes.len() != 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"pool {pool_idx} spans {} nodes; append_single_node_pool requires one node per pool",
|
||||||
|
nodes.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_idx = self.nodes.len();
|
||||||
|
let port = RustFSTestEnvironment::find_available_port().await?;
|
||||||
|
let address = format!("127.0.0.1:{port}");
|
||||||
|
let data_dirs: Vec<String> = (0..self.topology.drives_per_node)
|
||||||
|
.map(|drive| format!("{}/node{}/drive{}", self.temp_dir, new_idx, drive))
|
||||||
|
.collect();
|
||||||
|
for dir in &data_dirs {
|
||||||
|
fs::create_dir_all(dir).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.nodes.push(ClusterNode {
|
||||||
|
url: format!("http://{address}"),
|
||||||
|
address,
|
||||||
|
data_dir: data_dirs[0].clone(),
|
||||||
|
data_dirs,
|
||||||
|
pool_idx: pools.len(),
|
||||||
|
process: None,
|
||||||
|
});
|
||||||
|
pools.push(vec![new_idx]);
|
||||||
|
self.topology.node_count = self.nodes.len();
|
||||||
|
self.topology.pools = pools;
|
||||||
|
self.node_extra_env.push(Vec::new());
|
||||||
|
self.node_capture_log_paths.push(None);
|
||||||
|
self.volume_proxy_addresses.push(None);
|
||||||
|
|
||||||
|
if !self.extra_env.iter().any(|(key, _)| key == "RUSTFS_UNSAFE_BYPASS_DISK_CHECK") {
|
||||||
|
self.extra_env
|
||||||
|
.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(new_idx)
|
||||||
|
}
|
||||||
|
|
||||||
/// Gracefully stop one cluster node and wait for its process to exit.
|
/// Gracefully stop one cluster node and wait for its process to exit.
|
||||||
///
|
///
|
||||||
/// This is intentionally separate from [`Self::stop_node`]: the latter is
|
/// This is intentionally separate from [`Self::stop_node`]: the latter is
|
||||||
|
|||||||
@@ -35,11 +35,15 @@ where
|
|||||||
{
|
{
|
||||||
let mut last_usage = DataUsageInfo::default();
|
let mut last_usage = DataUsageInfo::default();
|
||||||
let mut last_query_error = None;
|
let mut last_query_error = None;
|
||||||
for _ in 0..45 {
|
for _ in 0..90 {
|
||||||
match get_data_usage_info(env).await {
|
match get_data_usage_info(env).await {
|
||||||
Ok(usage) => {
|
Ok(usage) => {
|
||||||
last_query_error = None;
|
last_query_error = None;
|
||||||
if usage.buckets_usage.contains_key(bucket) && predicate(&usage) {
|
if usage.is_complete_bucket_usage_snapshot()
|
||||||
|
&& usage.usage_snapshot_converged != Some(false)
|
||||||
|
&& usage.buckets_usage.contains_key(bucket)
|
||||||
|
&& predicate(&usage)
|
||||||
|
{
|
||||||
return Ok(usage);
|
return Ok(usage);
|
||||||
}
|
}
|
||||||
last_usage = usage;
|
last_usage = usage;
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
|
||||||
|
wait_for_ready, wait_until,
|
||||||
|
};
|
||||||
|
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
|
||||||
|
use crate::common::{build_test_s3_config, init_logging};
|
||||||
|
use crate::fault_proxy::FaultMode;
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::{Barrier, mpsc};
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("killnode");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let body = vec![0x11u8; 128 * 1024];
|
||||||
|
put_object(&dist.client(0)?, &bucket, "keep.bin", body.clone()).await?;
|
||||||
|
|
||||||
|
dist.cluster.stop_node(3)?;
|
||||||
|
retrying_get_equals(&dist.client(0)?, &bucket, "keep.bin", &body, Duration::from_secs(20)).await?;
|
||||||
|
|
||||||
|
dist.cluster.start_node(3).await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
assert_object_bytes(&dist.client(3)?, &bucket, "keep.bin", &body).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_cluster_restart_preserves_objects() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("pwr");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let body = vec![0x44u8; 64 * 1024];
|
||||||
|
put_object(&dist.client(1)?, &bucket, "survive.bin", body.clone()).await?;
|
||||||
|
|
||||||
|
dist.cluster.stop();
|
||||||
|
dist.cluster.start().await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
assert_object_bytes(&dist.client(node_idx)?, &bucket, "survive.bin", &body).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fresh_drive_replacement_is_physically_healed_without_data_change() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_HEAL_ENABLED", "true")]).await?;
|
||||||
|
let bucket = unique_bucket("baddrive");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let body = payload_for("fresh-drive/durable.bin", 8 * 1024 * 1024);
|
||||||
|
put_object(&dist.client(1)?, &bucket, "durable.bin", body.clone()).await?;
|
||||||
|
|
||||||
|
let replaced_drive = PathBuf::from(&dist.cluster.nodes[0].data_dirs[0]);
|
||||||
|
let baseline = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||||
|
assert!(
|
||||||
|
baseline.is_complete(),
|
||||||
|
"replacement target did not hold a complete baseline shard: {baseline:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!baseline.expected_part_numbers.is_empty(),
|
||||||
|
"replacement witness must use physical part shards: {baseline:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
dist.cluster.stop_node(0)?;
|
||||||
|
let format_path = replaced_drive.join(".rustfs.sys/format.json");
|
||||||
|
let format = std::fs::read(&format_path)?;
|
||||||
|
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
|
||||||
|
std::fs::rename(&replaced_drive, &retired_drive)?;
|
||||||
|
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path omitted parent")?)?;
|
||||||
|
std::fs::write(&format_path, format)?;
|
||||||
|
let empty = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||||
|
assert!(!empty.has_xl_meta, "fresh replacement unexpectedly retained object metadata: {empty:?}");
|
||||||
|
|
||||||
|
dist.cluster.start_node(0).await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
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/{bucket}?forceStart=true", dist.cluster.nodes[1].url);
|
||||||
|
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(90),
|
||||||
|
|| async {
|
||||||
|
let healed = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||||
|
Ok(healed.matches_manifest(&baseline))
|
||||||
|
},
|
||||||
|
"fresh replacement contains the original complete shard manifest",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
assert_object_bytes(&dist.client(node_idx)?, &bucket, "durable.bin", &body).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_gets_survive_peer_node_kill() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("getkill");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let body = payload_for("inflight/steady.bin", 8 * 1024 * 1024);
|
||||||
|
put_object(&dist.client(0)?, &bucket, "steady.bin", body.clone()).await?;
|
||||||
|
|
||||||
|
let live: Vec<_> = (0..3).map(|idx| dist.client(idx)).collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let worker_count = 12;
|
||||||
|
let release = Arc::new(Barrier::new(worker_count + 1));
|
||||||
|
let (started_tx, mut started_rx) = mpsc::unbounded_channel();
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for idx in 0..worker_count {
|
||||||
|
let client = live[idx % live.len()].clone();
|
||||||
|
let bucket = bucket.clone();
|
||||||
|
let body = body.clone();
|
||||||
|
let release = release.clone();
|
||||||
|
let started_tx = started_tx.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
let response = client.get_object().bucket(&bucket).key("steady.bin").send().await?;
|
||||||
|
if response.content_length() != Some(body.len() as i64) {
|
||||||
|
return Err::<(), Box<dyn std::error::Error + Send + Sync>>(
|
||||||
|
format!("worker {idx} received a wrong content length").into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
started_tx.send(idx)?;
|
||||||
|
release.wait().await;
|
||||||
|
let actual = response.body.collect().await?.into_bytes();
|
||||||
|
if actual.as_ref() != body.as_slice() {
|
||||||
|
return Err(format!("worker {idx} received corrupted bytes after peer kill").into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
drop(started_tx);
|
||||||
|
for _ in 0..worker_count {
|
||||||
|
timeout(Duration::from_secs(30), started_rx.recv())
|
||||||
|
.await?
|
||||||
|
.ok_or("a streaming GET exited before reaching the kill barrier")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
dist.cluster.stop_node(3)?;
|
||||||
|
release.wait().await;
|
||||||
|
for handle in handles {
|
||||||
|
handle.await??;
|
||||||
|
}
|
||||||
|
|
||||||
|
dist.cluster.start_node(3).await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
assert_object_bytes(&dist.client(3)?, &bucket, "steady.bin", &body).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn blackholed_node_client_network_preserves_cluster_availability_and_recovers() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let proxy = crate::fault_proxy::FaultProxy::start(dist.cluster.nodes[3].address.parse()?).await?;
|
||||||
|
let proxied_url = format!("http://{}", proxy.local_addr());
|
||||||
|
let proxied_client = Client::from_conf(build_test_s3_config(
|
||||||
|
&proxied_url,
|
||||||
|
&dist.cluster.access_key,
|
||||||
|
&dist.cluster.secret_key,
|
||||||
|
None,
|
||||||
|
"distributed-network-chaos",
|
||||||
|
));
|
||||||
|
|
||||||
|
let result: TestResult = async {
|
||||||
|
let bucket = unique_bucket("netfault");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let baseline = payload_for("network/baseline.bin", 1024 * 1024);
|
||||||
|
put_object(&dist.client(0)?, &bucket, "baseline.bin", baseline.clone()).await?;
|
||||||
|
assert_object_bytes(&proxied_client, &bucket, "baseline.bin", &baseline).await?;
|
||||||
|
|
||||||
|
proxy.set_mode(FaultMode::Blackhole);
|
||||||
|
assert_eq!(proxy.mode(), FaultMode::Blackhole);
|
||||||
|
if let Ok(Ok(_)) = timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
proxied_client.get_object().bucket(&bucket).key("baseline.bin").send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err("blackholed node endpoint unexpectedly completed a GET".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let during = payload_for("network/during.bin", 1024 * 1024);
|
||||||
|
timeout(Duration::from_secs(30), async {
|
||||||
|
put_object(&dist.client(1)?, &bucket, "during-blackhole.bin", during.clone()).await?;
|
||||||
|
assert_object_bytes(&dist.client(2)?, &bucket, "baseline.bin", &baseline).await?;
|
||||||
|
assert_object_bytes(&dist.client(0)?, &bucket, "during-blackhole.bin", &during).await?;
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
proxy.set_mode(FaultMode::Pass);
|
||||||
|
retrying_get_equals(&proxied_client, &bucket, "during-blackhole.bin", &during, Duration::from_secs(30)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
proxy.set_mode(FaultMode::Pass);
|
||||||
|
proxy.shutdown().await;
|
||||||
|
result
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, unique_bucket};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Barrier;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("conc");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let clients = Arc::new(dist.clients()?);
|
||||||
|
let worker_count = 24;
|
||||||
|
let rounds = 4;
|
||||||
|
let barrier = Arc::new(Barrier::new(worker_count));
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for idx in 0..worker_count {
|
||||||
|
let clients = clients.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
let bucket = bucket.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
barrier.wait().await;
|
||||||
|
let writer = &clients[idx % clients.len()];
|
||||||
|
let reader = &clients[(idx + 1) % clients.len()];
|
||||||
|
let copier = &clients[(idx + 2) % clients.len()];
|
||||||
|
let mut retained = Vec::with_capacity(rounds);
|
||||||
|
for round in 0..rounds {
|
||||||
|
let key = format!("source/worker-{idx:02}-round-{round}.bin");
|
||||||
|
let copy_key = format!("retained/worker-{idx:02}-round-{round}.bin");
|
||||||
|
let body = payload_for(&key, 64 * 1024);
|
||||||
|
put_object(writer, &bucket, &key, body.clone()).await?;
|
||||||
|
|
||||||
|
let head = reader.head_object().bucket(&bucket).key(&key).send().await?;
|
||||||
|
if head.content_length() != Some(body.len() as i64) {
|
||||||
|
return Err(format!("HEAD returned the wrong size for {key}: {head:?}").into());
|
||||||
|
}
|
||||||
|
assert_object_bytes(reader, &bucket, &key, &body).await?;
|
||||||
|
|
||||||
|
copier
|
||||||
|
.copy_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(©_key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_object_bytes(writer, &bucket, ©_key, &body).await?;
|
||||||
|
|
||||||
|
writer.delete_object().bucket(&bucket).key(&key).send().await?;
|
||||||
|
let missing = reader
|
||||||
|
.head_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(&key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("deleted source key must not remain visible");
|
||||||
|
if missing.raw_response().map(|response| response.status().as_u16()) != Some(404) {
|
||||||
|
return Err(format!("deleted source {key} returned an unexpected result: {missing:?}").into());
|
||||||
|
}
|
||||||
|
retained.push((copy_key, body));
|
||||||
|
}
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(retained)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inventory = Vec::new();
|
||||||
|
for handle in handles {
|
||||||
|
inventory.extend(handle.await??);
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_keys: BTreeSet<_> = inventory.iter().map(|(key, _)| key.as_str()).collect();
|
||||||
|
for (node_idx, client) in clients.iter().enumerate() {
|
||||||
|
let listed = client.list_objects_v2().bucket(&bucket).prefix("retained/").send().await?;
|
||||||
|
let listed_keys: BTreeSet<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
|
||||||
|
assert_eq!(listed_keys, expected_keys, "node {node_idx} returned a divergent retained-key listing");
|
||||||
|
for (key, body) in &inventory {
|
||||||
|
assert_object_bytes(client, &bucket, key, body)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("node {node_idx} failed to read {key}: {error}"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||||
|
decommission_status_json, payload_for, put_inventory_retrying, retrying_get_equals, retrying_put, start_decommission,
|
||||||
|
unique_bucket, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Barrier;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_puts_during_decommission_do_not_lose_baseline_or_new_objects() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("concdecom");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let baseline_client = dist.client(0)?;
|
||||||
|
let inventory = put_inventory_retrying(&baseline_client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||||
|
dist.expand_to_four_pools().await?;
|
||||||
|
|
||||||
|
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||||
|
|
||||||
|
let clients = Arc::new(dist.clients()?);
|
||||||
|
let barrier = Arc::new(Barrier::new(17));
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for idx in 0..16 {
|
||||||
|
let clients = clients.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
let bucket = bucket.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
barrier.wait().await;
|
||||||
|
let client = &clients[idx % clients.len()];
|
||||||
|
let key = format!("live/{idx:02}.bin");
|
||||||
|
let body = payload_for(&key, 8 * 1024);
|
||||||
|
retrying_put(client, &bucket, &key, body.clone(), Duration::from_secs(45)).await?;
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((key, body))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||||
|
barrier.wait().await;
|
||||||
|
|
||||||
|
let mut live_objects = Vec::new();
|
||||||
|
for handle in handles {
|
||||||
|
live_objects.push(handle.await??);
|
||||||
|
}
|
||||||
|
let status = decommission_status_json(&dist.cluster).await?;
|
||||||
|
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
|
||||||
|
return Err(format!("decommission did not remain active across concurrent PUTs: {status}").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||||
|
|
||||||
|
let checker = dist.client(2)?;
|
||||||
|
assert_inventory(&checker, &bucket, &inventory).await?;
|
||||||
|
for (key, body) in live_objects {
|
||||||
|
retrying_get_equals(&checker, &bucket, &key, &body, Duration::from_secs(30)).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, enable_versioning, put_inventory_retrying,
|
||||||
|
sha256_hex, start_decommission, unique_bucket, wait_for_decommission_active, wait_for_decommission_complete,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("integrity");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
enable_versioning(&client, &bucket).await?;
|
||||||
|
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||||
|
let before: Vec<(String, String)> = inventory.iter().map(|(key, body)| (key.clone(), sha256_hex(body))).collect();
|
||||||
|
|
||||||
|
let versioned_key = "history/versioned.bin";
|
||||||
|
let version_one = b"historical bytes before data movement".to_vec();
|
||||||
|
let version_two = b"current bytes before data movement".to_vec();
|
||||||
|
let version_one_id = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from(version_one.clone()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("historical PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let version_two_id = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from(version_two.clone()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("current PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let multipart_key = "multipart/moved.bin";
|
||||||
|
let first_part = vec![0x31; 5 * 1024 * 1024];
|
||||||
|
let second_part = vec![0x72; 1024 * 1024];
|
||||||
|
let upload = client
|
||||||
|
.create_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let upload_id = upload.upload_id().ok_or("movement multipart upload omitted upload ID")?;
|
||||||
|
let uploaded_one = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(1)
|
||||||
|
.body(ByteStream::from(first_part.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let uploaded_two = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(2)
|
||||||
|
.body(ByteStream::from(second_part.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
client
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.multipart_upload(
|
||||||
|
CompletedMultipartUpload::builder()
|
||||||
|
.parts(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(1)
|
||||||
|
.e_tag(uploaded_one.e_tag().ok_or("movement part 1 omitted ETag")?)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.parts(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(2)
|
||||||
|
.e_tag(uploaded_two.e_tag().ok_or("movement part 2 omitted ETag")?)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
dist.expand_to_four_pools().await?;
|
||||||
|
|
||||||
|
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||||
|
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||||
|
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||||
|
|
||||||
|
let after_client = dist.client(2)?;
|
||||||
|
assert_inventory(&after_client, &bucket, &inventory).await?;
|
||||||
|
for (key, expected_hash) in before {
|
||||||
|
let got = after_client.get_object().bucket(&bucket).key(&key).send().await?;
|
||||||
|
let body = got.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(sha256_hex(body.as_ref()), expected_hash, "checksum changed for {key} after decommission");
|
||||||
|
}
|
||||||
|
for (version_id, expected) in [(&version_one_id, &version_one), (&version_two_id, &version_two)] {
|
||||||
|
let got = after_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.version_id(version_id)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(got.as_ref(), expected.as_slice(), "version {version_id} changed after decommission");
|
||||||
|
}
|
||||||
|
let mut expected_multipart = first_part;
|
||||||
|
expected_multipart.extend_from_slice(&second_part);
|
||||||
|
let got_multipart = after_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(
|
||||||
|
sha256_hex(got_multipart.as_ref()),
|
||||||
|
sha256_hex(&expected_multipart),
|
||||||
|
"multipart checksum changed after decommission"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, list_pools_json, put_inventory,
|
||||||
|
put_inventory_retrying, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_active,
|
||||||
|
wait_for_decommission_complete, wait_for_rebalance_active, wait_for_rebalance_complete,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("expand");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
let inventory = put_inventory(&client, &bucket, 64, 256 * 1024).await?;
|
||||||
|
assert_inventory(&client, &bucket, &inventory).await?;
|
||||||
|
|
||||||
|
for expected_nodes in 2..=4 {
|
||||||
|
let new_node = dist.append_pool_and_restart().await?;
|
||||||
|
assert_eq!(new_node + 1, expected_nodes);
|
||||||
|
assert_inventory(&dist.client(new_node)?, &bucket, &inventory).await?;
|
||||||
|
}
|
||||||
|
assert_eq!(dist.cluster.nodes.len(), 4);
|
||||||
|
|
||||||
|
// Prove that the expanded pool map is durable, and clear any recovery
|
||||||
|
// latch raised while the newly-added pool replicas converged.
|
||||||
|
dist.restart_current_binary_gracefully().await?;
|
||||||
|
|
||||||
|
let after_expand = dist.client(0)?;
|
||||||
|
assert_inventory(&after_expand, &bucket, &inventory).await?;
|
||||||
|
let peer = dist.client(3)?;
|
||||||
|
assert_inventory(&peer, &bucket, &inventory).await?;
|
||||||
|
|
||||||
|
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||||
|
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||||
|
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||||
|
assert_inventory(&peer, &bucket, &inventory).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_pool_decommission_moves_objects_without_loss() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("decom");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
let inventory = put_inventory_retrying(&client, &bucket, 96, 128 * 1024, Duration::from_secs(30)).await?;
|
||||||
|
dist.expand_to_four_pools().await?;
|
||||||
|
|
||||||
|
let pools_before = list_pools_json(&dist.cluster).await?;
|
||||||
|
let pool_count = pools_before
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len)
|
||||||
|
.or_else(|| pools_before.get("pools").and_then(serde_json::Value::as_array).map(Vec::len))
|
||||||
|
.ok_or_else(|| format!("pool list omitted an array: {pools_before}"))?;
|
||||||
|
assert_eq!(pool_count, 4, "expected exactly four pools before decommission: {pools_before}");
|
||||||
|
|
||||||
|
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||||
|
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||||
|
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||||
|
|
||||||
|
let after = dist.client(2)?;
|
||||||
|
assert_inventory(&after, &bucket, &inventory).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket, wait_until,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_multipart_and_cross_node_listing_agree() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("extra");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
|
||||||
|
let key = "multipart.bin";
|
||||||
|
let part1 = vec![0x41u8; 5 * 1024 * 1024];
|
||||||
|
let part2 = vec![0x42u8; 5 * 1024 * 1024];
|
||||||
|
let upload = client.create_multipart_upload().bucket(&bucket).key(key).send().await?;
|
||||||
|
let upload_id = upload.upload_id().ok_or("missing upload id")?.to_string();
|
||||||
|
|
||||||
|
let uploaded1 = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(&upload_id)
|
||||||
|
.part_number(1)
|
||||||
|
.body(ByteStream::from(part1.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let uploaded2 = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(&upload_id)
|
||||||
|
.part_number(2)
|
||||||
|
.body(ByteStream::from(part2.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.upload_id(&upload_id)
|
||||||
|
.multipart_upload(
|
||||||
|
CompletedMultipartUpload::builder()
|
||||||
|
.parts(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(1)
|
||||||
|
.e_tag(uploaded1.e_tag().unwrap_or_default())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.parts(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(2)
|
||||||
|
.e_tag(uploaded2.e_tag().unwrap_or_default())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut expected = part1;
|
||||||
|
expected.extend_from_slice(&part2);
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
assert_object_bytes(&dist.client(node_idx)?, &bucket, key, &expected).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
put_object(&client, &bucket, "list/a", b"a".to_vec()).await?;
|
||||||
|
put_object(&dist.client(2)?, &bucket, "list/b", b"b".to_vec()).await?;
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
let listed = dist
|
||||||
|
.client(node_idx)?
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.prefix("list/")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let keys: Vec<String> = listed
|
||||||
|
.contents()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|object| object.key().map(str::to_string))
|
||||||
|
.collect();
|
||||||
|
seen.push(keys);
|
||||||
|
}
|
||||||
|
for keys in &seen[1..] {
|
||||||
|
assert_eq!(&seen[0], keys, "list results diverged across nodes: {seen:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let got = get_object_bytes(&dist.client(3)?, &bucket, "list/a").await?;
|
||||||
|
assert_eq!(got, b"a");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_list_buckets_agree_across_all_nodes() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("listed");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
put_object(&dist.client(0)?, &bucket, "seed.bin", b"seed".to_vec()).await?;
|
||||||
|
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
let client = dist.client(node_idx)?;
|
||||||
|
let name = bucket.clone();
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(20),
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let name = name.clone();
|
||||||
|
async move {
|
||||||
|
let listed = client.list_buckets().send().await?;
|
||||||
|
Ok(listed.buckets().iter().any(|entry| entry.name() == Some(name.as_str())))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
&format!("node {node_idx} lists {bucket}"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(20),
|
||||||
|
|| {
|
||||||
|
let client = dist.client(node_idx).expect("client");
|
||||||
|
let name = bucket.clone();
|
||||||
|
async move { Ok(get_object_bytes(&client, &name, "seed.bin").await.ok() == Some(b"seed".to_vec())) }
|
||||||
|
},
|
||||||
|
&format!("node {node_idx} reads seed.bin"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! 4-node 4-drive distributed e2e coverage.
|
||||||
|
//!
|
||||||
|
//! Selected by `[profile.e2e-distributed]` and run from
|
||||||
|
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
|
||||||
|
//! each case starts four real `rustfs` processes.
|
||||||
|
|
||||||
|
mod chaos_test;
|
||||||
|
mod concurrency_stability_test;
|
||||||
|
mod concurrent_data_movement_test;
|
||||||
|
mod data_integrity_movement_test;
|
||||||
|
mod expand_decommission_rebalance_test;
|
||||||
|
mod extra_test;
|
||||||
|
mod harness;
|
||||||
|
mod object_lock_test;
|
||||||
|
mod observability_test;
|
||||||
|
mod replication_quota_test;
|
||||||
|
mod s3_basic_test;
|
||||||
|
mod s3_during_data_movement_test;
|
||||||
|
mod site_replication_test;
|
||||||
|
mod upgrade_test;
|
||||||
|
mod versioning_test;
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use crate::object_lock::common::{
|
||||||
|
delete_object_with_bypass, put_object_lock_configuration, put_object_with_legal_hold, put_object_with_retention,
|
||||||
|
};
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::error::SdkError;
|
||||||
|
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{
|
||||||
|
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
|
||||||
|
ObjectLockRule,
|
||||||
|
};
|
||||||
|
use chrono::{Duration as ChronoDuration, Utc};
|
||||||
|
|
||||||
|
fn delete_denied(error: &SdkError<DeleteObjectError>, context: &str) -> TestResult {
|
||||||
|
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||||
|
if code == Some("AccessDenied") {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{context}: expected AccessDenied, got {error:?}").into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn expect_versioned_delete_denied(
|
||||||
|
client: &Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
version_id: &str,
|
||||||
|
bypass: bool,
|
||||||
|
context: &str,
|
||||||
|
) -> TestResult {
|
||||||
|
match delete_object_with_bypass(client, bucket, key, Some(version_id), bypass).await {
|
||||||
|
Ok(_) => Err(format!("{context}: DeleteObject of retained version must be denied").into()),
|
||||||
|
Err(error) => delete_denied(error.as_ref(), context),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_object_lock_worm_blocks_delete() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
let peer = dist.client(2)?;
|
||||||
|
let bucket = unique_bucket("objlock");
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.object_lock_enabled_for_bucket(true)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let retain_until = Utc::now() + ChronoDuration::days(1);
|
||||||
|
|
||||||
|
let compliance_key = "compliance.bin";
|
||||||
|
let compliance_version = put_object_with_retention(
|
||||||
|
&client,
|
||||||
|
&bucket,
|
||||||
|
compliance_key,
|
||||||
|
b"locked-compliance",
|
||||||
|
ObjectLockRetentionMode::Compliance,
|
||||||
|
retain_until,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Unversioned DELETE is allowed: it only creates a delete marker. WORM
|
||||||
|
// applies to a specific version id.
|
||||||
|
let marker = peer.delete_object().bucket(&bucket).key(compliance_key).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
marker.delete_marker(),
|
||||||
|
Some(true),
|
||||||
|
"unversioned DELETE on a locked object must create a delete marker"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, false, "COMPLIANCE without bypass")
|
||||||
|
.await?;
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, true, "COMPLIANCE with bypass").await?;
|
||||||
|
|
||||||
|
let governance_key = "governance.bin";
|
||||||
|
let governance_version = put_object_with_retention(
|
||||||
|
&client,
|
||||||
|
&bucket,
|
||||||
|
governance_key,
|
||||||
|
b"locked-governance",
|
||||||
|
ObjectLockRetentionMode::Governance,
|
||||||
|
retain_until,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, governance_key, &governance_version, false, "GOVERNANCE without bypass")
|
||||||
|
.await?;
|
||||||
|
delete_object_with_bypass(&peer, &bucket, governance_key, Some(&governance_version), true).await?;
|
||||||
|
let deleted_governance = peer
|
||||||
|
.head_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(governance_key)
|
||||||
|
.version_id(&governance_version)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("GOVERNANCE bypass must remove the retained version");
|
||||||
|
assert_eq!(
|
||||||
|
deleted_governance.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"deleted GOVERNANCE version returned an unexpected HEAD result: {deleted_governance:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let hold_key = "legal-hold.bin";
|
||||||
|
let hold_version =
|
||||||
|
put_object_with_legal_hold(&client, &bucket, hold_key, b"legal-hold", ObjectLockLegalHoldStatus::On).await?;
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, false, "legal hold without bypass").await?;
|
||||||
|
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, true, "legal hold with bypass").await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_default_retention_is_visible_and_non_lock_bucket_rejects_configuration() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
let bucket = unique_bucket("default-lock");
|
||||||
|
|
||||||
|
writer
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.object_lock_enabled_for_bucket(true)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
put_object_lock_configuration(&writer, &bucket, ObjectLockRetentionMode::Governance, Some(1), None).await?;
|
||||||
|
|
||||||
|
let key = "default-governance.bin";
|
||||||
|
let put = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(b"default retention payload"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let version_id = put.version_id().ok_or("default-retained PUT omitted version ID")?;
|
||||||
|
|
||||||
|
let config = reader.get_object_lock_configuration().bucket(&bucket).send().await?;
|
||||||
|
let default_retention = config
|
||||||
|
.object_lock_configuration()
|
||||||
|
.and_then(|configuration| configuration.rule())
|
||||||
|
.and_then(|rule| rule.default_retention())
|
||||||
|
.ok_or("GetObjectLockConfiguration omitted default retention")?;
|
||||||
|
assert_eq!(default_retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
|
||||||
|
assert_eq!(default_retention.days(), Some(1));
|
||||||
|
|
||||||
|
let retention = reader
|
||||||
|
.get_object_retention()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(version_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let retention = retention.retention().ok_or("GetObjectRetention omitted applied retention")?;
|
||||||
|
assert_eq!(retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
|
||||||
|
let retain_until = retention
|
||||||
|
.retain_until_date()
|
||||||
|
.ok_or("default retention omitted retain-until date")?;
|
||||||
|
assert!(retain_until.secs() > Utc::now().timestamp(), "default retention is not in the future");
|
||||||
|
|
||||||
|
let versioning = reader.get_bucket_versioning().bucket(&bucket).send().await?;
|
||||||
|
assert_eq!(versioning.status().map(|status| status.as_str()), Some("Enabled"));
|
||||||
|
expect_versioned_delete_denied(&reader, &bucket, key, version_id, false, "default GOVERNANCE retention without bypass")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let plain_bucket = unique_bucket("no-lock");
|
||||||
|
dist.create_bucket(&plain_bucket).await?;
|
||||||
|
let configuration = ObjectLockConfiguration::builder()
|
||||||
|
.object_lock_enabled(ObjectLockEnabled::Enabled)
|
||||||
|
.rule(
|
||||||
|
ObjectLockRule::builder()
|
||||||
|
.default_retention(
|
||||||
|
DefaultRetention::builder()
|
||||||
|
.mode(ObjectLockRetentionMode::Governance)
|
||||||
|
.days(1)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
let error = writer
|
||||||
|
.put_object_lock_configuration()
|
||||||
|
.bucket(&plain_bucket)
|
||||||
|
.object_lock_configuration(configuration)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an unversioned bucket must reject Object Lock enablement");
|
||||||
|
let service_error = error
|
||||||
|
.as_service_error()
|
||||||
|
.ok_or("non-lock bucket rejection was not an S3 service error")?;
|
||||||
|
assert_eq!(service_error.code(), Some("InvalidBucketState"), "unexpected error: {error:?}");
|
||||||
|
assert_eq!(
|
||||||
|
service_error.message(),
|
||||||
|
Some("Object Lock configuration cannot be enabled on existing buckets"),
|
||||||
|
"unexpected error: {error:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, cluster_admin_ok, unique_bucket, wait_for_ready};
|
||||||
|
use crate::common::{admin_request, init_logging, local_http_client};
|
||||||
|
use aws_sdk_s3::operation::RequestId;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use http::Method;
|
||||||
|
use http_body_util::{BodyExt, Empty};
|
||||||
|
use hyper::body::Incoming;
|
||||||
|
use hyper::service::service_fn;
|
||||||
|
use hyper::{Request, Response};
|
||||||
|
use hyper_util::rt::TokioIo;
|
||||||
|
use local_ip_address::local_ip;
|
||||||
|
use rustfs_madmin::metrics::RealtimeMetrics;
|
||||||
|
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio::time::{Instant, timeout};
|
||||||
|
|
||||||
|
async fn spawn_audit_collector() -> TestResult<(String, mpsc::UnboundedReceiver<Value>, JoinHandle<()>)> {
|
||||||
|
let listener = TcpListener::bind("0.0.0.0:0").await?;
|
||||||
|
let endpoint = format!("http://{}/audit", std::net::SocketAddr::new(local_ip()?, listener.local_addr()?.port()));
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((stream, _)) = listener.accept().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let tx = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let service = service_fn(move |request: Request<Incoming>| {
|
||||||
|
let tx = tx.clone();
|
||||||
|
async move {
|
||||||
|
let method = request.method().clone();
|
||||||
|
if let Ok(body) = request.into_body().collect().await
|
||||||
|
&& method == Method::POST
|
||||||
|
&& let Ok(payload) = serde_json::from_slice::<Value>(&body.to_bytes())
|
||||||
|
{
|
||||||
|
if let Some(records) = payload["Records"].as_array() {
|
||||||
|
for entry in records {
|
||||||
|
let _ = tx.send(entry.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let _ = tx.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = hyper::server::conn::http1::Builder::new()
|
||||||
|
.serve_connection(TokioIo::new(stream), service)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok((endpoint, rx, handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_audit_entry(
|
||||||
|
rx: &mut mpsc::UnboundedReceiver<Value>,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
request_id: &str,
|
||||||
|
) -> TestResult<Value> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
return Err(format!(
|
||||||
|
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
|
||||||
|
seen.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let entry = match timeout(remaining, rx.recv()).await {
|
||||||
|
Ok(Some(entry)) => entry,
|
||||||
|
Ok(None) => return Err("audit collector stopped before the expected entry arrived".into()),
|
||||||
|
Err(_) => {
|
||||||
|
return Err(format!(
|
||||||
|
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
|
||||||
|
seen.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if entry["api"]["name"].as_str() == Some("s3:PutObject")
|
||||||
|
&& entry["api"]["bucket"].as_str() == Some(bucket)
|
||||||
|
&& entry["api"]["object"].as_str() == Some(key)
|
||||||
|
&& entry["requestID"].as_str() == Some(request_id)
|
||||||
|
{
|
||||||
|
return Ok(entry);
|
||||||
|
}
|
||||||
|
if seen.len() < 8 {
|
||||||
|
seen.push(format!(
|
||||||
|
"api={:?} bucket={:?} object={:?} requestID={:?}",
|
||||||
|
entry["api"]["name"].as_str(),
|
||||||
|
entry["api"]["bucket"].as_str(),
|
||||||
|
entry["api"]["object"].as_str(),
|
||||||
|
entry["requestID"].as_str()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?;
|
||||||
|
let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization();
|
||||||
|
let audit_env = [
|
||||||
|
("RUSTFS_AUDIT_ENABLE", "true"),
|
||||||
|
("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"),
|
||||||
|
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()),
|
||||||
|
(ENV_OUTBOUND_ALLOW_ORIGINS, audit_origin.as_str()),
|
||||||
|
];
|
||||||
|
let mut dist = DistCluster::new_stopped_with_env(DistLayout::FourByFour, &audit_env).await?;
|
||||||
|
for node_idx in 0..dist.cluster.nodes.len() {
|
||||||
|
let queue_dir = format!("{}/audit-queue-node-{node_idx}", dist.cluster.temp_dir);
|
||||||
|
tokio::fs::create_dir_all(&queue_dir).await?;
|
||||||
|
dist.cluster
|
||||||
|
.set_node_env(node_idx, "RUSTFS_AUDIT_WEBHOOK_QUEUE_DIR_DISTRIBUTED", queue_dir)?;
|
||||||
|
}
|
||||||
|
dist.cluster.start().await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
|
||||||
|
let http = local_http_client();
|
||||||
|
for node in &dist.cluster.nodes {
|
||||||
|
for probe in ["ready", "live"] {
|
||||||
|
let response = http.get(format!("{}/health/{probe}", node.url)).send().await?;
|
||||||
|
assert!(
|
||||||
|
response.status().is_success(),
|
||||||
|
"node {} {probe} probe failed: {}",
|
||||||
|
node.address,
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let info_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/info", None).await?;
|
||||||
|
let info: Value = serde_json::from_str(&info_body)?;
|
||||||
|
let servers = info["info"]["servers"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| format!("admin info omitted servers: {info}"))?;
|
||||||
|
assert_eq!(servers.len(), 4, "admin info did not report all four nodes: {info}");
|
||||||
|
|
||||||
|
let storage_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
|
||||||
|
let storage: Value = serde_json::from_str(&storage_body)?;
|
||||||
|
let disks = storage["info"]["disks"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| format!("storageinfo omitted disks: {storage}"))?;
|
||||||
|
assert_eq!(disks.len(), 16, "storageinfo did not report all sixteen drives: {storage}");
|
||||||
|
assert!(
|
||||||
|
disks.iter().all(|disk| {
|
||||||
|
disk["state"].as_str().is_some_and(|state| state.eq_ignore_ascii_case("ok"))
|
||||||
|
&& disk["runtimeState"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|state| state.eq_ignore_ascii_case("online"))
|
||||||
|
}),
|
||||||
|
"storageinfo reported a drive that was not healthy and online: {storage}"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (node_idx, node) in dist.cluster.nodes.iter().enumerate() {
|
||||||
|
let (status, metrics_body) = admin_request(
|
||||||
|
&node.url,
|
||||||
|
Method::GET,
|
||||||
|
"/rustfs/admin/v3/metrics?n=1&by-host=true&by-disk=true",
|
||||||
|
None,
|
||||||
|
&dist.cluster.access_key,
|
||||||
|
&dist.cluster.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(status.is_success(), "node {node_idx} metrics failed: {status} {metrics_body}");
|
||||||
|
let sample: RealtimeMetrics = serde_json::from_str(
|
||||||
|
metrics_body
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| format!("node {node_idx} returned empty metrics"))?,
|
||||||
|
)?;
|
||||||
|
assert!(sample.finally, "node {node_idx} metrics sample was not terminal");
|
||||||
|
assert!(sample.errors.is_empty(), "node {node_idx} metrics reported errors: {:?}", sample.errors);
|
||||||
|
assert!(!sample.hosts.is_empty(), "node {node_idx} metrics omitted hosts");
|
||||||
|
}
|
||||||
|
|
||||||
|
let targets_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/audit/target/list", None).await?;
|
||||||
|
let targets: Value = serde_json::from_str(&targets_body)?;
|
||||||
|
let configured = targets["audit_endpoints"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or_else(|| format!("audit target list omitted audit_endpoints: {targets}"))?
|
||||||
|
.iter()
|
||||||
|
.any(|target| target["account_id"].as_str() == Some("distributed") && target["service"].as_str() == Some("webhook"));
|
||||||
|
assert!(configured, "configured audit webhook was missing: {targets}");
|
||||||
|
|
||||||
|
let bucket = unique_bucket("audit");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let key = "correlated/audit-object.bin";
|
||||||
|
let put = dist
|
||||||
|
.client(2)?
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(b"distributed audit payload"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let request_id = put.request_id().ok_or("PutObject response omitted request ID")?;
|
||||||
|
let audit = wait_for_audit_entry(&mut audit_entries, &bucket, key, request_id).await?;
|
||||||
|
assert_eq!(
|
||||||
|
audit["api"]["status_code"].as_i64(),
|
||||||
|
Some(200),
|
||||||
|
"audit entry did not report success: {audit}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!audit.to_string().contains(&dist.cluster.secret_key),
|
||||||
|
"audit entry leaked the root secret key"
|
||||||
|
);
|
||||||
|
|
||||||
|
collector.abort();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, enable_versioning, put_bucket_replication, put_object, retrying_put, set_bucket_quota,
|
||||||
|
set_remote_target, unique_bucket, wait_for_ready, wait_for_replicated_bytes, wait_until,
|
||||||
|
};
|
||||||
|
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, init_logging};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use http::Method;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
async fn wait_for_replication_status(
|
||||||
|
client: &aws_sdk_s3::Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
expected: &[&str],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> TestResult {
|
||||||
|
wait_until(
|
||||||
|
timeout,
|
||||||
|
|| async {
|
||||||
|
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
Ok(head
|
||||||
|
.replication_status()
|
||||||
|
.is_some_and(|status| expected.contains(&status.as_str())))
|
||||||
|
},
|
||||||
|
&format!("replication status for {bucket}/{key} in {expected:?}"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_bucket_replication_converges_to_peer_cluster() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let (source, mut target) = DistCluster::start_replication_pair().await?;
|
||||||
|
let source_bucket = unique_bucket("replsrc");
|
||||||
|
let target_bucket = unique_bucket("repldst");
|
||||||
|
source.create_bucket(&source_bucket).await?;
|
||||||
|
target.create_bucket(&target_bucket).await?;
|
||||||
|
|
||||||
|
let source_client = source.client(0)?;
|
||||||
|
let target_client = target.client(0)?;
|
||||||
|
enable_versioning(&source_client, &source_bucket).await?;
|
||||||
|
enable_versioning(&target_client, &target_bucket).await?;
|
||||||
|
|
||||||
|
let arn = set_remote_target(&source.cluster, &source_bucket, &target.cluster, &target_bucket).await?;
|
||||||
|
put_bucket_replication(&source.cluster, &source_bucket, &arn).await?;
|
||||||
|
|
||||||
|
let key = "replicated/metadata-and-tags.bin";
|
||||||
|
let body = b"distributed-bucket-replication".to_vec();
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&source_bucket)
|
||||||
|
.key(key)
|
||||||
|
.metadata("origin", "four-node-source")
|
||||||
|
.tagging("suite=distributed&shape=metadata")
|
||||||
|
.body(ByteStream::from(body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_replicated_bytes(&target_client, &target_bucket, key, &body, Duration::from_secs(45)).await?;
|
||||||
|
wait_for_replication_status(&source_client, &source_bucket, key, &["COMPLETED"], Duration::from_secs(30)).await?;
|
||||||
|
|
||||||
|
let peer_read = target.client(3)?;
|
||||||
|
wait_for_replicated_bytes(&peer_read, &target_bucket, key, &body, Duration::from_secs(15)).await?;
|
||||||
|
let replica_head = peer_read.head_object().bucket(&target_bucket).key(key).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
replica_head
|
||||||
|
.metadata()
|
||||||
|
.and_then(|metadata| metadata.get("origin"))
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("four-node-source")
|
||||||
|
);
|
||||||
|
assert_eq!(replica_head.replication_status().map(|status| status.as_str()), Some("REPLICA"));
|
||||||
|
let replica_tags = peer_read.get_object_tagging().bucket(&target_bucket).key(key).send().await?;
|
||||||
|
let tags: std::collections::BTreeMap<_, _> = replica_tags.tag_set().iter().map(|tag| (tag.key(), tag.value())).collect();
|
||||||
|
assert_eq!(tags.get("suite"), Some(&"distributed"));
|
||||||
|
assert_eq!(tags.get("shape"), Some(&"metadata"));
|
||||||
|
|
||||||
|
target.cluster.stop();
|
||||||
|
let outage_key = "replicated/queued-during-target-outage.bin";
|
||||||
|
let outage_body = b"retry-after-target-restart".to_vec();
|
||||||
|
put_object(&source_client, &source_bucket, outage_key, outage_body.clone()).await?;
|
||||||
|
wait_for_replication_status(
|
||||||
|
&source_client,
|
||||||
|
&source_bucket,
|
||||||
|
outage_key,
|
||||||
|
&["PENDING", "FAILED"],
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
target.cluster.start().await?;
|
||||||
|
wait_for_ready(&target.cluster).await?;
|
||||||
|
wait_for_replicated_bytes(&target.client(2)?, &target_bucket, outage_key, &outage_body, Duration::from_secs(90)).await?;
|
||||||
|
wait_for_replication_status(&source_client, &source_bucket, outage_key, &["COMPLETED"], Duration::from_secs(45)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start_with_env(DistLayout::FourByFour, FAST_DATA_USAGE_SCANNER_ENV).await?;
|
||||||
|
let bucket = unique_bucket("quota");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
set_bucket_quota(&dist.cluster, &bucket, 8 * 1024).await?;
|
||||||
|
|
||||||
|
let client = dist.client(1)?;
|
||||||
|
retrying_put(&client, &bucket, "small.bin", vec![0u8; 1024], Duration::from_secs(30)).await?;
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
|| async {
|
||||||
|
let (status, body) = super::harness::cluster_admin(
|
||||||
|
&dist.cluster,
|
||||||
|
Method::GET,
|
||||||
|
&format!("/rustfs/admin/v3/quota-stats/{bucket}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let stats: serde_json::Value =
|
||||||
|
serde_json::from_str(&body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
|
||||||
|
let usage = stats
|
||||||
|
.get("current_usage")
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.ok_or_else(|| format!("quota stats omitted current_usage: {stats}"))?;
|
||||||
|
Ok(usage >= 1024)
|
||||||
|
},
|
||||||
|
"quota stats observe small object",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let oversized_key = "too-big.bin";
|
||||||
|
let error = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(oversized_key)
|
||||||
|
.body(vec![0u8; 16 * 1024].into())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("hard quota must reject the oversized PUT");
|
||||||
|
let service_error = error
|
||||||
|
.as_service_error()
|
||||||
|
.ok_or("quota rejection was not an S3 service error")?;
|
||||||
|
assert_eq!(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(400),
|
||||||
|
"quota rejection must be HTTP 400: {error:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(service_error.code(), Some("InvalidRequest"), "unexpected quota error: {error:?}");
|
||||||
|
assert!(
|
||||||
|
service_error
|
||||||
|
.message()
|
||||||
|
.is_some_and(|message| message.starts_with("Bucket quota exceeded")),
|
||||||
|
"PUT must fail specifically at quota admission: {error:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let missing = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(oversized_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an object rejected by quota must not become visible");
|
||||||
|
assert_eq!(
|
||||||
|
missing.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"quota-rejected object returned an unexpected HEAD result: {missing:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let listed = client.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
assert!(
|
||||||
|
listed.contents().iter().all(|object| object.key() != Some(oversized_key)),
|
||||||
|
"quota-rejected key leaked into ListObjectsV2"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket};
|
||||||
|
use crate::common::{init_logging, local_http_client};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::presigning::PresigningConfig;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{Delete, MetadataDirective, ObjectIdentifier};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_s3_put_get_head_list_copy_rename_delete_and_presign() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("s3basic");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
let key = "dir/object.bin";
|
||||||
|
let body = vec![0xA5u8; 256 * 1024];
|
||||||
|
put_object(&writer, &bucket, key, body.clone()).await?;
|
||||||
|
|
||||||
|
let head = reader.head_object().bucket(&bucket).key(key).send().await?;
|
||||||
|
assert_eq!(head.content_length(), Some(body.len() as i64));
|
||||||
|
assert_object_bytes(&reader, &bucket, key, &body).await?;
|
||||||
|
|
||||||
|
let ranged = reader
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.range("bytes=0-15")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let ranged_body = ranged.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(ranged_body.as_ref(), &body[..16]);
|
||||||
|
|
||||||
|
let listed = reader.list_objects_v2().bucket(&bucket).prefix("dir/").send().await?;
|
||||||
|
let keys: Vec<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
|
||||||
|
assert_eq!(keys, vec![key]);
|
||||||
|
|
||||||
|
let copy_key = "dir/object-copy.bin";
|
||||||
|
reader
|
||||||
|
.copy_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(copy_key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.metadata_directive(MetadataDirective::Copy)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_object_bytes(&writer, &bucket, copy_key, &body).await?;
|
||||||
|
|
||||||
|
let moved_key = "dir/object-moved.bin";
|
||||||
|
writer
|
||||||
|
.copy_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(moved_key)
|
||||||
|
.copy_source(format!("{bucket}/{copy_key}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
writer.delete_object().bucket(&bucket).key(copy_key).send().await?;
|
||||||
|
match writer.head_object().bucket(&bucket).key(copy_key).send().await {
|
||||||
|
Ok(_) => return Err("copied source still present after rename delete".into()),
|
||||||
|
Err(error) if error.as_service_error().is_some_and(|err| err.is_not_found()) => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
assert_object_bytes(&reader, &bucket, moved_key, &body).await?;
|
||||||
|
|
||||||
|
let presigned = writer
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.presigned(PresigningConfig::expires_in(Duration::from_secs(120))?)
|
||||||
|
.await?;
|
||||||
|
let response = local_http_client().get(presigned.uri().to_string()).send().await?;
|
||||||
|
assert!(response.status().is_success(), "presigned GET failed: {}", response.status());
|
||||||
|
let presigned_body = response.bytes().await?;
|
||||||
|
assert_eq!(presigned_body.as_ref(), body.as_slice());
|
||||||
|
|
||||||
|
let empty_key = "empty";
|
||||||
|
put_object(&writer, &bucket, empty_key, Vec::new()).await?;
|
||||||
|
let empty = get_object_bytes(&reader, &bucket, empty_key).await?;
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
|
||||||
|
let deleted = writer
|
||||||
|
.delete_objects()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.delete(
|
||||||
|
Delete::builder()
|
||||||
|
.objects(ObjectIdentifier::builder().key(key).build()?)
|
||||||
|
.objects(ObjectIdentifier::builder().key(moved_key).build()?)
|
||||||
|
.objects(ObjectIdentifier::builder().key(empty_key).build()?)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(deleted.errors().is_empty(), "DeleteObjects reported failures: {deleted:?}");
|
||||||
|
assert_eq!(deleted.deleted().len(), 3, "DeleteObjects did not acknowledge every key");
|
||||||
|
|
||||||
|
let remaining = reader.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
assert!(remaining.contents().is_empty(), "bucket still has objects after delete");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_s3_metadata_tags_special_keys_pagination_and_multipart_abort() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("s3matrix");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
|
||||||
|
let special_key = "unicode/测试 space+percent%25.txt";
|
||||||
|
let special_body = b"metadata and tagging survive distributed routing".to_vec();
|
||||||
|
let put = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(special_key)
|
||||||
|
.metadata("test-meta", "distributed")
|
||||||
|
.tagging("purpose=compatibility&scope=four-by-four")
|
||||||
|
.body(ByteStream::from(special_body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let etag = put.e_tag().ok_or("PutObject omitted ETag")?.to_string();
|
||||||
|
|
||||||
|
let head = reader.head_object().bucket(&bucket).key(special_key).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
head.metadata()
|
||||||
|
.and_then(|metadata| metadata.get("test-meta"))
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("distributed")
|
||||||
|
);
|
||||||
|
assert_eq!(head.e_tag(), Some(etag.as_str()));
|
||||||
|
let tags = reader.get_object_tagging().bucket(&bucket).key(special_key).send().await?;
|
||||||
|
let actual_tags: std::collections::BTreeMap<_, _> = tags
|
||||||
|
.tag_set()
|
||||||
|
.iter()
|
||||||
|
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(actual_tags.get("purpose").map(String::as_str), Some("compatibility"));
|
||||||
|
assert_eq!(actual_tags.get("scope").map(String::as_str), Some("four-by-four"));
|
||||||
|
|
||||||
|
let conditional = reader
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(special_key)
|
||||||
|
.if_match(&etag)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(conditional.body.collect().await?.into_bytes().as_ref(), special_body.as_slice());
|
||||||
|
let invalid_range = reader
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(special_key)
|
||||||
|
.range("bytes=999999-1000000")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an unsatisfiable range must fail");
|
||||||
|
assert_eq!(
|
||||||
|
invalid_range.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("InvalidRange"),
|
||||||
|
"unexpected invalid-range error: {invalid_range:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let upload_key = "multipart/aborted.bin";
|
||||||
|
let upload = writer
|
||||||
|
.create_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(upload_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let upload_id = upload.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
|
||||||
|
writer
|
||||||
|
.upload_part()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(upload_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(1)
|
||||||
|
.body(ByteStream::from(vec![0x5Au8; 5 * 1024 * 1024]))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let pending = reader
|
||||||
|
.list_multipart_uploads()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.prefix("multipart/")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(pending.uploads().iter().any(|entry| entry.upload_id() == Some(upload_id)));
|
||||||
|
writer
|
||||||
|
.abort_multipart_upload()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(upload_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let after_abort = reader
|
||||||
|
.list_multipart_uploads()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.prefix("multipart/")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(after_abort.uploads().iter().all(|entry| entry.upload_id() != Some(upload_id)));
|
||||||
|
let aborted_head = reader
|
||||||
|
.head_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(upload_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("aborted multipart upload must not create an object");
|
||||||
|
assert_eq!(
|
||||||
|
aborted_head.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"aborted multipart object returned an unexpected HEAD result: {aborted_head:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
for index in 0..113 {
|
||||||
|
let key = format!("page/{index:04}.txt");
|
||||||
|
put_object(&writer, &bucket, &key, format!("page-{index}").into_bytes()).await?;
|
||||||
|
}
|
||||||
|
let mut token = None;
|
||||||
|
let mut paged_keys = Vec::new();
|
||||||
|
loop {
|
||||||
|
let page = reader
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.prefix("page/")
|
||||||
|
.max_keys(37)
|
||||||
|
.set_continuation_token(token.take())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
paged_keys.extend(page.contents().iter().filter_map(|object| object.key().map(str::to_string)));
|
||||||
|
if page.is_truncated() != Some(true) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
token = Some(
|
||||||
|
page.next_continuation_token()
|
||||||
|
.ok_or("truncated ListObjectsV2 page omitted next continuation token")?
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(paged_keys.len(), 113);
|
||||||
|
let expected: Vec<_> = (0..113).map(|index| format!("page/{index:04}.txt")).collect();
|
||||||
|
assert_eq!(paged_keys, expected, "pagination lost, duplicated, or reordered keys");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||||
|
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
|
||||||
|
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||||
|
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
|
||||||
|
};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||||
|
let bucket = unique_bucket("s3move");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let client = dist.client(0)?;
|
||||||
|
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||||
|
dist.expand_to_four_pools().await?;
|
||||||
|
|
||||||
|
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||||
|
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||||
|
let live = dist.client(2)?;
|
||||||
|
retrying_put(
|
||||||
|
&live,
|
||||||
|
&bucket,
|
||||||
|
"during-decommission.bin",
|
||||||
|
b"written-while-decommissioning".to_vec(),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
retrying_get_equals(
|
||||||
|
&live,
|
||||||
|
&bucket,
|
||||||
|
"during-decommission.bin",
|
||||||
|
b"written-while-decommissioning",
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.contents()
|
||||||
|
.iter()
|
||||||
|
.any(|object| object.key() == Some("during-decommission.bin")),
|
||||||
|
"list during decommission missed the newly written key"
|
||||||
|
);
|
||||||
|
let status = decommission_status_json(&dist.cluster).await?;
|
||||||
|
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
|
||||||
|
return Err(format!("decommission did not remain active across the S3 operations: {status}").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||||
|
assert_inventory(&live, &bucket, &inventory).await?;
|
||||||
|
|
||||||
|
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||||
|
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||||
|
retrying_put(
|
||||||
|
&live,
|
||||||
|
&bucket,
|
||||||
|
"during-rebalance.bin",
|
||||||
|
b"written-while-rebalancing".to_vec(),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
retrying_get_equals(
|
||||||
|
&live,
|
||||||
|
&bucket,
|
||||||
|
"during-rebalance.bin",
|
||||||
|
b"written-while-rebalancing",
|
||||||
|
Duration::from_secs(30),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let status = rebalance_status_json(&dist.cluster).await?;
|
||||||
|
if !rebalance_running_with_progress(&status, &rebalance_id)? {
|
||||||
|
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
|
||||||
|
}
|
||||||
|
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||||
|
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, TestResult, cluster_admin_ok, enable_versioning, put_object, unique_bucket, wait_for_replicated_bytes,
|
||||||
|
wait_until,
|
||||||
|
};
|
||||||
|
use crate::common::{init_logging, signed_request};
|
||||||
|
use http::{Method, StatusCode};
|
||||||
|
use rustfs_madmin::{PeerSite, ReplicateAddStatus, SiteReplicationInfo, SyncStatus};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
async fn site_replication_add(
|
||||||
|
cluster: &crate::common::RustFSTestClusterEnvironment,
|
||||||
|
sites: &[PeerSite],
|
||||||
|
) -> TestResult<ReplicateAddStatus> {
|
||||||
|
let url = format!("{}/rustfs/admin/v3/site-replication/add?replicateILMExpiry=false", cluster.nodes[0].url);
|
||||||
|
let response = signed_request(
|
||||||
|
Method::PUT,
|
||||||
|
&url,
|
||||||
|
&cluster.access_key,
|
||||||
|
&cluster.secret_key,
|
||||||
|
Some(serde_json::to_vec(sites)?),
|
||||||
|
Some("application/json"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if response.status() != StatusCode::OK {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("site replication add failed: {status} {body}").into());
|
||||||
|
}
|
||||||
|
Ok(serde_json::from_slice(&response.bytes().await?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn site_replication_info(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult<SiteReplicationInfo> {
|
||||||
|
let body = cluster_admin_ok(cluster, Method::GET, "/rustfs/admin/v3/site-replication/info", None).await?;
|
||||||
|
Ok(serde_json::from_str(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_site_replication_enabled(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult {
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
|| async {
|
||||||
|
let info = site_replication_info(cluster).await?;
|
||||||
|
Ok(info.enabled && info.sites.len() == 2 && info.sites.iter().all(|site| site.sync_state == SyncStatus::Enable))
|
||||||
|
},
|
||||||
|
"site replication enabled with two synchronized sites",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_site_replication_replicates_object_to_peer_site() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let (site_a, site_b) = DistCluster::start_replication_pair().await?;
|
||||||
|
let bucket = unique_bucket("siterepl");
|
||||||
|
site_a.create_bucket(&bucket).await?;
|
||||||
|
site_b.create_bucket(&bucket).await?;
|
||||||
|
|
||||||
|
let client_a = site_a.client(0)?;
|
||||||
|
let client_b = site_b.client(0)?;
|
||||||
|
enable_versioning(&client_a, &bucket).await?;
|
||||||
|
enable_versioning(&client_b, &bucket).await?;
|
||||||
|
|
||||||
|
let sites = vec![
|
||||||
|
PeerSite {
|
||||||
|
name: "site-a".to_string(),
|
||||||
|
endpoint: site_a.cluster.nodes[0].url.clone(),
|
||||||
|
access_key: site_a.cluster.access_key.clone(),
|
||||||
|
secret_key: site_a.cluster.secret_key.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
PeerSite {
|
||||||
|
name: "site-b".to_string(),
|
||||||
|
endpoint: site_b.cluster.nodes[0].url.clone(),
|
||||||
|
access_key: site_b.cluster.access_key.clone(),
|
||||||
|
secret_key: site_b.cluster.secret_key.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let add_status = site_replication_add(&site_a.cluster, &sites).await?;
|
||||||
|
assert!(
|
||||||
|
add_status.success && add_status.err_detail.is_empty() && add_status.initial_sync_error_message.is_empty(),
|
||||||
|
"site replication add reported failure: {add_status:?}"
|
||||||
|
);
|
||||||
|
wait_for_site_replication_enabled(&site_a.cluster).await?;
|
||||||
|
wait_for_site_replication_enabled(&site_b.cluster).await?;
|
||||||
|
|
||||||
|
let info_a = site_replication_info(&site_a.cluster).await?;
|
||||||
|
let remote = info_a
|
||||||
|
.sites
|
||||||
|
.iter()
|
||||||
|
.find(|site| site.name == "site-b")
|
||||||
|
.ok_or_else(|| format!("site A info omitted the configured site-b peer: {info_a:?}"))?;
|
||||||
|
assert_eq!(remote.endpoint, site_b.cluster.nodes[0].url);
|
||||||
|
let deployment_ids: std::collections::BTreeSet<_> = info_a.sites.iter().map(|site| site.deployment_id.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
deployment_ids.iter().all(|deployment_id| !deployment_id.is_empty()) && deployment_ids.len() == 2,
|
||||||
|
"site peers must have two distinct non-empty deployment IDs: {info_a:?}"
|
||||||
|
);
|
||||||
|
assert!(info_a.retry_stats.is_none(), "site A has pending replication retries: {info_a:?}");
|
||||||
|
assert!(info_a.pending_operation.is_none(), "site A has a pending operation: {info_a:?}");
|
||||||
|
|
||||||
|
let key = "site-object.bin";
|
||||||
|
let body = b"four-node-site-replication".to_vec();
|
||||||
|
put_object(&client_a, &bucket, key, body.clone()).await?;
|
||||||
|
wait_for_replicated_bytes(&client_b, &bucket, key, &body, Duration::from_secs(60)).await?;
|
||||||
|
|
||||||
|
let peer_b = site_b.client(3)?;
|
||||||
|
wait_for_replicated_bytes(&peer_b, &bucket, key, &body, Duration::from_secs(20)).await?;
|
||||||
|
|
||||||
|
let reverse_key = "reverse/site-object.bin";
|
||||||
|
let reverse_body = b"site-b-to-site-a".to_vec();
|
||||||
|
put_object(&site_b.client(2)?, &bucket, reverse_key, reverse_body.clone()).await?;
|
||||||
|
wait_for_replicated_bytes(&site_a.client(3)?, &bucket, reverse_key, &reverse_body, Duration::from_secs(60)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/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.
|
||||||
|
|
||||||
|
//! 4-node upgrade coverage for historical objects and IAM AK/SK.
|
||||||
|
//!
|
||||||
|
//! Complements `upgrade_compatibility_test` (single-node SSE/multipart and
|
||||||
|
//! mixed-version listing). This module pins the distributed contract the
|
||||||
|
//! hardware upgrade chain is meant to catch: after a 4-node upgrade, objects
|
||||||
|
//! written on the previous release still read back, and IAM user credentials
|
||||||
|
//! created before the upgrade still authenticate.
|
||||||
|
//!
|
||||||
|
//! Requires `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous
|
||||||
|
//! release. The `e2e-distributed` workflow downloads that binary; a local run
|
||||||
|
//! without it fails closed rather than skipping.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, enable_versioning, get_object_bytes, put_object,
|
||||||
|
unique_bucket, wait_until,
|
||||||
|
};
|
||||||
|
use crate::common::{
|
||||||
|
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
|
||||||
|
};
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||||
|
const IAM_SECRET: &str = "UpgradeTestSecretKey1";
|
||||||
|
const WRONG_SECRET: &str = "WrongSecretKey000000";
|
||||||
|
const CREDENTIAL_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
struct UpgradeSeed {
|
||||||
|
history_bucket: String,
|
||||||
|
history_key: &'static str,
|
||||||
|
history_body: Vec<u8>,
|
||||||
|
versioned_bucket: String,
|
||||||
|
versioned_key: &'static str,
|
||||||
|
version1: String,
|
||||||
|
version1_body: Vec<u8>,
|
||||||
|
version2: String,
|
||||||
|
version2_body: Vec<u8>,
|
||||||
|
iam_bucket: String,
|
||||||
|
iam_key: &'static str,
|
||||||
|
iam_body: Vec<u8>,
|
||||||
|
iam_user: String,
|
||||||
|
iam_secret: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_binary() -> TestResult<PathBuf> {
|
||||||
|
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(format!("upgrade source binary does not exist: {}", path.display()).into());
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_upgrade_logs(cluster: &mut DistCluster, label: &str) -> TestResult {
|
||||||
|
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
std::fs::create_dir_all(&log_dir)?;
|
||||||
|
for node_idx in 0..cluster.cluster.nodes.len() {
|
||||||
|
let path = Path::new(&log_dir).join(format!("{label}-node-{node_idx}.log"));
|
||||||
|
cluster
|
||||||
|
.cluster
|
||||||
|
.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iam_rw_policy(bucket: &str) -> String {
|
||||||
|
serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:*"],
|
||||||
|
"Resource": [
|
||||||
|
format!("arn:aws:s3:::{bucket}"),
|
||||||
|
format!("arn:aws:s3:::{bucket}/*")
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_iam_user(dist: &DistCluster, user: &str, secret: &str, policy_name: &str, bucket: &str) -> TestResult {
|
||||||
|
let url = &dist.cluster.nodes[0].url;
|
||||||
|
let access = &dist.cluster.access_key;
|
||||||
|
let admin_secret = &dist.cluster.secret_key;
|
||||||
|
admin_create_user_via(AdminTransport::Signed, url, access, admin_secret, user, secret).await?;
|
||||||
|
admin_add_canned_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, &iam_rw_policy(bucket)).await?;
|
||||||
|
admin_attach_user_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, user).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, label: &str) -> TestResult {
|
||||||
|
wait_until(
|
||||||
|
CREDENTIAL_TIMEOUT,
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let bucket = bucket.to_string();
|
||||||
|
let key = key.to_string();
|
||||||
|
let body = body.clone();
|
||||||
|
async move {
|
||||||
|
put_object(&client, &bucket, &key, body).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8], label: &str) -> TestResult {
|
||||||
|
wait_until(
|
||||||
|
CREDENTIAL_TIMEOUT,
|
||||||
|
|| {
|
||||||
|
let client = client.clone();
|
||||||
|
let bucket = bucket.to_string();
|
||||||
|
let key = key.to_string();
|
||||||
|
let expected = expected.to_vec();
|
||||||
|
async move {
|
||||||
|
let got = get_object_bytes(&client, &bucket, &key).await?;
|
||||||
|
Ok(got == expected)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_history_and_iam(dist: &DistCluster) -> TestResult<UpgradeSeed> {
|
||||||
|
let history_bucket = unique_bucket("upg-hist");
|
||||||
|
let versioned_bucket = unique_bucket("upg-ver");
|
||||||
|
let iam_bucket = unique_bucket("upg-iam");
|
||||||
|
dist.create_bucket(&history_bucket).await?;
|
||||||
|
dist.create_bucket(&versioned_bucket).await?;
|
||||||
|
dist.create_bucket(&iam_bucket).await?;
|
||||||
|
|
||||||
|
let root = dist.client(0)?;
|
||||||
|
enable_versioning(&root, &versioned_bucket).await?;
|
||||||
|
|
||||||
|
let history_key = "plain-history.bin";
|
||||||
|
let history_body = b"written by the previous 4-node release".to_vec();
|
||||||
|
put_object(&root, &history_bucket, history_key, history_body.clone()).await?;
|
||||||
|
|
||||||
|
let versioned_key = "versioned-history.txt";
|
||||||
|
let version1_body = b"version-one-before-upgrade".to_vec();
|
||||||
|
let version1 = root
|
||||||
|
.put_object()
|
||||||
|
.bucket(&versioned_bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from(version1_body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("first versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let version2_body = b"version-two-before-upgrade".to_vec();
|
||||||
|
let version2 = root
|
||||||
|
.put_object()
|
||||||
|
.bucket(&versioned_bucket)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from(version2_body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("second versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let iam_user = format!("upg{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||||
|
let policy_name = format!("upgpol{}", &Uuid::new_v4().simple().to_string()[..8]);
|
||||||
|
create_iam_user(dist, &iam_user, IAM_SECRET, &policy_name, &iam_bucket).await?;
|
||||||
|
|
||||||
|
let iam_key = "iam-history.bin";
|
||||||
|
let iam_body = b"written with pre-upgrade IAM AK/SK".to_vec();
|
||||||
|
let iam_client = dist.client_with_credentials(1, &iam_user, IAM_SECRET)?;
|
||||||
|
wait_for_put(&iam_client, &iam_bucket, iam_key, iam_body.clone(), "IAM user PUT before upgrade").await?;
|
||||||
|
|
||||||
|
Ok(UpgradeSeed {
|
||||||
|
history_bucket,
|
||||||
|
history_key,
|
||||||
|
history_body,
|
||||||
|
versioned_bucket,
|
||||||
|
versioned_key,
|
||||||
|
version1,
|
||||||
|
version1_body,
|
||||||
|
version2,
|
||||||
|
version2_body,
|
||||||
|
iam_bucket,
|
||||||
|
iam_key,
|
||||||
|
iam_body,
|
||||||
|
iam_user,
|
||||||
|
iam_secret: IAM_SECRET,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_history_and_iam(dist: &DistCluster, seed: &UpgradeSeed, context: &str) -> TestResult {
|
||||||
|
let root_a = dist.client(0)?;
|
||||||
|
let root_b = dist.client(3)?;
|
||||||
|
wait_for_bytes(
|
||||||
|
&root_b,
|
||||||
|
&seed.history_bucket,
|
||||||
|
seed.history_key,
|
||||||
|
&seed.history_body,
|
||||||
|
&format!("{context}: root GET historical object"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_object_bytes(&root_a, &seed.history_bucket, seed.history_key, &seed.history_body).await?;
|
||||||
|
|
||||||
|
let v1 = root_b
|
||||||
|
.get_object()
|
||||||
|
.bucket(&seed.versioned_bucket)
|
||||||
|
.key(seed.versioned_key)
|
||||||
|
.version_id(&seed.version1)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let v1_body = v1.body.collect().await?.into_bytes();
|
||||||
|
if v1_body.as_ref() != seed.version1_body.as_slice() {
|
||||||
|
return Err(format!("{context}: version 1 bytes changed after upgrade").into());
|
||||||
|
}
|
||||||
|
let v2 = root_a
|
||||||
|
.get_object()
|
||||||
|
.bucket(&seed.versioned_bucket)
|
||||||
|
.key(seed.versioned_key)
|
||||||
|
.version_id(&seed.version2)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let v2_body = v2.body.collect().await?.into_bytes();
|
||||||
|
if v2_body.as_ref() != seed.version2_body.as_slice() {
|
||||||
|
return Err(format!("{context}: version 2 bytes changed after upgrade").into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let users = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/list-users", None).await?;
|
||||||
|
if !users.contains(&seed.iam_user) {
|
||||||
|
return Err(format!("{context}: list-users lost IAM user {}: {users}", seed.iam_user).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let iam_on_upgraded = dist.client_with_credentials(0, &seed.iam_user, seed.iam_secret)?;
|
||||||
|
let iam_on_peer = dist.client_with_credentials(3, &seed.iam_user, seed.iam_secret)?;
|
||||||
|
wait_for_bytes(
|
||||||
|
&iam_on_upgraded,
|
||||||
|
&seed.iam_bucket,
|
||||||
|
seed.iam_key,
|
||||||
|
&seed.iam_body,
|
||||||
|
&format!("{context}: IAM GET historical object on node 0"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_for_bytes(
|
||||||
|
&iam_on_peer,
|
||||||
|
&seed.iam_bucket,
|
||||||
|
seed.iam_key,
|
||||||
|
&seed.iam_body,
|
||||||
|
&format!("{context}: IAM GET historical object on node 3"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let post_key = format!("after-upgrade-{context}.txt");
|
||||||
|
let post_body = format!("{context}: written with the same IAM AK/SK after upgrade").into_bytes();
|
||||||
|
wait_for_put(
|
||||||
|
&iam_on_peer,
|
||||||
|
&seed.iam_bucket,
|
||||||
|
&post_key,
|
||||||
|
post_body.clone(),
|
||||||
|
&format!("{context}: IAM PUT after upgrade"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_object_bytes(&iam_on_upgraded, &seed.iam_bucket, &post_key, &post_body).await?;
|
||||||
|
|
||||||
|
let bad = dist.client_with_credentials(1, &seed.iam_user, WRONG_SECRET)?;
|
||||||
|
match bad.get_object().bucket(&seed.iam_bucket).key(seed.iam_key).send().await {
|
||||||
|
Ok(_) => return Err(format!("{context}: wrong secret must not read the IAM object").into()),
|
||||||
|
Err(error) => {
|
||||||
|
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||||
|
let rejected = code == Some("SignatureDoesNotMatch")
|
||||||
|
|| code == Some("InvalidAccessKeyId")
|
||||||
|
|| code == Some("AccessDenied")
|
||||||
|
|| code == Some("InvalidArgument")
|
||||||
|
|| error.raw_response().is_some_and(|response| response.status().as_u16() == 403);
|
||||||
|
if !rejected {
|
||||||
|
return Err(format!("{context}: wrong secret failed with unexpected error {error:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let post_root_key = format!("root-after-{context}.bin");
|
||||||
|
let post_root_body = format!("{context}: root write after upgrade").into_bytes();
|
||||||
|
put_object(&root_a, &seed.history_bucket, &post_root_key, post_root_body.clone()).await?;
|
||||||
|
assert_object_bytes(&root_b, &seed.history_bucket, &post_root_key, &post_root_body).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_direct_upgrade_preserves_history_and_iam_credentials() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous = source_binary()?;
|
||||||
|
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
|
||||||
|
capture_upgrade_logs(&mut dist, "direct-upgrade")?;
|
||||||
|
dist.start_from_binary(&previous).await?;
|
||||||
|
|
||||||
|
let seed = seed_history_and_iam(&dist).await?;
|
||||||
|
dist.restart_with_current_binary().await?;
|
||||||
|
assert_history_and_iam(&dist, &seed, "direct").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous = source_binary()?;
|
||||||
|
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
|
||||||
|
capture_upgrade_logs(&mut dist, "rolling-upgrade")?;
|
||||||
|
dist.start_from_binary(&previous).await?;
|
||||||
|
|
||||||
|
let seed = seed_history_and_iam(&dist).await?;
|
||||||
|
|
||||||
|
dist.replace_node_with_current_binary(0).await?;
|
||||||
|
assert_history_and_iam(&dist, &seed, "one-current-node").await?;
|
||||||
|
|
||||||
|
for node_idx in [1, 2] {
|
||||||
|
dist.replace_node_with_current_binary(node_idx).await?;
|
||||||
|
}
|
||||||
|
assert_history_and_iam(&dist, &seed, "one-previous-node").await?;
|
||||||
|
|
||||||
|
dist.replace_node_with_current_binary(3).await?;
|
||||||
|
assert_history_and_iam(&dist, &seed, "homogeneous-current").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{DistCluster, DistLayout, TestResult, enable_versioning, get_object_bytes, put_object, unique_bucket};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_four_drive_versioning_put_list_get_delete_marker() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("version");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
enable_versioning(&writer, &bucket).await?;
|
||||||
|
|
||||||
|
let key = "versioned.txt";
|
||||||
|
let v1_id = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(b"v1".to_vec().into())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("v1 PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
let v2_id = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(b"v2".to_vec().into())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("v2 PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
|
||||||
|
let matching_versions: Vec<_> = versions
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.filter(|version| version.key() == Some(key))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(matching_versions.len(), 2, "fresh key must have exactly two versions: {versions:?}");
|
||||||
|
assert!(versions.delete_markers().is_empty(), "fresh key unexpectedly has a delete marker");
|
||||||
|
assert!(
|
||||||
|
matching_versions
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(v1_id.as_str()) && version.is_latest() != Some(true)),
|
||||||
|
"v1 was not the historical version: {versions:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matching_versions
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.version_id() == Some(v2_id.as_str()) && version.is_latest() == Some(true)),
|
||||||
|
"v2 was not the latest version: {versions:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let latest = get_object_bytes(&reader, &bucket, key).await?;
|
||||||
|
assert_eq!(latest, b"v2");
|
||||||
|
|
||||||
|
let older = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
|
||||||
|
let older_body = older.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(older_body.as_ref(), b"v1");
|
||||||
|
|
||||||
|
let deleted = writer.delete_object().bucket(&bucket).key(key).send().await?;
|
||||||
|
assert_eq!(deleted.delete_marker(), Some(true));
|
||||||
|
let marker_id = deleted.version_id().ok_or("DeleteObject omitted delete-marker version ID")?;
|
||||||
|
let after_delete = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
|
||||||
|
let matching_markers: Vec<_> = after_delete
|
||||||
|
.delete_markers()
|
||||||
|
.iter()
|
||||||
|
.filter(|marker| marker.key() == Some(key))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
matching_markers.len(),
|
||||||
|
1,
|
||||||
|
"delete marker missing or duplicated after current-version delete: {after_delete:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matching_markers[0].version_id() == Some(marker_id) && matching_markers[0].is_latest() == Some(true),
|
||||||
|
"DeleteObject response and ListObjectVersions disagree about the marker: {after_delete:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let latest_after_delete = reader.get_object().bucket(&bucket).key(key).send().await;
|
||||||
|
match latest_after_delete {
|
||||||
|
Ok(_) => return Err("current version should be a delete marker".into()),
|
||||||
|
Err(error)
|
||||||
|
if error
|
||||||
|
.as_service_error()
|
||||||
|
.and_then(ProvideErrorMetadata::code)
|
||||||
|
.is_some_and(|code| code == "NoSuchKey" || code == "NotFound") => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
|
||||||
|
let restored = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
|
||||||
|
let restored_body = restored.body.collect().await?.into_bytes();
|
||||||
|
assert_eq!(restored_body.as_ref(), b"v1");
|
||||||
|
|
||||||
|
writer
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(marker_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"v2");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn four_node_versioning_suspension_keeps_one_null_version_and_history() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||||
|
let bucket = unique_bucket("suspend");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let reader = dist.client(3)?;
|
||||||
|
enable_versioning(&writer, &bucket).await?;
|
||||||
|
|
||||||
|
let key = "suspended.txt";
|
||||||
|
let original = writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(b"enabled-history".to_vec().into())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("enabled PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
writer
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.versioning_configuration(
|
||||||
|
VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Suspended)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
put_object(&writer, &bucket, key, b"null-one".to_vec()).await?;
|
||||||
|
put_object(&writer, &bucket, key, b"null-two".to_vec()).await?;
|
||||||
|
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"null-two");
|
||||||
|
|
||||||
|
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
|
||||||
|
let matching: Vec<_> = versions
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.filter(|version| version.key() == Some(key))
|
||||||
|
.collect();
|
||||||
|
assert!(matching.iter().any(|version| version.version_id() == Some(original.as_str())));
|
||||||
|
let null_version_count = matching
|
||||||
|
.iter()
|
||||||
|
.filter(|version| {
|
||||||
|
matches!(
|
||||||
|
version.version_id(),
|
||||||
|
None | Some("") | Some("null") | Some("00000000-0000-0000-0000-000000000000")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert_eq!(null_version_count, 1, "suspended overwrites must keep one null version: {versions:?}");
|
||||||
|
|
||||||
|
let historical = reader
|
||||||
|
.get_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(&original)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(historical.body.collect().await?.into_bytes().as_ref(), b"enabled-history");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -34,12 +34,14 @@ use s3s::dto::{
|
|||||||
AbortMultipartUploadInput, AbortMultipartUploadOutput, CommonPrefix, CompleteMultipartUploadInput,
|
AbortMultipartUploadInput, AbortMultipartUploadOutput, CommonPrefix, CompleteMultipartUploadInput,
|
||||||
CompleteMultipartUploadOutput, CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteMarkerEntry, DeleteObjectInput,
|
CompleteMultipartUploadOutput, CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteMarkerEntry, DeleteObjectInput,
|
||||||
DeleteObjectOutput, DeleteObjectTaggingInput, DeleteObjectTaggingOutput, ETag, GetBucketVersioningInput,
|
DeleteObjectOutput, DeleteObjectTaggingInput, DeleteObjectTaggingOutput, ETag, GetBucketVersioningInput,
|
||||||
GetBucketVersioningOutput, GetObjectInput, GetObjectLockConfigurationInput, GetObjectLockConfigurationOutput,
|
GetBucketVersioningOutput, GetObjectInput, GetObjectLegalHoldInput, GetObjectLegalHoldOutput,
|
||||||
GetObjectOutput, GetObjectTaggingInput, GetObjectTaggingOutput, HeadBucketInput, HeadBucketOutput, HeadObjectInput,
|
GetObjectLockConfigurationInput, GetObjectLockConfigurationOutput, GetObjectOutput, GetObjectRetentionInput,
|
||||||
|
GetObjectRetentionOutput, GetObjectTaggingInput, GetObjectTaggingOutput, HeadBucketInput, HeadBucketOutput, HeadObjectInput,
|
||||||
HeadObjectOutput, ListObjectVersionsInput, ListObjectVersionsOutput, ListObjectsV2Input, ListObjectsV2Output, Object,
|
HeadObjectOutput, ListObjectVersionsInput, ListObjectVersionsOutput, ListObjectsV2Input, ListObjectsV2Output, Object,
|
||||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectStorageClass, ObjectVersionId, PutObjectInput, PutObjectOutput,
|
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
|
||||||
PutObjectTaggingInput, PutObjectTaggingOutput, Range, StreamingBlob, Tag, TagSet, Timestamp, TimestampFormat,
|
ObjectLockRetention, ObjectLockRetentionMode, ObjectStorageClass, ObjectVersionId, PutObjectInput, PutObjectLegalHoldInput,
|
||||||
UploadPartInput, UploadPartOutput,
|
PutObjectLegalHoldOutput, PutObjectOutput, PutObjectRetentionInput, PutObjectRetentionOutput, PutObjectTaggingInput,
|
||||||
|
PutObjectTaggingOutput, Range, StreamingBlob, Tag, TagSet, Timestamp, TimestampFormat, UploadPartInput, UploadPartOutput,
|
||||||
};
|
};
|
||||||
use s3s::service::{S3Service, S3ServiceBuilder};
|
use s3s::service::{S3Service, S3ServiceBuilder};
|
||||||
use s3s::validation::{AwsNameValidation, NameValidation};
|
use s3s::validation::{AwsNameValidation, NameValidation};
|
||||||
@@ -127,6 +129,10 @@ pub enum Operation {
|
|||||||
GetObjectTagging,
|
GetObjectTagging,
|
||||||
PutObjectTagging,
|
PutObjectTagging,
|
||||||
DeleteObjectTagging,
|
DeleteObjectTagging,
|
||||||
|
GetObjectRetention,
|
||||||
|
PutObjectRetention,
|
||||||
|
GetObjectLegalHold,
|
||||||
|
PutObjectLegalHold,
|
||||||
ListObjectVersions,
|
ListObjectVersions,
|
||||||
ListObjectsV2,
|
ListObjectsV2,
|
||||||
CreateMultipartUpload,
|
CreateMultipartUpload,
|
||||||
@@ -451,10 +457,40 @@ impl JournaledHeaders {
|
|||||||
struct ControlState {
|
struct ControlState {
|
||||||
scripts: HashMap<Operation, VecDeque<FaultAction>>,
|
scripts: HashMap<Operation, VecDeque<FaultAction>>,
|
||||||
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
|
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
|
||||||
|
held_get: Option<HeldGetObject>,
|
||||||
requests: VecDeque<RequestRecord>,
|
requests: VecDeque<RequestRecord>,
|
||||||
next_sequence: u64,
|
next_sequence: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct HeldGetObject {
|
||||||
|
bucket: String,
|
||||||
|
key: String,
|
||||||
|
entered: watch::Sender<usize>,
|
||||||
|
released: watch::Receiver<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds every GET of one object, including retries, until this guard is dropped.
|
||||||
|
#[must_use = "dropping the guard releases the held GET requests"]
|
||||||
|
pub struct GetObjectGate {
|
||||||
|
control: Arc<Mutex<ControlState>>,
|
||||||
|
entered: watch::Receiver<usize>,
|
||||||
|
released: watch::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GetObjectGate {
|
||||||
|
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
|
||||||
|
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for GetObjectGate {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
lock(&self.control).held_get = None;
|
||||||
|
self.released.send_replace(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct StoreState {
|
struct StoreState {
|
||||||
assign_own_version_ids: bool,
|
assign_own_version_ids: bool,
|
||||||
@@ -471,6 +507,10 @@ struct StoreState {
|
|||||||
/// PutObject carrying any `x-amz-object-lock-*` header must also carry
|
/// PutObject carrying any `x-amz-object-lock-*` header must also carry
|
||||||
/// `Content-MD5` or an `x-amz-checksum-*` header.
|
/// `Content-MD5` or an `x-amz-checksum-*` header.
|
||||||
require_checksum_for_object_lock: bool,
|
require_checksum_for_object_lock: bool,
|
||||||
|
/// Models Wasabi (rustfs/backlog#2340): a version-addressed DELETE of a
|
||||||
|
/// version id the target never had answers 404 `NoSuchVersion` instead of
|
||||||
|
/// the idempotent 204 RustFS/MinIO give.
|
||||||
|
reject_unknown_version_deletes: bool,
|
||||||
limits: StoreLimits,
|
limits: StoreLimits,
|
||||||
buckets: HashMap<String, BucketState>,
|
buckets: HashMap<String, BucketState>,
|
||||||
uploads: HashMap<String, MultipartState>,
|
uploads: HashMap<String, MultipartState>,
|
||||||
@@ -535,6 +575,41 @@ struct ObjectVersion {
|
|||||||
/// SSE-C passthrough transport headers stored with the version (RustFS
|
/// SSE-C passthrough transport headers stored with the version (RustFS
|
||||||
/// target behavior); empty when the drop mode discarded them.
|
/// target behavior); empty when the drop mode discarded them.
|
||||||
replication_sse_headers: Vec<(String, String)>,
|
replication_sse_headers: Vec<(String, String)>,
|
||||||
|
/// Object Lock state of the version: retention (mode, retain-until) from
|
||||||
|
/// the PUT / CreateMultipartUpload headers or PutObjectRetention, and the
|
||||||
|
/// legal hold flag; replayed on HEAD.
|
||||||
|
lock: VersionLock,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct VersionLock {
|
||||||
|
retention: Option<(String, Timestamp)>,
|
||||||
|
/// `None` until a legal hold status was ever set; like S3, HEAD then
|
||||||
|
/// reports nothing, while an explicit OFF is reported as `OFF`.
|
||||||
|
legal_hold: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VersionLock {
|
||||||
|
fn from_headers(
|
||||||
|
mode: Option<ObjectLockMode>,
|
||||||
|
retain_until: Option<Timestamp>,
|
||||||
|
legal_hold: Option<ObjectLockLegalHoldStatus>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
retention: mode.zip(retain_until).map(|(mode, until)| (mode.as_str().to_string(), until)),
|
||||||
|
legal_hold: legal_hold.map(|status| status.as_str().eq_ignore_ascii_case("ON")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legal_hold_status(&self) -> Option<ObjectLockLegalHoldStatus> {
|
||||||
|
self.legal_hold.map(|on| {
|
||||||
|
ObjectLockLegalHoldStatus::from_static(if on {
|
||||||
|
ObjectLockLegalHoldStatus::ON
|
||||||
|
} else {
|
||||||
|
ObjectLockLegalHoldStatus::OFF
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -546,6 +621,7 @@ struct MultipartState {
|
|||||||
metadata: Option<HashMap<String, String>>,
|
metadata: Option<HashMap<String, String>>,
|
||||||
standard_headers: StandardHeaders,
|
standard_headers: StandardHeaders,
|
||||||
replication_sse_headers: Vec<(String, String)>,
|
replication_sse_headers: Vec<(String, String)>,
|
||||||
|
lock: VersionLock,
|
||||||
parts: BTreeMap<i32, MultipartPart>,
|
parts: BTreeMap<i32, MultipartPart>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,6 +891,7 @@ impl FakeS3Target {
|
|||||||
standard_headers: seed.standard_headers.clone(),
|
standard_headers: seed.standard_headers.clone(),
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
replication_sse_headers: Vec::new(),
|
replication_sse_headers: Vec::new(),
|
||||||
|
lock: VersionLock::default(),
|
||||||
};
|
};
|
||||||
upsert_version(&mut state, bucket, key.into(), version).expect("seed object must fit the storage budget");
|
upsert_version(&mut state, bucket, key.into(), version).expect("seed object must fit the storage budget");
|
||||||
e_tag
|
e_tag
|
||||||
@@ -888,6 +965,12 @@ impl FakeS3Target {
|
|||||||
/// PutObject that carries Object Lock parameters (AWS S3 / MinIO rule,
|
/// PutObject that carries Object Lock parameters (AWS S3 / MinIO rule,
|
||||||
/// rustfs#7082). `Content-MD5`, when present, is always verified against
|
/// rustfs#7082). `Content-MD5`, when present, is always verified against
|
||||||
/// the body regardless of this mode.
|
/// the body regardless of this mode.
|
||||||
|
/// Wasabi-like mode: DELETE of an unknown version id answers 404
|
||||||
|
/// `NoSuchVersion` (the default 204 models RustFS/MinIO).
|
||||||
|
pub fn reject_unknown_version_deletes(&self, enabled: bool) {
|
||||||
|
lock(&self.backend.store).reject_unknown_version_deletes = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn require_checksum_for_object_lock(&self, enabled: bool) {
|
pub fn require_checksum_for_object_lock(&self, enabled: bool) {
|
||||||
lock(&self.backend.store).require_checksum_for_object_lock = enabled;
|
lock(&self.backend.store).require_checksum_for_object_lock = enabled;
|
||||||
}
|
}
|
||||||
@@ -936,6 +1019,30 @@ impl FakeS3Target {
|
|||||||
.extend(std::iter::repeat_n(action, times));
|
.extend(std::iter::repeat_n(action, times));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hold one exact bucket/key before any GET response can reach the client.
|
||||||
|
/// The fixture supports one live gate; request and connection deadlines still apply.
|
||||||
|
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
|
||||||
|
assert!(
|
||||||
|
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
|
||||||
|
"held GET identifiers exceed the fixture limit"
|
||||||
|
);
|
||||||
|
let mut state = lock(&self.control);
|
||||||
|
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
|
||||||
|
let (entered, entered_rx) = watch::channel(0);
|
||||||
|
let (released, released_rx) = watch::channel(false);
|
||||||
|
state.held_get = Some(HeldGetObject {
|
||||||
|
bucket: bucket.to_string(),
|
||||||
|
key: key.to_string(),
|
||||||
|
entered,
|
||||||
|
released: released_rx,
|
||||||
|
});
|
||||||
|
GetObjectGate {
|
||||||
|
control: Arc::clone(&self.control),
|
||||||
|
entered: entered_rx,
|
||||||
|
released,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear_faults(&self) {
|
pub fn clear_faults(&self) {
|
||||||
let mut state = lock(&self.control);
|
let mut state = lock(&self.control);
|
||||||
state.scripts.clear();
|
state.scripts.clear();
|
||||||
@@ -1098,6 +1205,10 @@ fn operation_from_s3_name(name: &str) -> Operation {
|
|||||||
"GetObjectTagging" => Operation::GetObjectTagging,
|
"GetObjectTagging" => Operation::GetObjectTagging,
|
||||||
"PutObjectTagging" => Operation::PutObjectTagging,
|
"PutObjectTagging" => Operation::PutObjectTagging,
|
||||||
"DeleteObjectTagging" => Operation::DeleteObjectTagging,
|
"DeleteObjectTagging" => Operation::DeleteObjectTagging,
|
||||||
|
"GetObjectRetention" => Operation::GetObjectRetention,
|
||||||
|
"PutObjectRetention" => Operation::PutObjectRetention,
|
||||||
|
"GetObjectLegalHold" => Operation::GetObjectLegalHold,
|
||||||
|
"PutObjectLegalHold" => Operation::PutObjectLegalHold,
|
||||||
"ListObjectsV2" => Operation::ListObjectsV2,
|
"ListObjectsV2" => Operation::ListObjectsV2,
|
||||||
"CreateMultipartUpload" => Operation::CreateMultipartUpload,
|
"CreateMultipartUpload" => Operation::CreateMultipartUpload,
|
||||||
"UploadPart" => Operation::UploadPart,
|
"UploadPart" => Operation::UploadPart,
|
||||||
@@ -1235,6 +1346,18 @@ fn parse_request(method: &Method, uri: &Uri) -> ParsedRequest {
|
|||||||
(&Method::DELETE, true) if query.contains_key("tagging") && only_query_keys(&["tagging", "versionId"]) => {
|
(&Method::DELETE, true) if query.contains_key("tagging") && only_query_keys(&["tagging", "versionId"]) => {
|
||||||
Operation::DeleteObjectTagging
|
Operation::DeleteObjectTagging
|
||||||
}
|
}
|
||||||
|
(&Method::GET, true) if query.contains_key("retention") && only_query_keys(&["retention", "versionId"]) => {
|
||||||
|
Operation::GetObjectRetention
|
||||||
|
}
|
||||||
|
(&Method::PUT, true) if query.contains_key("retention") && only_query_keys(&["retention", "versionId"]) => {
|
||||||
|
Operation::PutObjectRetention
|
||||||
|
}
|
||||||
|
(&Method::GET, true) if query.contains_key("legal-hold") && only_query_keys(&["legal-hold", "versionId"]) => {
|
||||||
|
Operation::GetObjectLegalHold
|
||||||
|
}
|
||||||
|
(&Method::PUT, true) if query.contains_key("legal-hold") && only_query_keys(&["legal-hold", "versionId"]) => {
|
||||||
|
Operation::PutObjectLegalHold
|
||||||
|
}
|
||||||
// A replication PUT addresses the source version via `?versionId=`.
|
// A replication PUT addresses the source version via `?versionId=`.
|
||||||
(&Method::PUT, true) if only_query_keys(&["versionId"]) => Operation::PutObject,
|
(&Method::PUT, true) if only_query_keys(&["versionId"]) => Operation::PutObject,
|
||||||
(&Method::GET, true) if only_query_keys(&["versionId"]) => Operation::GetObject,
|
(&Method::GET, true) if only_query_keys(&["versionId"]) => Operation::GetObject,
|
||||||
@@ -1788,6 +1911,28 @@ fn set_version_tags(
|
|||||||
Ok(resolved)
|
Ok(resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_version_lock(
|
||||||
|
state: &mut StoreState,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
version_id: Option<&str>,
|
||||||
|
update: impl FnOnce(&mut VersionLock),
|
||||||
|
) -> S3Result<String> {
|
||||||
|
let resolved = find_version(state, bucket, key, version_id)?.version_id;
|
||||||
|
let version = state
|
||||||
|
.buckets
|
||||||
|
.get_mut(bucket)
|
||||||
|
.expect("bucket existence checked by find_version")
|
||||||
|
.objects
|
||||||
|
.get_mut(key)
|
||||||
|
.expect("key existence checked by find_version")
|
||||||
|
.iter_mut()
|
||||||
|
.find(|version| version.version_id == resolved)
|
||||||
|
.expect("version existence checked by find_version");
|
||||||
|
update(&mut version.lock);
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether version ids are surfaced for this bucket. Unknown buckets report
|
/// Whether version ids are surfaced for this bucket. Unknown buckets report
|
||||||
/// `true`; the caller's lookup raises `NoSuchBucket` first.
|
/// `true`; the caller's lookup raises `NoSuchBucket` first.
|
||||||
fn bucket_versioned(state: &StoreState, bucket: &str) -> bool {
|
fn bucket_versioned(state: &StoreState, bucket: &str) -> bool {
|
||||||
@@ -2227,6 +2372,11 @@ impl S3 for FakeBackend {
|
|||||||
standard_headers,
|
standard_headers,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted),
|
replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted),
|
||||||
|
lock: VersionLock::from_headers(
|
||||||
|
input.object_lock_mode,
|
||||||
|
input.object_lock_retain_until_date,
|
||||||
|
input.object_lock_legal_hold_status,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
upsert_version(&mut lock(&self.store), &input.bucket, input.key, version)?;
|
upsert_version(&mut lock(&self.store), &input.bucket, input.key, version)?;
|
||||||
Ok(apply_response_fault(
|
Ok(apply_response_fault(
|
||||||
@@ -2243,6 +2393,17 @@ impl S3 for FakeBackend {
|
|||||||
let fault = request_fault(&req);
|
let fault = request_fault(&req);
|
||||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||||
let input = req.input;
|
let input = req.input;
|
||||||
|
let held_get = lock(&self.control)
|
||||||
|
.held_get
|
||||||
|
.as_ref()
|
||||||
|
.filter(|held| held.bucket == input.bucket && held.key == input.key)
|
||||||
|
.cloned();
|
||||||
|
if let Some(mut held) = held_get {
|
||||||
|
held.entered.send_modify(|count| *count += 1);
|
||||||
|
// Keep the gate installed when a request is cancelled or times out:
|
||||||
|
// a retry must cross the same boundary before returning any bytes.
|
||||||
|
let _ = held.released.wait_for(|released| *released).await;
|
||||||
|
}
|
||||||
let (version, versioned) = {
|
let (version, versioned) = {
|
||||||
let state = lock(&self.store);
|
let state = lock(&self.store);
|
||||||
(
|
(
|
||||||
@@ -2274,6 +2435,13 @@ impl S3 for FakeBackend {
|
|||||||
last_modified: Some(version.last_modified.clone()),
|
last_modified: Some(version.last_modified.clone()),
|
||||||
version_id: versioned.then_some(version.version_id),
|
version_id: versioned.then_some(version.version_id),
|
||||||
sse_customer_algorithm,
|
sse_customer_algorithm,
|
||||||
|
object_lock_mode: version
|
||||||
|
.lock
|
||||||
|
.retention
|
||||||
|
.as_ref()
|
||||||
|
.map(|(mode, _)| ObjectLockMode::from(mode.clone())),
|
||||||
|
object_lock_retain_until_date: version.lock.retention.as_ref().map(|(_, until)| until.clone()),
|
||||||
|
object_lock_legal_hold_status: version.lock.legal_hold_status(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
response.status = served.status;
|
response.status = served.status;
|
||||||
@@ -2308,6 +2476,13 @@ impl S3 for FakeBackend {
|
|||||||
last_modified: Some(version.last_modified.clone()),
|
last_modified: Some(version.last_modified.clone()),
|
||||||
version_id: versioned.then_some(version.version_id),
|
version_id: versioned.then_some(version.version_id),
|
||||||
sse_customer_algorithm,
|
sse_customer_algorithm,
|
||||||
|
object_lock_mode: version
|
||||||
|
.lock
|
||||||
|
.retention
|
||||||
|
.as_ref()
|
||||||
|
.map(|(mode, _)| ObjectLockMode::from(mode.clone())),
|
||||||
|
object_lock_retain_until_date: version.lock.retention.as_ref().map(|(_, until)| until.clone()),
|
||||||
|
object_lock_legal_hold_status: version.lock.legal_hold_status(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
response.status = served.status;
|
response.status = served.status;
|
||||||
@@ -2367,6 +2542,82 @@ impl S3 for FakeBackend {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_object_retention(
|
||||||
|
&self,
|
||||||
|
req: S3Request<GetObjectRetentionInput>,
|
||||||
|
) -> S3Result<S3Response<GetObjectRetentionOutput>> {
|
||||||
|
let fault = request_fault(&req);
|
||||||
|
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||||
|
let input = req.input;
|
||||||
|
let version = find_version(&lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref())?;
|
||||||
|
Ok(apply_response_fault(
|
||||||
|
S3Response::new(GetObjectRetentionOutput {
|
||||||
|
retention: version.lock.retention.map(|(mode, until)| ObjectLockRetention {
|
||||||
|
mode: Some(ObjectLockRetentionMode::from(mode)),
|
||||||
|
retain_until_date: Some(until),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
fault.as_ref(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_object_retention(
|
||||||
|
&self,
|
||||||
|
req: S3Request<PutObjectRetentionInput>,
|
||||||
|
) -> S3Result<S3Response<PutObjectRetentionOutput>> {
|
||||||
|
let fault = request_fault(&req);
|
||||||
|
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||||
|
let input = req.input;
|
||||||
|
let retention = input
|
||||||
|
.retention
|
||||||
|
.and_then(|retention| retention.mode.zip(retention.retain_until_date))
|
||||||
|
.map(|(mode, until)| (mode.as_str().to_string(), until));
|
||||||
|
update_version_lock(&mut lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref(), |lock| {
|
||||||
|
lock.retention = retention;
|
||||||
|
})?;
|
||||||
|
Ok(apply_response_fault(S3Response::new(PutObjectRetentionOutput::default()), fault.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_object_legal_hold(
|
||||||
|
&self,
|
||||||
|
req: S3Request<GetObjectLegalHoldInput>,
|
||||||
|
) -> S3Result<S3Response<GetObjectLegalHoldOutput>> {
|
||||||
|
let fault = request_fault(&req);
|
||||||
|
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||||
|
let input = req.input;
|
||||||
|
let version = find_version(&lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref())?;
|
||||||
|
Ok(apply_response_fault(
|
||||||
|
S3Response::new(GetObjectLegalHoldOutput {
|
||||||
|
legal_hold: Some(ObjectLockLegalHold {
|
||||||
|
status: Some(
|
||||||
|
version
|
||||||
|
.lock
|
||||||
|
.legal_hold_status()
|
||||||
|
.unwrap_or_else(|| ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
fault.as_ref(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_object_legal_hold(
|
||||||
|
&self,
|
||||||
|
req: S3Request<PutObjectLegalHoldInput>,
|
||||||
|
) -> S3Result<S3Response<PutObjectLegalHoldOutput>> {
|
||||||
|
let fault = request_fault(&req);
|
||||||
|
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||||
|
let input = req.input;
|
||||||
|
let legal_hold_on = input
|
||||||
|
.legal_hold
|
||||||
|
.and_then(|hold| hold.status)
|
||||||
|
.is_some_and(|status| status.as_str().eq_ignore_ascii_case("ON"));
|
||||||
|
update_version_lock(&mut lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref(), |lock| {
|
||||||
|
lock.legal_hold = Some(legal_hold_on);
|
||||||
|
})?;
|
||||||
|
Ok(apply_response_fault(S3Response::new(PutObjectLegalHoldOutput::default()), fault.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_object_tagging(
|
async fn delete_object_tagging(
|
||||||
&self,
|
&self,
|
||||||
req: S3Request<DeleteObjectTaggingInput>,
|
req: S3Request<DeleteObjectTaggingInput>,
|
||||||
@@ -2420,6 +2671,7 @@ impl S3 for FakeBackend {
|
|||||||
return Ok(apply_response_fault(S3Response::new(DeleteObjectOutput::default()), fault.as_ref()));
|
return Ok(apply_response_fault(S3Response::new(DeleteObjectOutput::default()), fault.as_ref()));
|
||||||
}
|
}
|
||||||
if let Some(version_id) = input.version_id {
|
if let Some(version_id) = input.version_id {
|
||||||
|
let reject_unknown = state.reject_unknown_version_deletes;
|
||||||
let (removed_bytes, removed_versions, delete_marker, remove_key) = {
|
let (removed_bytes, removed_versions, delete_marker, remove_key) = {
|
||||||
let Some(versions) = state
|
let Some(versions) = state
|
||||||
.buckets
|
.buckets
|
||||||
@@ -2428,6 +2680,9 @@ impl S3 for FakeBackend {
|
|||||||
.objects
|
.objects
|
||||||
.get_mut(&input.key)
|
.get_mut(&input.key)
|
||||||
else {
|
else {
|
||||||
|
if reject_unknown {
|
||||||
|
return Err(s3s::s3_error!(NoSuchVersion, "The specified version does not exist."));
|
||||||
|
}
|
||||||
return Ok(apply_response_fault(
|
return Ok(apply_response_fault(
|
||||||
S3Response::new(DeleteObjectOutput {
|
S3Response::new(DeleteObjectOutput {
|
||||||
version_id: Some(version_id),
|
version_id: Some(version_id),
|
||||||
@@ -2436,6 +2691,9 @@ impl S3 for FakeBackend {
|
|||||||
fault.as_ref(),
|
fault.as_ref(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
if reject_unknown && !versions.iter().any(|version| version.version_id == version_id) {
|
||||||
|
return Err(s3s::s3_error!(NoSuchVersion, "The specified version does not exist."));
|
||||||
|
}
|
||||||
let mut removed_bytes = 0usize;
|
let mut removed_bytes = 0usize;
|
||||||
let mut removed_versions = 0usize;
|
let mut removed_versions = 0usize;
|
||||||
let mut delete_marker = None;
|
let mut delete_marker = None;
|
||||||
@@ -2489,6 +2747,7 @@ impl S3 for FakeBackend {
|
|||||||
standard_headers: StandardHeaders::default(),
|
standard_headers: StandardHeaders::default(),
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
replication_sse_headers: Vec::new(),
|
replication_sse_headers: Vec::new(),
|
||||||
|
lock: VersionLock::default(),
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
Ok(apply_response_fault(
|
Ok(apply_response_fault(
|
||||||
@@ -2543,6 +2802,11 @@ impl S3 for FakeBackend {
|
|||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
standard_headers,
|
standard_headers,
|
||||||
replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted),
|
replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted),
|
||||||
|
lock: VersionLock::from_headers(
|
||||||
|
input.object_lock_mode,
|
||||||
|
input.object_lock_retain_until_date,
|
||||||
|
input.object_lock_legal_hold_status,
|
||||||
|
),
|
||||||
parts: BTreeMap::new(),
|
parts: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2683,6 +2947,7 @@ impl S3 for FakeBackend {
|
|||||||
metadata: upload.metadata.clone(),
|
metadata: upload.metadata.clone(),
|
||||||
standard_headers: upload.standard_headers.clone(),
|
standard_headers: upload.standard_headers.clone(),
|
||||||
replication_sse_headers: upload.replication_sse_headers.clone(),
|
replication_sse_headers: upload.replication_sse_headers.clone(),
|
||||||
|
lock: upload.lock.clone(),
|
||||||
parts: BTreeMap::new(),
|
parts: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
selected,
|
selected,
|
||||||
@@ -2713,6 +2978,7 @@ impl S3 for FakeBackend {
|
|||||||
standard_headers: upload.standard_headers,
|
standard_headers: upload.standard_headers,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
replication_sse_headers: upload.replication_sse_headers,
|
replication_sse_headers: upload.replication_sse_headers,
|
||||||
|
lock: upload.lock,
|
||||||
};
|
};
|
||||||
let mut state = lock(&self.store);
|
let mut state = lock(&self.store);
|
||||||
let versioned = bucket_versioned(&state, &input.bucket);
|
let versioned = bucket_versioned(&state, &input.bucket);
|
||||||
@@ -2894,6 +3160,81 @@ mod tests {
|
|||||||
aws_sdk_s3::primitives::DateTime::from_secs(4_102_444_800)
|
aws_sdk_s3::primitives::DateTime::from_secs(4_102_444_800)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_object_gate_holds_retries_and_releases_on_drop() -> Result<(), BoxError> {
|
||||||
|
let target = FakeS3Target::start().await?;
|
||||||
|
let bucket = "gated-target";
|
||||||
|
target.create_bucket(bucket);
|
||||||
|
for key in ["held", "unrelated"] {
|
||||||
|
target.put_seed_object(bucket, key, Bytes::from_static(b"payload"), &SeedMetadata::default());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let gate = target.hold_get_object(bucket, "held");
|
||||||
|
let request = || S3Request {
|
||||||
|
input: GetObjectInput {
|
||||||
|
bucket: bucket.to_string(),
|
||||||
|
key: "held".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
method: Method::GET,
|
||||||
|
uri: Uri::from_static("/gated-target/held"),
|
||||||
|
headers: HeaderMap::new(),
|
||||||
|
extensions: http::Extensions::new(),
|
||||||
|
credentials: None,
|
||||||
|
region: None,
|
||||||
|
service: None,
|
||||||
|
trailing_headers: None,
|
||||||
|
};
|
||||||
|
// Without a fault, only the gate can suspend this backend method.
|
||||||
|
let mut first = target.backend.get_object(request());
|
||||||
|
assert!(futures::poll!(first.as_mut()).is_pending(), "the first GET must wait at the gate");
|
||||||
|
drop(first);
|
||||||
|
let mut retry = target.backend.get_object(request());
|
||||||
|
assert!(futures::poll!(retry.as_mut()).is_pending(), "a cancelled GET must not consume the gate");
|
||||||
|
drop(gate);
|
||||||
|
let std::task::Poll::Ready(response) = futures::poll!(retry.as_mut()) else {
|
||||||
|
panic!("dropping the gate must release the waiting GET");
|
||||||
|
};
|
||||||
|
let mut body = response?.output.body.expect("released GET body");
|
||||||
|
assert_eq!(body.next().await.transpose()?, Some(Bytes::from_static(b"payload")));
|
||||||
|
assert!(body.next().await.is_none(), "released GET body must be complete");
|
||||||
|
}
|
||||||
|
let client = client(&target);
|
||||||
|
let mut gate = target.hold_get_object(bucket, "held");
|
||||||
|
let mut requests = tokio::task::JoinSet::new();
|
||||||
|
let first = client.clone();
|
||||||
|
requests.spawn(async move { get_bytes(&first, bucket, "held", None).await });
|
||||||
|
timeout(Duration::from_secs(2), gate.wait_until_entered()).await??;
|
||||||
|
requests.abort_all();
|
||||||
|
assert!(
|
||||||
|
requests
|
||||||
|
.join_next()
|
||||||
|
.await
|
||||||
|
.expect("first GET task")
|
||||||
|
.expect_err("cancel the first GET attempt")
|
||||||
|
.is_cancelled()
|
||||||
|
);
|
||||||
|
|
||||||
|
let retry = client.clone();
|
||||||
|
requests.spawn(async move { get_bytes(&retry, bucket, "held", None).await });
|
||||||
|
timeout(Duration::from_secs(2), gate.entered.wait_for(|count| *count == 2)).await??;
|
||||||
|
assert_eq!(
|
||||||
|
timeout(Duration::from_secs(2), get_bytes(&client, bucket, "unrelated", None)).await??,
|
||||||
|
Bytes::from_static(b"payload")
|
||||||
|
);
|
||||||
|
assert!(requests.try_join_next().is_none(), "the retry must remain behind the gate");
|
||||||
|
drop(gate);
|
||||||
|
assert_eq!(
|
||||||
|
timeout(Duration::from_secs(2), requests.join_next())
|
||||||
|
.await?
|
||||||
|
.expect("retried GET task")??,
|
||||||
|
Bytes::from_static(b"payload")
|
||||||
|
);
|
||||||
|
assert_eq!(get_bytes(&client, bucket, "held", None).await?, Bytes::from_static(b"payload"));
|
||||||
|
assert_eq!(target.count_requests(Operation::GetObject, "held"), 3);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
|
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
@@ -4469,6 +4810,7 @@ mod tests {
|
|||||||
metadata: None,
|
metadata: None,
|
||||||
standard_headers: StandardHeaders::default(),
|
standard_headers: StandardHeaders::default(),
|
||||||
replication_sse_headers: Vec::new(),
|
replication_sse_headers: Vec::new(),
|
||||||
|
lock: VersionLock::default(),
|
||||||
parts: BTreeMap::new(),
|
parts: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,24 +16,128 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
|
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
|
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
|
||||||
|
rustfs_binary_path,
|
||||||
};
|
};
|
||||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use http::Method;
|
use http::Method;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::io::{Read, Write};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio::time::{Duration, Instant, sleep, timeout};
|
use tokio::time::{Duration, Instant, sleep, timeout};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
const POOL_METADATA_OBJECT: &str = "pool.bin";
|
const POOL_METADATA_OBJECT: &str = "pool.bin";
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct EvidenceBuild {
|
||||||
|
sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct RestartEvidenceRun {
|
||||||
|
schema: u32,
|
||||||
|
run_id: String,
|
||||||
|
source_revision: String,
|
||||||
|
test_build: serde_json::Value,
|
||||||
|
binary: EvidenceBuild,
|
||||||
|
test_binary: EvidenceBuild,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct ScannerHealEvidenceCase {
|
||||||
|
id: &'static str,
|
||||||
|
oracle: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-restart",
|
||||||
|
oracle: "background-target-restart.json",
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RestartEvidenceContext {
|
||||||
|
directory: PathBuf,
|
||||||
|
run: RestartEvidenceRun,
|
||||||
|
case: ScannerHealEvidenceCase,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||||
|
let mut file = std::fs::File::open(path)?;
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
let mut buffer = [0_u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = file.read(&mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
digest.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restart_evidence_run(
|
||||||
|
binary: &Path,
|
||||||
|
case: ScannerHealEvidenceCase,
|
||||||
|
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
|
||||||
|
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if case.id.is_empty()
|
||||||
|
|| case.oracle.is_empty()
|
||||||
|
|| !case.oracle.ends_with(".json")
|
||||||
|
|| case.oracle.contains('/')
|
||||||
|
|| case.oracle.contains('\\')
|
||||||
|
|| case.oracle.contains("..")
|
||||||
|
{
|
||||||
|
return Err("invalid scanner/heal evidence case".into());
|
||||||
|
}
|
||||||
|
let directory = PathBuf::from(directory);
|
||||||
|
let receipt = directory.join("run.json");
|
||||||
|
if receipt.metadata()?.len() > 1024 * 1024 {
|
||||||
|
return Err("oversized scanner/heal execution receipt".into());
|
||||||
|
}
|
||||||
|
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
|
||||||
|
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
|
||||||
|
return Err("invalid scanner/heal execution identity".into());
|
||||||
|
}
|
||||||
|
let built = compiled_test_identity();
|
||||||
|
for key in ["source_revision", "dirty", "lock_blob", "features"] {
|
||||||
|
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
|
||||||
|
}
|
||||||
|
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
|
||||||
|
assert_eq!(
|
||||||
|
file_sha256(&std::env::current_exe()?)?,
|
||||||
|
run.test_binary.sha256,
|
||||||
|
"test executable must match the run receipt"
|
||||||
|
);
|
||||||
|
if directory.join(case.oracle).exists() {
|
||||||
|
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
||||||
|
}
|
||||||
|
Ok(Some(RestartEvidenceContext { directory, run, case }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compiled_test_identity() -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
||||||
|
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
|
||||||
|
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
|
||||||
|
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
|
||||||
|
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
|
||||||
|
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
|
||||||
|
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
struct TcpPortBlackhole {
|
struct TcpPortBlackhole {
|
||||||
port: u16,
|
port: u16,
|
||||||
comment: String,
|
comment: String,
|
||||||
@@ -42,6 +146,49 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TcpPortBlackhole {
|
impl TcpPortBlackhole {
|
||||||
|
/// Environment flag that turns an unusable fault-injection host into a
|
||||||
|
/// hard failure instead of a logged skip. Lanes that provision
|
||||||
|
/// `CAP_NET_ADMIN` set it so a broken runner cannot pass silently.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const REQUIRE_ENV: &str = "RUSTFS_E2E_REQUIRE_NET_FAULT_INJECTION";
|
||||||
|
|
||||||
|
/// Probe whether this host can manipulate the OUTPUT chain at all.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(Some(reason))` when `iptables` is missing or lacks
|
||||||
|
/// `CAP_NET_ADMIN` (the nf_tables backend reports "Permission denied"
|
||||||
|
/// even under `sudo` inside an unprivileged container) and the lane did
|
||||||
|
/// not demand fault injection; returns an error when the lane demands
|
||||||
|
/// it; returns `Ok(None)` when the blackhole can be installed.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn unavailable_reason() -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
|
||||||
|
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 command = if use_sudo {
|
||||||
|
let mut command = Command::new("sudo");
|
||||||
|
command.args(["-n", "iptables"]);
|
||||||
|
command
|
||||||
|
} else {
|
||||||
|
Command::new("iptables")
|
||||||
|
};
|
||||||
|
let probe = command.args(["-w", "5", "-S", "OUTPUT"]).output();
|
||||||
|
let reason = match probe {
|
||||||
|
Ok(output) if output.status.success() => return Ok(None),
|
||||||
|
Ok(output) => format!(
|
||||||
|
"iptables cannot read the OUTPUT chain (status {}): {}",
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
),
|
||||||
|
Err(err) => format!("iptables is not runnable: {err}"),
|
||||||
|
};
|
||||||
|
if std::env::var_os(Self::REQUIRE_ENV).is_some() {
|
||||||
|
return Err(format!("{} is set but network fault injection is unavailable: {reason}", Self::REQUIRE_ENV).into());
|
||||||
|
}
|
||||||
|
Ok(Some(reason))
|
||||||
|
}
|
||||||
|
|
||||||
fn install(address: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
fn install(address: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||||
let address = address.parse::<SocketAddr>()?;
|
let address = address.parse::<SocketAddr>()?;
|
||||||
if !address.ip().is_loopback() {
|
if !address.ip().is_loopback() {
|
||||||
@@ -126,6 +273,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a disk directory underneath a running server. Background writers
|
||||||
|
/// (scanner, usage cache, heal markers) can recreate entries between the
|
||||||
|
/// recursive listing and the final `rmdir`, which surfaces as
|
||||||
|
/// `DirectoryNotEmpty` on macOS; retry briefly so the wipe reflects the
|
||||||
|
/// operator action rather than a listing race.
|
||||||
|
fn wipe_directory_while_server_runs(disk: &Path) -> std::io::Result<()> {
|
||||||
|
let mut last_err = None;
|
||||||
|
for _ in 0..20 {
|
||||||
|
match std::fs::remove_dir_all(disk) {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
|
||||||
|
last_err = Some(err);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_err.expect("retry loop only exits without success after recording an error"))
|
||||||
|
}
|
||||||
|
|
||||||
fn has_file_under(path: &Path) -> bool {
|
fn has_file_under(path: &Path) -> bool {
|
||||||
let Ok(entries) = std::fs::read_dir(path) else {
|
let Ok(entries) = std::fs::read_dir(path) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -195,8 +363,9 @@ mod tests {
|
|||||||
clients: &[aws_sdk_s3::Client],
|
clients: &[aws_sdk_s3::Client],
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
expected_keys: &HashSet<String>,
|
expected_keys: &HashSet<String>,
|
||||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
) -> Result<Vec<Vec<String>>, Box<dyn Error + Send + Sync>> {
|
||||||
const PAGE_SIZE: i32 = 10;
|
const PAGE_SIZE: i32 = 10;
|
||||||
|
let mut node_listings = Vec::with_capacity(clients.len());
|
||||||
for (node_index, client) in clients.iter().enumerate() {
|
for (node_index, client) in clients.iter().enumerate() {
|
||||||
let mut listed_keys = Vec::new();
|
let mut listed_keys = Vec::new();
|
||||||
let mut continuation_token = None;
|
let mut continuation_token = None;
|
||||||
@@ -243,8 +412,10 @@ mod tests {
|
|||||||
&listed_key_set, expected_keys,
|
&listed_key_set, expected_keys,
|
||||||
"node {node_index} did not expose the complete recovered namespace"
|
"node {node_index} did not expose the complete recovered namespace"
|
||||||
);
|
);
|
||||||
|
listed_keys.sort();
|
||||||
|
node_listings.push(listed_keys);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(node_listings)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn heal_task_status_diagnostic(body: &str) -> String {
|
fn heal_task_status_diagnostic(body: &str) -> String {
|
||||||
@@ -405,7 +576,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::remove_dir_all(&disk0).expect("disk0 wipe should succeed while server is running");
|
wipe_directory_while_server_runs(&disk0).expect("disk0 wipe should succeed while server is running");
|
||||||
std::fs::create_dir_all(&disk0).expect("disk0 should be recreated empty while server is running");
|
std::fs::create_dir_all(&disk0).expect("disk0 should be recreated empty while server is running");
|
||||||
assert!(!has_file_under(&disk0), "disk0 must be empty immediately after runtime wipe");
|
assert!(!has_file_under(&disk0), "disk0 must be empty immediately after runtime wipe");
|
||||||
|
|
||||||
@@ -792,6 +963,18 @@ mod tests {
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_cluster_root_heal_recovers_after_target_endpoint_blackhole() -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn test_cluster_root_heal_recovers_after_target_endpoint_blackhole() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
if let Some(reason) = TcpPortBlackhole::unavailable_reason()? {
|
||||||
|
init_logging();
|
||||||
|
warn!(
|
||||||
|
event = "heal_interruption_skipped",
|
||||||
|
component = "e2e_test",
|
||||||
|
subsystem = "heal",
|
||||||
|
interruption_kind = "target_endpoint_blackhole",
|
||||||
|
reason,
|
||||||
|
"Skipping endpoint blackhole scenario: network fault injection is unavailable on this host"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
timeout(
|
timeout(
|
||||||
Duration::from_secs(420),
|
Duration::from_secs(420),
|
||||||
run_cluster_root_heal_interruption(InterruptionScenario::TargetEndpointBlackhole),
|
run_cluster_root_heal_interruption(InterruptionScenario::TargetEndpointBlackhole),
|
||||||
@@ -808,6 +991,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let server_binary = rustfs_binary_path();
|
||||||
|
let evidence_run = if scenario == InterruptionScenario::BackgroundTargetRestart {
|
||||||
|
restart_evidence_run(&server_binary, BACKGROUND_TARGET_RESTART_EVIDENCE)?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut evidence_objects = Vec::new();
|
||||||
let (background_enabled, interruption_node, interruption_kind) = match scenario {
|
let (background_enabled, interruption_node, interruption_kind) = match scenario {
|
||||||
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
|
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
|
||||||
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
|
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
|
||||||
@@ -855,7 +1045,7 @@ mod tests {
|
|||||||
for node_index in 0..cluster.nodes.len() {
|
for node_index in 0..cluster.nodes.len() {
|
||||||
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
|
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
|
||||||
}
|
}
|
||||||
cluster.start().await?;
|
cluster.start_with_binary(&server_binary).await?;
|
||||||
let clients = cluster.create_all_clients()?;
|
let clients = cluster.create_all_clients()?;
|
||||||
|
|
||||||
let bucket = "heal-restart-during-rebuild";
|
let bucket = "heal-restart-during-rebuild";
|
||||||
@@ -996,7 +1186,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cluster.start_node(1).await?;
|
cluster.start_node_from_binary(1, &server_binary).await?;
|
||||||
|
|
||||||
let status_url = format!("{}/rustfs/admin/v3/background-heal/status", cluster.nodes[0].url);
|
let status_url = format!("{}/rustfs/admin/v3/background-heal/status", cluster.nodes[0].url);
|
||||||
let recovery_deadline = Instant::now() + Duration::from_secs(60);
|
let recovery_deadline = Instant::now() + Duration::from_secs(60);
|
||||||
@@ -1274,7 +1464,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cluster.start_node(interruption_node).await?;
|
cluster.start_node_from_binary(interruption_node, &server_binary).await?;
|
||||||
if interruption_node == 0 {
|
if interruption_node == 0 {
|
||||||
let target = cluster.nodes[1]
|
let target = cluster.nodes[1]
|
||||||
.process
|
.process
|
||||||
@@ -1373,7 +1563,7 @@ mod tests {
|
|||||||
.map(|manifest| manifest.key.clone())
|
.map(|manifest| manifest.key.clone())
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
assert!(expected_keys.insert(outage_key.to_string()));
|
assert!(expected_keys.insert(outage_key.to_string()));
|
||||||
assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
|
let node_listings = assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
|
||||||
|
|
||||||
let target_client = cluster.create_s3_client(1)?;
|
let target_client = cluster.create_s3_client(1)?;
|
||||||
for expected in &expected_manifests {
|
for expected in &expected_manifests {
|
||||||
@@ -1381,11 +1571,31 @@ mod tests {
|
|||||||
let actual = response.body.collect().await?.into_bytes();
|
let actual = response.body.collect().await?.into_bytes();
|
||||||
let expected_body = deterministic_object_body(object_size_bytes, expected.payload_seed);
|
let expected_body = deterministic_object_body(object_size_bytes, expected.payload_seed);
|
||||||
assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {}", expected.key);
|
assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {}", expected.key);
|
||||||
|
if evidence_run.is_some() {
|
||||||
|
evidence_objects.push(serde_json::json!({
|
||||||
|
"key": expected.key, "version_id": expected.shard_census.version_id,
|
||||||
|
"expected_bytes": expected_body.len(), "actual_bytes": actual.len(),
|
||||||
|
"expected_sha256": sha256_hex(&expected_body),
|
||||||
|
"actual_sha256": sha256_hex(&actual),
|
||||||
|
"expected_physical": expected.shard_census,
|
||||||
|
"physical": census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let response = target_client.get_object().bucket(bucket).key(outage_key).send().await?;
|
let response = target_client.get_object().bucket(bucket).key(outage_key).send().await?;
|
||||||
let actual = response.body.collect().await?.into_bytes();
|
let actual = response.body.collect().await?.into_bytes();
|
||||||
let expected_outage_body = deterministic_object_body(object_size_bytes, outage_payload_seed);
|
let expected_outage_body = deterministic_object_body(object_size_bytes, outage_payload_seed);
|
||||||
assert_eq!(actual.as_ref(), expected_outage_body.as_slice(), "object body changed for {outage_key}");
|
assert_eq!(actual.as_ref(), expected_outage_body.as_slice(), "object body changed for {outage_key}");
|
||||||
|
if evidence_run.is_some() {
|
||||||
|
evidence_objects.push(serde_json::json!({
|
||||||
|
"key": outage_key, "version_id": null,
|
||||||
|
"expected_bytes": expected_outage_body.len(), "actual_bytes": actual.len(),
|
||||||
|
"expected_sha256": sha256_hex(&expected_outage_body),
|
||||||
|
"actual_sha256": sha256_hex(&actual),
|
||||||
|
"expected_physical": null,
|
||||||
|
"physical": census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let terminal_deadline = Instant::now() + Duration::from_secs(30);
|
let terminal_deadline = Instant::now() + Duration::from_secs(30);
|
||||||
loop {
|
loop {
|
||||||
@@ -1432,6 +1642,36 @@ mod tests {
|
|||||||
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
|
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(evidence_context) = evidence_run {
|
||||||
|
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
|
||||||
|
assert_ne!(target_pid, restarted_pid, "target must be a new process");
|
||||||
|
assert_eq!(
|
||||||
|
file_sha256(&server_binary)?,
|
||||||
|
evidence_context.run.binary.sha256,
|
||||||
|
"server build changed during restart"
|
||||||
|
);
|
||||||
|
let evidence = serde_json::json!({
|
||||||
|
"schema": 1, "case": evidence_context.case.id, "evidence": "process-restart",
|
||||||
|
"run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
|
||||||
|
"test_build": compiled_test_identity(),
|
||||||
|
"binary_sha256": evidence_context.run.binary.sha256,
|
||||||
|
"test_binary_sha256": evidence_context.run.test_binary.sha256,
|
||||||
|
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
|
||||||
|
"pid_before": target_pid, "pid_after": restarted_pid,
|
||||||
|
"objects": evidence_objects, "node_listings": node_listings,
|
||||||
|
});
|
||||||
|
let data = serde_json::to_vec(&evidence)?;
|
||||||
|
if data.len() > 1024 * 1024 {
|
||||||
|
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
|
||||||
|
}
|
||||||
|
let mut output = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(evidence_context.directory.join(evidence_context.case.oracle))?;
|
||||||
|
output.write_all(&data)?;
|
||||||
|
output.sync_all()?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -378,6 +378,11 @@ mod bucket_stats_regression_test;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod distributed_startup_regression_test;
|
mod distributed_startup_regression_test;
|
||||||
|
|
||||||
|
// 4-node / 4-disk distributed Actions suite (S3, lock, versioning, replication,
|
||||||
|
// quota, observability, expand/decommission/rebalance, site replication, chaos).
|
||||||
|
#[cfg(test)]
|
||||||
|
mod distributed;
|
||||||
|
|
||||||
// P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024)
|
// P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024)
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tier_transition_regression_test;
|
mod tier_transition_regression_test;
|
||||||
|
|||||||
@@ -20,9 +20,10 @@
|
|||||||
//! journal (`count_requests`) carries the assertion in every one of them.
|
//! journal (`count_requests`) carries the assertion in every one of them.
|
||||||
|
|
||||||
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
|
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
|
||||||
use crate::fake_s3_target::Operation;
|
use crate::fake_s3_target::{FaultAction, Operation};
|
||||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use futures::{StreamExt, TryStreamExt};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
type TestResult = Result<(), BoxError>;
|
type TestResult = Result<(), BoxError>;
|
||||||
@@ -145,14 +146,38 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let body = payload(128 * 1024);
|
let body = payload(128 * 1024);
|
||||||
|
let blocker = "queue/blocker.bin";
|
||||||
|
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(blocker, body.clone())]);
|
||||||
|
// The one-chunk range completes immediately; its full background pull
|
||||||
|
// occupies the only slot while the remaining requests fill the queue.
|
||||||
|
env.source.inject_for_key(
|
||||||
|
Operation::GetObject,
|
||||||
|
blocker,
|
||||||
|
FaultAction::SlowSendBody {
|
||||||
|
chunk_bytes: 1024,
|
||||||
|
delay: Duration::from_millis(100),
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
let response = env
|
||||||
|
.raw_object_request(http::Method::GET, bucket, blocker, &[("range", "bytes=0-1023")])
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status, 206);
|
||||||
|
assert_eq!(response.body, body.slice(0..1024));
|
||||||
|
env.wait_for_status_counter(bucket, "/inflight_pulls", 1, SETTLE).await?;
|
||||||
|
|
||||||
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
|
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();
|
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
|
||||||
env.seed_source(SOURCE_BUCKET, &seeds);
|
env.seed_source(SOURCE_BUCKET, &seeds);
|
||||||
|
|
||||||
let responses: Vec<RawResponse> = futures::future::try_join_all(
|
// Bound source connections below the fixture's limit while still
|
||||||
|
// submitting all 100 requests to the eight-slot background queue.
|
||||||
|
let responses: Vec<RawResponse> = futures::stream::iter(
|
||||||
keys.iter()
|
keys.iter()
|
||||||
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
|
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
|
||||||
)
|
)
|
||||||
|
.buffered(16)
|
||||||
|
.try_collect()
|
||||||
.await?;
|
.await?;
|
||||||
for (key, response) in keys.iter().zip(&responses) {
|
for (key, response) in keys.iter().zip(&responses) {
|
||||||
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
|
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
|
||||||
@@ -168,6 +193,15 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
|
|||||||
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
|
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
|
||||||
.await?;
|
.await?;
|
||||||
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
|
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
|
||||||
|
let queue_full = usize::try_from(queue_full)?;
|
||||||
|
assert!(queue_full <= REQUESTS);
|
||||||
|
env.wait_for_status_counter(
|
||||||
|
bucket,
|
||||||
|
"/counters/pulled_objects_total/background",
|
||||||
|
u64::try_from(REQUESTS + 1 - queue_full)?,
|
||||||
|
SETTLE,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
|
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -175,9 +209,6 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
|
|||||||
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
|
"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();
|
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
|
||||||
assert!(
|
assert_eq!(dropped, queue_full, "only overflowed keys remain without a background GET");
|
||||||
dropped > 0,
|
|
||||||
"the overflowed keys are the ones with no backfill GET, but every key got one"
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject};
|
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject};
|
||||||
use crate::fake_s3_target::{BucketMode, Operation};
|
use crate::fake_s3_target::{BucketMode, Operation};
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
use aws_sdk_s3::types::{BucketVersioningStatus, ObjectAttributes, VersioningConfiguration};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -103,11 +103,19 @@ async fn get_miss_pulls_inline_and_serves_locally_afterwards() -> TestResult {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_large_object_streams_through_and_backfills_in_background() -> TestResult {
|
async fn get_large_object_streams_through_and_backfills_in_background() -> TestResult {
|
||||||
|
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||||
let bucket = "odm-get-large";
|
let bucket = "odm-get-large";
|
||||||
let env = configured_env(bucket, |spec| spec.policy.inline_max_bytes = 4096).await?;
|
let env = configured_env(bucket, |spec| {
|
||||||
|
spec.policy.inline_max_bytes = 4096;
|
||||||
|
spec.policy.multipart_part_size_bytes = u64::try_from(PART_SIZE).expect("part size fits in u64");
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
let key = "large/archive.bin";
|
let key = "large/archive.bin";
|
||||||
let body = payload(512 * 1024);
|
let body = payload(PART_SIZE + 4096);
|
||||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
let etag = env
|
||||||
|
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
|
||||||
|
.remove(0);
|
||||||
|
assert_eq!(etag.len(), 32, "the source fixture has a plain MD5 ETag");
|
||||||
|
|
||||||
let response = env.raw_get(bucket, key).await?;
|
let response = env.raw_get(bucket, key).await?;
|
||||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||||
@@ -125,6 +133,68 @@ async fn get_large_object_streams_through_and_backfills_in_background() -> TestR
|
|||||||
vec![None, None],
|
vec![None, None],
|
||||||
"one passthrough GET plus one background pull, both unranged"
|
"one passthrough GET plus one background pull, both unranged"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let source_requests = env.source.requests().len();
|
||||||
|
let second_part = env.client.get_object().bucket(bucket).key(key).part_number(2).send().await?;
|
||||||
|
assert_eq!(second_part.content_length(), Some(4096), "the completed second part is the tail");
|
||||||
|
assert_eq!(
|
||||||
|
second_part.content_range(),
|
||||||
|
Some(format!("bytes {PART_SIZE}-{}/{}", body.len() - 1, body.len()).as_str()),
|
||||||
|
"partNumber reads the stored multipart boundary"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
second_part.body.collect().await?.into_bytes(),
|
||||||
|
body.slice(PART_SIZE..),
|
||||||
|
"the local second part contains the exact source tail"
|
||||||
|
);
|
||||||
|
let third_part = env
|
||||||
|
.client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.part_number(3)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("the completed object has exactly two parts");
|
||||||
|
assert_eq!(third_part.code(), Some("InvalidPart"));
|
||||||
|
|
||||||
|
let mut part_marker = None;
|
||||||
|
for (part_number, part_size) in [(1, PART_SIZE), (2, 4096)] {
|
||||||
|
let attributes = env
|
||||||
|
.client
|
||||||
|
.get_object_attributes()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.object_attributes(ObjectAttributes::ObjectParts)
|
||||||
|
.object_attributes(ObjectAttributes::Etag)
|
||||||
|
.max_parts(1)
|
||||||
|
.set_part_number_marker(part_marker.clone())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
attributes.e_tag().map(|value| value.trim_matches('"')),
|
||||||
|
Some(etag.as_str()),
|
||||||
|
"multipart write-back preserves the source MD5 ETag"
|
||||||
|
);
|
||||||
|
let parts = attributes
|
||||||
|
.object_parts()
|
||||||
|
.expect("RustFS must expose the stored multipart layout");
|
||||||
|
assert_eq!(parts.total_parts_count(), Some(2));
|
||||||
|
assert_eq!(parts.max_parts(), Some(1));
|
||||||
|
assert_eq!(parts.is_truncated(), Some(part_number == 1));
|
||||||
|
assert_eq!(parts.parts().len(), 1, "RustFS returns one stored part per requested page");
|
||||||
|
assert_eq!(parts.parts()[0].part_number(), Some(part_number));
|
||||||
|
assert_eq!(parts.parts()[0].size(), Some(i64::try_from(part_size).expect("part size fits in i64")));
|
||||||
|
part_marker = parts.next_part_number_marker().map(str::to_owned);
|
||||||
|
if part_number == 1 {
|
||||||
|
assert_eq!(part_marker.as_deref(), Some("1"), "the next request continues after the first part");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
env.source.requests().len(),
|
||||||
|
source_requests,
|
||||||
|
"local part reads must not consult the source"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,17 +22,21 @@
|
|||||||
//! local object and what the source was asked for.
|
//! local object and what the source was asked for.
|
||||||
|
|
||||||
use super::common::{
|
use super::common::{
|
||||||
AdminResponse, BoxError, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env,
|
ALLOW_LOOPBACK_SOURCE_ENV, AdminResponse, BackfillOp, BackfillRequest, BoxError, ODM_MODULE_SWITCH_ENV, ODM_SERVER_ENV,
|
||||||
start_configured_env_with,
|
OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env, start_configured_env_with, start_source_rustfs,
|
||||||
};
|
};
|
||||||
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
|
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::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
|
||||||
use crate::object_lock::common::put_object_lock_configuration;
|
use crate::object_lock::common::put_object_lock_configuration;
|
||||||
|
use crate::replication_extension_test::{
|
||||||
|
ReplicationTargetOptions, enable_bucket_versioning, set_replication_target_with_options,
|
||||||
|
};
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
|
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
|
||||||
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault,
|
ObjectAttributes, ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption,
|
||||||
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, VersioningConfiguration,
|
ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Tag, Tagging,
|
||||||
|
VersioningConfiguration,
|
||||||
};
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use local_ip_address::local_ip;
|
use local_ip_address::local_ip;
|
||||||
@@ -580,6 +584,130 @@ async fn test_odm_pulled_object_replicates_and_target_as_source_is_rejected() ->
|
|||||||
"a bucket may not migrate from its own replication target: {}",
|
"a bucket may not migrate from its own replication target: {}",
|
||||||
rejected.body
|
rejected.body
|
||||||
);
|
);
|
||||||
|
Box::pin(assert_odm_multipart_replicates_to_rustfs(&env, bucket)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_odm_multipart_replicates_to_rustfs(env: &OdmTestEnv, bucket: &str) -> TestResult {
|
||||||
|
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||||
|
let replica = start_source_rustfs().await?;
|
||||||
|
let replica_bucket = "odm-real-replica";
|
||||||
|
replica.create_test_bucket(replica_bucket).await?;
|
||||||
|
enable_bucket_versioning(&replica, replica_bucket).await?;
|
||||||
|
let arn = set_replication_target_with_options(
|
||||||
|
&env.rustfs,
|
||||||
|
bucket,
|
||||||
|
ReplicationTargetOptions {
|
||||||
|
endpoint: &replica.address,
|
||||||
|
access_key: &replica.access_key,
|
||||||
|
secret_key: &replica.secret_key,
|
||||||
|
target_bucket: replica_bucket,
|
||||||
|
secure: false,
|
||||||
|
skip_tls_verify: false,
|
||||||
|
ca_cert_pem: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
put_bucket_replication(&env.rustfs, bucket, &arn).await?;
|
||||||
|
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||||
|
// Below the 16 MiB inline default the pull is one tee'd PUT with a single
|
||||||
|
// part; force the passthrough + background multipart write-back instead.
|
||||||
|
spec.policy.inline_max_bytes = 4096;
|
||||||
|
spec.policy.multipart_part_size_bytes = PART_SIZE as u64;
|
||||||
|
spec.policy.preserve_etag = true;
|
||||||
|
env.configure_and_wait(bucket, &spec).await?;
|
||||||
|
|
||||||
|
let key = "replicated/preserved-md5-multipart.bin";
|
||||||
|
let body = payload(PART_SIZE + 4096);
|
||||||
|
let source_put = env
|
||||||
|
.source_client()
|
||||||
|
.put_object()
|
||||||
|
.bucket(SOURCE_BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from(body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let etag = source_put.e_tag().ok_or("source PUT omitted its ETag")?.trim_matches('"');
|
||||||
|
assert_eq!(etag.len(), 32, "the source must retain a single-PUT MD5 ETag");
|
||||||
|
assert!(etag.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
||||||
|
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 multipart pull must persist");
|
||||||
|
|
||||||
|
let deadline = Instant::now() + SETTLE;
|
||||||
|
let source_head = loop {
|
||||||
|
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
match head.replication_status().map(|status| status.as_str()) {
|
||||||
|
Some("COMPLETED") => break head,
|
||||||
|
Some("FAILED") => return Err("the ODM multipart copy failed replication to RustFS".into()),
|
||||||
|
_ => {
|
||||||
|
assert!(Instant::now() < deadline, "the ODM multipart copy never completed replication to RustFS");
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let version = source_head
|
||||||
|
.version_id()
|
||||||
|
.ok_or("the versioned ODM copy omitted its version id")?;
|
||||||
|
assert_ne!(version, "null");
|
||||||
|
let replica_client = replica.create_s3_client();
|
||||||
|
for (client, object_bucket) in [(&env.client, bucket), (&replica_client, replica_bucket)] {
|
||||||
|
let attributes = client
|
||||||
|
.get_object_attributes()
|
||||||
|
.bucket(object_bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(version)
|
||||||
|
.object_attributes(ObjectAttributes::Etag)
|
||||||
|
.object_attributes(ObjectAttributes::ObjectParts)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(attributes.e_tag().map(|value| value.trim_matches('"')), Some(etag));
|
||||||
|
let parts = attributes
|
||||||
|
.object_parts()
|
||||||
|
.ok_or("the local copy and replica must both expose two parts")?;
|
||||||
|
assert_eq!(parts.total_parts_count(), Some(2));
|
||||||
|
assert_eq!(
|
||||||
|
parts
|
||||||
|
.parts()
|
||||||
|
.iter()
|
||||||
|
.map(|part| (part.part_number(), part.size()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[(Some(1), Some(PART_SIZE as i64)), (Some(2), Some(4096))]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// REPLICA status surfaces on HEAD, like the other inbound-replica checks.
|
||||||
|
let replica_head = replica_client
|
||||||
|
.head_object()
|
||||||
|
.bucket(replica_bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(version)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(replica_head.replication_status().map(|status| status.as_str()), Some("REPLICA"));
|
||||||
|
let replica_get = replica_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(replica_bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(version)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(replica_get.version_id(), Some(version));
|
||||||
|
assert_eq!(replica_get.body.collect().await?.into_bytes(), body);
|
||||||
|
let boundary = replica_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(replica_bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(version)
|
||||||
|
.range(format!("bytes={}-{}", PART_SIZE - 32, PART_SIZE + 31))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(boundary.body.collect().await?.into_bytes(), body.slice(PART_SIZE - 32..PART_SIZE + 32));
|
||||||
|
assert_eq!(
|
||||||
|
env.source.count_requests(Operation::GetObject, key),
|
||||||
|
2,
|
||||||
|
"one passthrough GET plus one background pull; replication and local reads must not fetch the migration source again"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,6 +861,242 @@ async fn test_odm_disable_keeps_pulled_objects_and_stops_source_traffic() -> Tes
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The process switch preserves configured buckets and unfinished jobs while
|
||||||
|
/// restoring local-only S3 behavior, including after an ordinary metadata write.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_odm_global_disable_preserves_data_config_and_backfill_across_restarts() -> TestResult {
|
||||||
|
let bucket = "odm-global-disable";
|
||||||
|
let mut env = start_configured_env(bucket, SOURCE_BUCKET, |spec| spec.policy.list_through = true).await?;
|
||||||
|
let pulled_key = "migrated/pulled.bin";
|
||||||
|
let remote_key = "remote/untouched.bin";
|
||||||
|
let pending_key = "backfill/pending.bin";
|
||||||
|
let local_key = "local/kept.bin";
|
||||||
|
let source_body = Bytes::from_static(b"source payload");
|
||||||
|
let local_body = Bytes::from_static(b"client payload");
|
||||||
|
env.seed_source(
|
||||||
|
SOURCE_BUCKET,
|
||||||
|
&[
|
||||||
|
SeedObject::new(pulled_key, source_body.clone()),
|
||||||
|
SeedObject::new(remote_key, source_body.clone()),
|
||||||
|
SeedObject::new(pending_key, source_body.clone()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
env.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(local_key)
|
||||||
|
.body(local_body.clone().into())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let pulled = env.raw_get(bucket, pulled_key).await?;
|
||||||
|
assert_eq!(pulled.status, 200);
|
||||||
|
assert_eq!(pulled.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||||
|
assert_eq!(pulled.body, source_body);
|
||||||
|
let stored = env.raw_get(bucket, pulled_key).await?;
|
||||||
|
assert_eq!(stored.status, 200);
|
||||||
|
assert_eq!(stored.header(ODM_RESPONSE_HEADER), None, "the inline pull has committed locally");
|
||||||
|
assert_eq!(stored.body, source_body);
|
||||||
|
let config = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(config.status, 200, "{}", config.body);
|
||||||
|
let config = config.json()?;
|
||||||
|
|
||||||
|
// Hold every attempt until the process has exited, so retries cannot commit
|
||||||
|
// the only backfill object before the crash. The start checkpoint exists.
|
||||||
|
let mut pending_get = env.source.hold_get_object(SOURCE_BUCKET, pending_key);
|
||||||
|
let started = env
|
||||||
|
.start_backfill(
|
||||||
|
bucket,
|
||||||
|
BackfillRequest {
|
||||||
|
prefix: Some("backfill/".to_string()),
|
||||||
|
..BackfillRequest::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(started.status, 200, "{}", started.body);
|
||||||
|
let job_id = started.json()?["job"]["job_id"].as_str().ok_or("missing job ID")?.to_string();
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), pending_get.wait_until_entered())
|
||||||
|
.await
|
||||||
|
.expect("backfill never reached the held source GET")?;
|
||||||
|
let process = env.rustfs.process.as_mut().ok_or("missing RustFS process before crash")?;
|
||||||
|
assert!(process.try_wait()?.is_none(), "RustFS exited before the controlled crash");
|
||||||
|
process.kill()?;
|
||||||
|
let stopped = process.wait()?;
|
||||||
|
assert!(!stopped.success(), "the interrupted process must exit after being killed");
|
||||||
|
drop(env.rustfs.process.take());
|
||||||
|
drop(pending_get);
|
||||||
|
env.source.take_requests();
|
||||||
|
env.rustfs
|
||||||
|
.restart_server_preserving_data(vec![], &[(ODM_MODULE_SWITCH_ENV, "false"), (ALLOW_LOOPBACK_SOURCE_ENV, "true")])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let off_config = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(off_config.status, 200, "{}", off_config.body);
|
||||||
|
assert_eq!(off_config.json()?, config, "the saved configuration and timestamp survive disabling");
|
||||||
|
let status = env.status_json(bucket).await?;
|
||||||
|
assert_eq!(status["configured"], true, "{status}");
|
||||||
|
assert_eq!(status["enabled"], true, "the bucket remains configured as enabled: {status}");
|
||||||
|
assert_eq!(status["module_enabled"], false, "{status}");
|
||||||
|
assert_eq!(status["counters"], Value::Null, "no bucket runtime is installed: {status}");
|
||||||
|
let checkpoint = env.backfill_job(bucket).await?.ok_or("disabled module lost the checkpoint")?;
|
||||||
|
assert_eq!(checkpoint["job_id"], job_id);
|
||||||
|
assert_eq!(checkpoint["state"], "running", "the interrupted job is retained: {checkpoint}");
|
||||||
|
|
||||||
|
for (key, body) in [(local_key, &local_body), (pulled_key, &source_body)] {
|
||||||
|
let get = env.raw_get(bucket, key).await?;
|
||||||
|
assert_eq!(get.status, 200);
|
||||||
|
assert_eq!(&get.body, body);
|
||||||
|
assert_eq!(get.header(ODM_RESPONSE_HEADER), None);
|
||||||
|
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
|
||||||
|
assert_eq!(head.content_length(), Some(i64::try_from(body.len())?));
|
||||||
|
}
|
||||||
|
for key in [remote_key, pending_key] {
|
||||||
|
let get = env.raw_get(bucket, key).await?;
|
||||||
|
assert_eq!(get.status, 404, "disabled source GET {key}: {}", String::from_utf8_lossy(&get.body));
|
||||||
|
let head = env.client.head_object().bucket(bucket).key(key).send().await;
|
||||||
|
let err = head.expect_err("a source-only object must remain absent locally");
|
||||||
|
assert_eq!(err.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
}
|
||||||
|
|
||||||
|
let replacement = Bytes::from_static(b"written while the module is off");
|
||||||
|
for key in [local_key, "local/deleted.bin"] {
|
||||||
|
env.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(replacement.clone().into())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
env.client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("local/deleted.bin")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(env.raw_get(bucket, "local/deleted.bin").await?.status, 404);
|
||||||
|
assert_eq!(env.raw_get(bucket, local_key).await?.body, replacement);
|
||||||
|
|
||||||
|
// Both wire protocols must finish their local pages even though the saved
|
||||||
|
// configuration still requests list-through.
|
||||||
|
for use_v2 in [false, true] {
|
||||||
|
let mut cursor = None;
|
||||||
|
let mut listed = Vec::new();
|
||||||
|
for page_number in 0..2 {
|
||||||
|
let (keys, truncated, next) = if use_v2 {
|
||||||
|
let page = env
|
||||||
|
.client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(bucket)
|
||||||
|
.max_keys(1)
|
||||||
|
.set_continuation_token(cursor)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
(
|
||||||
|
page.contents()
|
||||||
|
.iter()
|
||||||
|
.map(|object| object.key().expect("listed key").to_string())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
page.is_truncated(),
|
||||||
|
page.next_continuation_token().map(str::to_string),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let page = env
|
||||||
|
.client
|
||||||
|
.list_objects()
|
||||||
|
.bucket(bucket)
|
||||||
|
.max_keys(1)
|
||||||
|
.set_marker(cursor)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
// V1 may omit NextMarker without a delimiter; clients then
|
||||||
|
// continue from the last returned key.
|
||||||
|
let next = page.next_marker().or_else(|| {
|
||||||
|
if page.is_truncated() == Some(true) {
|
||||||
|
page.contents().last().and_then(|object| object.key())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(
|
||||||
|
page.contents()
|
||||||
|
.iter()
|
||||||
|
.map(|object| object.key().expect("listed key").to_string())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
page.is_truncated(),
|
||||||
|
next.map(str::to_string),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(keys.len(), 1, "one local key per page, V2={use_v2}");
|
||||||
|
assert_eq!(truncated, Some(page_number == 0), "local pagination must terminate, V2={use_v2}");
|
||||||
|
if page_number == 0 {
|
||||||
|
assert!(next.as_ref().is_some_and(|value| !value.is_empty()), "missing local cursor, V2={use_v2}");
|
||||||
|
}
|
||||||
|
cursor = next;
|
||||||
|
listed.extend(keys);
|
||||||
|
}
|
||||||
|
assert_eq!(listed, [local_key, pulled_key], "source-only keys must stay absent, V2={use_v2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||||
|
for response in [
|
||||||
|
env.configure_source(bucket, &spec).await?,
|
||||||
|
env.validate_source(bucket, &spec).await?,
|
||||||
|
env.backfill(bucket, BackfillOp::Start(BackfillRequest::default())).await?,
|
||||||
|
] {
|
||||||
|
assert_eq!(response.status, 400, "{}", response.body);
|
||||||
|
assert!(response.body.contains("OnDemandMigrationDisabled"), "{}", response.body);
|
||||||
|
}
|
||||||
|
let tagging = Tagging::builder()
|
||||||
|
.tag_set(Tag::builder().key("module").value("disabled").build()?)
|
||||||
|
.build()?;
|
||||||
|
env.client
|
||||||
|
.put_bucket_tagging()
|
||||||
|
.bucket(bucket)
|
||||||
|
.tagging(tagging.clone())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(env.get_config(bucket).await?.json()?, config, "an unrelated metadata write preserves ODM");
|
||||||
|
assert_eq!(
|
||||||
|
env.backfill_job(bucket).await?,
|
||||||
|
Some(checkpoint),
|
||||||
|
"no recovery or checkpoint update while disabled"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
env.source.requests().is_empty(),
|
||||||
|
"disabled startup and all requests must leave the source untouched"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||||
|
env.wait_until_source_consulted(bucket).await?;
|
||||||
|
assert_eq!(
|
||||||
|
env.get_config(bucket).await?.json()?,
|
||||||
|
config,
|
||||||
|
"reenabling uses the persisted configuration"
|
||||||
|
);
|
||||||
|
let tags = env.client.get_bucket_tagging().bucket(bucket).send().await?;
|
||||||
|
assert_eq!(tags.tag_set(), tagging.tag_set(), "the ordinary metadata write also persists");
|
||||||
|
let resumed = env.raw_get(bucket, remote_key).await?;
|
||||||
|
assert_eq!(resumed.status, 200);
|
||||||
|
assert_eq!(resumed.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||||
|
assert_eq!(resumed.body, source_body, "stored credentials still authenticate without reconfiguration");
|
||||||
|
let completed = env
|
||||||
|
.wait_for_backfill(bucket, SETTLE, |job| job["state"] == "completed")
|
||||||
|
.await?;
|
||||||
|
assert_eq!(completed["job_id"], job_id, "the interrupted job resumes without a new start");
|
||||||
|
assert_eq!(completed["failed"], 0, "{completed}");
|
||||||
|
for (key, body) in [
|
||||||
|
(local_key, &replacement),
|
||||||
|
(pulled_key, &source_body),
|
||||||
|
(pending_key, &source_body),
|
||||||
|
] {
|
||||||
|
let get = env.raw_get(bucket, key).await?;
|
||||||
|
assert_eq!(get.status, 200);
|
||||||
|
assert_eq!(&get.body, body);
|
||||||
|
assert_eq!(get.header(ODM_RESPONSE_HEADER), None, "{key} remains stored locally");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Case 19: the admin surface an operator sees — the configuration read back
|
/// Case 19: the admin surface an operator sees — the configuration read back
|
||||||
/// without its secret, and a status document whose counters match the source
|
/// without its secret, and a status document whose counters match the source
|
||||||
/// journal exactly.
|
/// journal exactly.
|
||||||
|
|||||||
@@ -265,16 +265,13 @@ async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
|
|||||||
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
|
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}");
|
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 tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":3").as_bytes());
|
||||||
let rejected = env
|
assert_ne!(tampered, token, "the test must change the token version");
|
||||||
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}"))
|
let query = serde_urlencoded::to_string([("continuation-token", tampered.as_str())])?;
|
||||||
.await?;
|
let rejected = env.raw_list_objects_v2(bucket, &query).await?;
|
||||||
assert_eq!(
|
let error_body = String::from_utf8_lossy(&rejected.body);
|
||||||
rejected.status,
|
assert_eq!(rejected.status, 400, "a bumped token version is a client error: {}", error_body);
|
||||||
400,
|
assert!(error_body.contains("<Code>InvalidArgument</Code>"), "{error_body}");
|
||||||
"a bumped token version is a client error: {}",
|
|
||||||
String::from_utf8_lossy(&rejected.body)
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -368,23 +368,23 @@ impl Drop for SlowReplicationTargetGuard {
|
|||||||
// Mirrors madmin-go `ResyncTargetsInfo`/`ResyncTarget` json tags — the same
|
// Mirrors madmin-go `ResyncTargetsInfo`/`ResyncTarget` json tags — the same
|
||||||
// shape `mc replicate resync status` decodes.
|
// shape `mc replicate resync status` decodes.
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
struct ReplicationResetStatusResponse {
|
pub(crate) struct ReplicationResetStatusResponse {
|
||||||
#[serde(rename = "target", default)]
|
#[serde(rename = "target", default)]
|
||||||
targets: Vec<ReplicationResetStatusTarget>,
|
pub(crate) targets: Vec<ReplicationResetStatusTarget>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
struct ReplicationResetStatusTarget {
|
pub(crate) struct ReplicationResetStatusTarget {
|
||||||
#[serde(rename = "arn", default)]
|
#[serde(rename = "arn", default)]
|
||||||
arn: String,
|
pub(crate) arn: String,
|
||||||
#[serde(rename = "resetid", default)]
|
#[serde(rename = "resetid", default)]
|
||||||
reset_id: String,
|
pub(crate) reset_id: String,
|
||||||
#[serde(rename = "resyncStatus", default)]
|
#[serde(rename = "resyncStatus", default)]
|
||||||
status: String,
|
pub(crate) status: String,
|
||||||
#[serde(rename = "replicationCount", default)]
|
#[serde(rename = "replicationCount", default)]
|
||||||
replicated_count: i64,
|
pub(crate) replicated_count: i64,
|
||||||
#[serde(rename = "object", default)]
|
#[serde(rename = "object", default)]
|
||||||
object: String,
|
pub(crate) object: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
|
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
|
||||||
@@ -512,7 +512,7 @@ pub(crate) async fn put_bucket_replication(
|
|||||||
put_bucket_replication_with_delete_statuses(env, bucket, target_arn, "Enabled", None).await
|
put_bucket_replication_with_delete_statuses(env, bucket, target_arn, "Enabled", None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn put_bucket_replication_with_delete_statuses(
|
pub(crate) async fn put_bucket_replication_with_delete_statuses(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
target_arn: &str,
|
target_arn: &str,
|
||||||
@@ -627,7 +627,7 @@ async fn put_bucket_replication_rules(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_bucket_replication(
|
pub(crate) async fn delete_bucket_replication(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||||
@@ -2294,7 +2294,7 @@ async fn site_replication_state_edit(
|
|||||||
/// return the target `(arn, reset_id)`, asserting the response carries the
|
/// return the target `(arn, reset_id)`, asserting the response carries the
|
||||||
/// madmin `ResyncTargetsInfo` shape (`target[0].arn` / `target[0].resetid`)
|
/// madmin `ResyncTargetsInfo` shape (`target[0].arn` / `target[0].resetid`)
|
||||||
/// that `mc replicate resync start` decodes.
|
/// that `mc replicate resync start` decodes.
|
||||||
async fn start_bucket_replication_reset(
|
pub(crate) async fn start_bucket_replication_reset(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
) -> Result<(String, String), Box<dyn Error + Send + Sync>> {
|
) -> Result<(String, String), Box<dyn Error + Send + Sync>> {
|
||||||
@@ -2314,7 +2314,7 @@ async fn start_bucket_replication_reset(
|
|||||||
Ok((arn, reset_id))
|
Ok((arn, reset_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_replication_reset_status(
|
pub(crate) async fn get_replication_reset_status(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
arn: &str,
|
arn: &str,
|
||||||
@@ -3837,6 +3837,244 @@ async fn test_bucket_replication_converges_delete_marker_and_version_purge() ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression for rustfs/backlog#2340 (not Wasabi specific): a directory
|
||||||
|
/// marker (`prefix/` with a body) in a versioned bucket is stored as the null
|
||||||
|
/// version, like MinIO (`putOpts`: "for directory objects skip creating new
|
||||||
|
/// versions"), and must still replicate to completion instead of staying
|
||||||
|
/// `PENDING`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bucket_replication_replicates_directory_marker_in_versioned_bucket() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
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);
|
||||||
|
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
|
||||||
|
|
||||||
|
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||||
|
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||||
|
|
||||||
|
let source_bucket = "replication-dir-marker-src";
|
||||||
|
let target_bucket = "replication-dir-marker-dst";
|
||||||
|
let source_client = source_env.create_s3_client();
|
||||||
|
let target_client = target_env.create_s3_client();
|
||||||
|
|
||||||
|
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||||
|
target_client.create_bucket().bucket(target_bucket).send().await?;
|
||||||
|
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||||
|
enable_bucket_versioning(&target_env, target_bucket).await?;
|
||||||
|
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
|
||||||
|
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||||
|
|
||||||
|
let marker_key = "dir/trailing/";
|
||||||
|
let body = b"directory marker body";
|
||||||
|
let put = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(marker_key)
|
||||||
|
.body(ByteStream::from_static(body))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
put.version_id()
|
||||||
|
.is_none_or(|id| id == "null" || id == uuid::Uuid::nil().to_string()),
|
||||||
|
"a directory marker is the null version even in a versioned bucket: {:?}",
|
||||||
|
put.version_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
wait_for_source_replication_status(&source_client, source_bucket, marker_key, "COMPLETED", false).await?;
|
||||||
|
|
||||||
|
let replica = target_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(target_bucket)
|
||||||
|
.key(marker_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), body);
|
||||||
|
let listed = target_client
|
||||||
|
.list_object_versions()
|
||||||
|
.bucket(target_bucket)
|
||||||
|
.prefix(marker_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let marker_versions: Vec<_> = listed.versions().iter().filter(|v| v.key() == Some(marker_key)).collect();
|
||||||
|
assert_eq!(marker_versions.len(), 1, "the marker must land exactly once: {marker_versions:?}");
|
||||||
|
assert_eq!(
|
||||||
|
marker_versions[0].version_id(),
|
||||||
|
Some("null"),
|
||||||
|
"the replica keeps the null version identity"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression for rustfs/backlog#2340 (not Wasabi specific): permanently
|
||||||
|
/// deleting a version whose payload lives in a data dir must leave the source
|
||||||
|
/// clean once the purge replicates. Managed-SSE objects are never inlined and a
|
||||||
|
/// plain object above the inline threshold takes the same layout. The version
|
||||||
|
/// retained with a pending purge used to lose its data dir, so the purge state
|
||||||
|
/// could never be applied (`VersionNotFound` on every retry) and the bucket
|
||||||
|
/// stayed `BucketNotEmpty` while `ListObjectVersions` was already empty.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bucket_replication_version_purge_of_non_inline_object_releases_source_bucket() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let (source_env, target_env, source_bucket, target_bucket) = build_sse_replication_pair("purge-datadir", true, true).await?;
|
||||||
|
let target_arn = wait_for_remote_target_arn(&source_env, &source_bucket).await?;
|
||||||
|
put_bucket_replication_with_delete_statuses(&source_env, &source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
|
||||||
|
let source_client = source_env.create_s3_client();
|
||||||
|
let target_client = target_env.create_s3_client();
|
||||||
|
|
||||||
|
let sse_key = "sse-object.bin";
|
||||||
|
let large_key = "large-object.bin";
|
||||||
|
let sse_put = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&source_bucket)
|
||||||
|
.key(sse_key)
|
||||||
|
.body(ByteStream::from_static(b"encrypted source payload"))
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let large_put = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&source_bucket)
|
||||||
|
.key(large_key)
|
||||||
|
.body(ByteStream::from(vec![0x5a; 2 * 1024 * 1024]))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let purged = [
|
||||||
|
(sse_key, sse_put.version_id().ok_or("SSE PUT omitted version ID")?.to_string()),
|
||||||
|
(large_key, large_put.version_id().ok_or("large PUT omitted version ID")?.to_string()),
|
||||||
|
];
|
||||||
|
assert_replication_converged(&source_client, &source_bucket, &target_client, &target_bucket).await?;
|
||||||
|
|
||||||
|
for (key, version_id) in &purged {
|
||||||
|
source_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&source_bucket)
|
||||||
|
.key(*key)
|
||||||
|
.version_id(version_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
assert_replication_converged(&source_client, &source_bucket, &target_client, &target_bucket).await?;
|
||||||
|
let target_state = list_replication_state(&target_client, &target_bucket).await?;
|
||||||
|
assert!(target_state.is_empty(), "target retained an explicitly purged version: {target_state:?}");
|
||||||
|
|
||||||
|
// The purge state is applied on the source asynchronously after the target
|
||||||
|
// acknowledges the delete; only then does the retained version go away and
|
||||||
|
// the bucket become deletable. A listing that is empty while DeleteBucket
|
||||||
|
// keeps answering BucketNotEmpty is exactly the regression.
|
||||||
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
||||||
|
loop {
|
||||||
|
let listing = source_client.list_object_versions().bucket(&source_bucket).send().await?;
|
||||||
|
let listed = listing.versions().len() + listing.delete_markers().len();
|
||||||
|
match source_client.delete_bucket().bucket(&source_bucket).send().await {
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(err) if err.code() == Some("BucketNotEmpty") => {
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"source bucket stayed BucketNotEmpty after the version purge replicated; \
|
||||||
|
ListObjectVersions shows {listed} entries"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression for rustfs/backlog#2340 (not Wasabi specific): a single-part
|
||||||
|
/// object uploaded with `x-amz-checksum-*` must reach the target with the same
|
||||||
|
/// checksum. The outbound options keyed the stored record by algorithm name,
|
||||||
|
/// which the target client sent as `x-amz-meta-*` user metadata, so a replica
|
||||||
|
/// never carried a checksum although the source HEAD returned one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bucket_replication_forwards_single_part_object_checksums() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
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);
|
||||||
|
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
|
||||||
|
|
||||||
|
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||||
|
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||||
|
|
||||||
|
let source_bucket = "replication-checksum-src";
|
||||||
|
let target_bucket = "replication-checksum-dst";
|
||||||
|
let source_client = source_env.create_s3_client();
|
||||||
|
let target_client = target_env.create_s3_client();
|
||||||
|
|
||||||
|
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||||
|
target_client.create_bucket().bucket(target_bucket).send().await?;
|
||||||
|
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||||
|
enable_bucket_versioning(&target_env, target_bucket).await?;
|
||||||
|
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
|
||||||
|
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||||
|
|
||||||
|
let body = b"123456789";
|
||||||
|
let crc32_key = "checksum-crc32.txt";
|
||||||
|
let sha256_key = "checksum-sha256.txt";
|
||||||
|
let crc32_put = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(crc32_key)
|
||||||
|
.body(ByteStream::from_static(body))
|
||||||
|
.checksum_algorithm(aws_sdk_s3::types::ChecksumAlgorithm::Crc32)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let expected_crc32 = crc32_put.checksum_crc32().ok_or("source PUT omitted CRC32")?.to_string();
|
||||||
|
let sha256_put = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(sha256_key)
|
||||||
|
.body(ByteStream::from_static(body))
|
||||||
|
.checksum_algorithm(aws_sdk_s3::types::ChecksumAlgorithm::Sha256)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let expected_sha256 = sha256_put.checksum_sha256().ok_or("source PUT omitted SHA256")?.to_string();
|
||||||
|
|
||||||
|
for key in [crc32_key, sha256_key] {
|
||||||
|
wait_for_source_replication_status(&source_client, source_bucket, key, "COMPLETED", false).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let replica = target_client
|
||||||
|
.head_object()
|
||||||
|
.bucket(target_bucket)
|
||||||
|
.key(crc32_key)
|
||||||
|
.checksum_mode(aws_sdk_s3::types::ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(replica.checksum_crc32(), Some(expected_crc32.as_str()), "replica lost the CRC32 checksum");
|
||||||
|
let replica = target_client
|
||||||
|
.head_object()
|
||||||
|
.bucket(target_bucket)
|
||||||
|
.key(sha256_key)
|
||||||
|
.checksum_mode(aws_sdk_s3::types::ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
replica.checksum_sha256(),
|
||||||
|
Some(expected_sha256.as_str()),
|
||||||
|
"replica lost the SHA256 checksum"
|
||||||
|
);
|
||||||
|
// The bare algorithm name must not leak as user metadata either.
|
||||||
|
assert!(
|
||||||
|
replica
|
||||||
|
.metadata()
|
||||||
|
.is_none_or(|meta| !meta.keys().any(|k| k.eq_ignore_ascii_case("sha256"))),
|
||||||
|
"replica carries the checksum as user metadata: {:?}",
|
||||||
|
replica.metadata()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_bucket_replication_disabled_delete_marker_does_not_propagate() -> TestResult {
|
async fn test_bucket_replication_disabled_delete_marker_does_not_propagate() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
@@ -6743,6 +6981,99 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
|
|||||||
Ok(())
|
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.
|
/// 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
|
/// `PutBucketReplication` broadcasts the config to every peer — the console's replication
|
||||||
@@ -8958,11 +9289,13 @@ async fn test_replication_check_flags_version_minting_target() -> TestResult {
|
|||||||
fidelity["Code"], "BucketRemoteTargetVersionMismatch",
|
fidelity["Code"], "BucketRemoteTargetVersionMismatch",
|
||||||
"the failure must carry a machine-readable code: {payload}"
|
"the failure must carry a machine-readable code: {payload}"
|
||||||
);
|
);
|
||||||
// The probe PUT itself succeeded (fidelity is judged from its response);
|
// The probe PUT itself succeeded (fidelity is judged from its response).
|
||||||
// the later mutation phases are pointless against a drifting target and
|
// The mutation phases address the id the target assigned — the ledger
|
||||||
// must be skipped, but cleanup still runs.
|
// the worker records per object (rustfs/backlog#2340) — so they run and
|
||||||
|
// pass on a drifting target, and cleanup uses the same id.
|
||||||
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
|
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
|
||||||
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "SKIPPED", "{payload}");
|
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "OK", "{payload}");
|
||||||
|
assert_eq!(target_report["Phases"]["VersionDelete"]["Status"], "OK", "{payload}");
|
||||||
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
|
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
|
||||||
|
|
||||||
// The probe PUT must carry the source version as `?versionId=` — the
|
// The probe PUT must carry the source version as `?versionId=` — the
|
||||||
|
|||||||
@@ -31,17 +31,22 @@
|
|||||||
//! Adding a target behavior the fleet has shown: add the mode to the fake
|
//! 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.
|
//! 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::common::{init_logging, replication_fast_env};
|
||||||
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
|
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
|
||||||
use crate::fake_s3_target::{FakeS3Target, Operation as FakeTargetOperation, RequestRecord};
|
use crate::fake_s3_target::{FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation, RequestRecord};
|
||||||
use crate::on_demand_migration::common::fake_source_client;
|
use crate::on_demand_migration::common::{OdmEnvOptions, OdmTestEnv, fake_source_client};
|
||||||
use crate::replication_extension_test::{
|
use crate::replication_extension_test::{
|
||||||
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, put_bucket_replication,
|
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, delete_bucket_replication, enable_bucket_versioning,
|
||||||
set_replication_target_with_options,
|
get_replication_reset_status, put_bucket_replication, put_bucket_replication_with_delete_statuses,
|
||||||
|
set_replication_target_with_options, start_bucket_replication_reset,
|
||||||
};
|
};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::{ByteStream, DateTime};
|
use aws_sdk_s3::primitives::{ByteStream, DateTime};
|
||||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockMode};
|
use aws_sdk_s3::types::{
|
||||||
|
Checksum, ChecksumAlgorithm, CompletedMultipartUpload, CompletedPart, ObjectAttributes, ObjectLockLegalHold,
|
||||||
|
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, Tag, Tagging,
|
||||||
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -63,7 +68,10 @@ enum TargetMode {
|
|||||||
/// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`.
|
/// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`.
|
||||||
RequireChecksumWithObjectLock,
|
RequireChecksumWithObjectLock,
|
||||||
/// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids
|
/// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids
|
||||||
/// (rustfs/backlog#2085). Data must still land.
|
/// (rustfs/backlog#2085) and, like Wasabi, answers NoSuchVersion to a
|
||||||
|
/// DELETE of an id it never had (rustfs/backlog#2340). Data must still
|
||||||
|
/// land, and every version-addressed mutation must resolve the replica
|
||||||
|
/// through the target-version ledger.
|
||||||
MintOwnVersionIds,
|
MintOwnVersionIds,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +88,10 @@ impl TargetMode {
|
|||||||
TargetMode::Baseline => {}
|
TargetMode::Baseline => {}
|
||||||
TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true),
|
TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true),
|
||||||
TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true),
|
TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true),
|
||||||
TargetMode::MintOwnVersionIds => target.assign_own_version_ids(true),
|
TargetMode::MintOwnVersionIds => {
|
||||||
|
target.assign_own_version_ids(true);
|
||||||
|
target.reject_unknown_version_deletes(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,16 +121,23 @@ enum ObjectShape {
|
|||||||
/// Two-part multipart upload with a GOVERNANCE retention period; the
|
/// Two-part multipart upload with a GOVERNANCE retention period; the
|
||||||
/// lock headers travel on CreateMultipartUpload, which has no body.
|
/// lock headers travel on CreateMultipartUpload, which has no body.
|
||||||
LockedMultipart,
|
LockedMultipart,
|
||||||
|
/// ODM stores two local parts while preserving a single-PUT source's MD5 ETag.
|
||||||
|
OdmPreservedMd5Multipart,
|
||||||
|
/// Single-part object uploaded with `x-amz-checksum-sha256`; the replica
|
||||||
|
/// must carry the same header (rustfs/backlog#2340).
|
||||||
|
Checksummed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectShape {
|
impl ObjectShape {
|
||||||
const ALL: [ObjectShape; 6] = [
|
const ALL: [ObjectShape; 8] = [
|
||||||
ObjectShape::Empty,
|
ObjectShape::Empty,
|
||||||
ObjectShape::Plain,
|
ObjectShape::Plain,
|
||||||
ObjectShape::Retention,
|
ObjectShape::Retention,
|
||||||
ObjectShape::LegalHold,
|
ObjectShape::LegalHold,
|
||||||
ObjectShape::Multipart,
|
ObjectShape::Multipart,
|
||||||
ObjectShape::LockedMultipart,
|
ObjectShape::LockedMultipart,
|
||||||
|
ObjectShape::OdmPreservedMd5Multipart,
|
||||||
|
ObjectShape::Checksummed,
|
||||||
];
|
];
|
||||||
|
|
||||||
fn key(self) -> &'static str {
|
fn key(self) -> &'static str {
|
||||||
@@ -130,6 +148,17 @@ impl ObjectShape {
|
|||||||
ObjectShape::LegalHold => "matrix/legal-hold.bin",
|
ObjectShape::LegalHold => "matrix/legal-hold.bin",
|
||||||
ObjectShape::Multipart => "matrix/multipart.bin",
|
ObjectShape::Multipart => "matrix/multipart.bin",
|
||||||
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
|
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
|
||||||
|
ObjectShape::OdmPreservedMd5Multipart => "matrix/odm-preserved-md5.bin",
|
||||||
|
ObjectShape::Checksummed => "matrix/checksummed.bin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `x-amz-checksum-*` header the source stored and every upload of
|
||||||
|
/// the replica must repeat.
|
||||||
|
fn forwarded_checksum_header(self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
ObjectShape::Checksummed => Some("x-amz-checksum-sha256"),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +168,8 @@ impl ObjectShape {
|
|||||||
|
|
||||||
/// Upload the shape to the source and return the bytes the target must
|
/// Upload the shape to the source and return the bytes the target must
|
||||||
/// end up holding.
|
/// end up holding.
|
||||||
async fn put(self, client: &Client, bucket: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
|
async fn put(self, env: &OdmTestEnv, bucket: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
|
||||||
|
let client = &env.client;
|
||||||
let key = self.key();
|
let key = self.key();
|
||||||
match self {
|
match self {
|
||||||
ObjectShape::Empty => {
|
ObjectShape::Empty => {
|
||||||
@@ -190,6 +220,19 @@ impl ObjectShape {
|
|||||||
}
|
}
|
||||||
ObjectShape::Multipart => multipart_put(client, bucket, key, 0x44, false).await,
|
ObjectShape::Multipart => multipart_put(client, bucket, key, 0x44, false).await,
|
||||||
ObjectShape::LockedMultipart => multipart_put(client, bucket, key, 0x55, true).await,
|
ObjectShape::LockedMultipart => multipart_put(client, bucket, key, 0x55, true).await,
|
||||||
|
ObjectShape::OdmPreservedMd5Multipart => odm_preserved_md5_multipart(env, bucket, key).await,
|
||||||
|
ObjectShape::Checksummed => {
|
||||||
|
let body = payload(40 * 1024, 0x66);
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from(body.clone()))
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,6 +262,595 @@ fn expectation(mode: TargetMode, shape: ObjectShape) -> Expectation {
|
|||||||
.unwrap_or(Expectation::Completed)
|
.unwrap_or(Expectation::Completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// rustfs/backlog#2340: a target that mints its own version ids (Wasabi,
|
||||||
|
/// AWS S3) answers 404 to a HEAD by the source uuid, which the worker used to
|
||||||
|
/// read as "replica missing" and re-drive the PUT — one more target version
|
||||||
|
/// per heal, MRF retry or resync. Two re-drive shapes, both must converge on
|
||||||
|
/// the single version the first PUT created:
|
||||||
|
/// - the first PUT lands but its response is lost, so the object is FAILED
|
||||||
|
/// and the scanner heal pass re-drives it;
|
||||||
|
/// - an existing-object resync re-drives a COMPLETED object unconditionally.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let target = FakeS3Target::start().await?;
|
||||||
|
let target_bucket = "matrix-mint-own-redrive-dst".to_string();
|
||||||
|
target.create_bucket_with_object_lock(target_bucket.clone());
|
||||||
|
target.assign_own_version_ids(true);
|
||||||
|
|
||||||
|
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", ""),
|
||||||
|
// The scanner heal pass is what re-drives a FAILED object.
|
||||||
|
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||||
|
("RUSTFS_SCANNER_START_DELAY_SECS", "1"),
|
||||||
|
]);
|
||||||
|
let env = OdmTestEnv::start_with(OdmEnvOptions {
|
||||||
|
env: env_vars,
|
||||||
|
..OdmEnvOptions::default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let source_env = &env.rustfs;
|
||||||
|
|
||||||
|
let source_bucket = "matrix-mint-own-redrive-src";
|
||||||
|
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?;
|
||||||
|
|
||||||
|
// Teach the worker the target's identity contract with one ordinary
|
||||||
|
// write, exactly as production learns it (the PUT response carries the
|
||||||
|
// minted id).
|
||||||
|
let probe_key = "redrive/identity-probe.bin";
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(probe_key)
|
||||||
|
.body(ByteStream::from(payload(4 * 1024, 0x01)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_terminal_replication_status(&source_client, source_bucket, probe_key).await?,
|
||||||
|
"COMPLETED"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shape 1: the PUT is stored, its response never arrives, heal re-drives.
|
||||||
|
let heal_key = "redrive/heal.bin";
|
||||||
|
target.inject_for_key(FakeTargetOperation::PutObject, heal_key, FakeTargetFault::DisconnectAfterResponse, 1);
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(heal_key)
|
||||||
|
.body(ByteStream::from(payload(8 * 1024, 0x02)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_replication_status_and_single_version(&source_client, source_bucket, &target, &target_bucket, heal_key).await?;
|
||||||
|
|
||||||
|
// Shape 2: an existing-object resync re-drives a COMPLETED object.
|
||||||
|
let resync_key = "redrive/resync.bin";
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(resync_key)
|
||||||
|
.body(ByteStream::from(payload(8 * 1024, 0x03)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_terminal_replication_status(&source_client, source_bucket, resync_key).await?,
|
||||||
|
"COMPLETED"
|
||||||
|
);
|
||||||
|
let (reset_arn, _reset_id) = start_bucket_replication_reset(source_env, source_bucket).await?;
|
||||||
|
assert_eq!(reset_arn, target_arn);
|
||||||
|
let resync = async {
|
||||||
|
loop {
|
||||||
|
let status = get_replication_reset_status(source_env, source_bucket, &target_arn).await?;
|
||||||
|
if let Some(entry) = status.targets.iter().find(|entry| entry.arn == target_arn)
|
||||||
|
&& entry.status == "Completed"
|
||||||
|
{
|
||||||
|
return Ok::<_, Box<dyn Error + Send + Sync>>(entry.replicated_count);
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let replicated = timeout(Duration::from_secs(90), resync)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "existing-object resync did not complete within 90 seconds")??;
|
||||||
|
assert!(replicated >= 3, "resync must count the located replicas as replicated, got {replicated}");
|
||||||
|
for key in [probe_key, heal_key, resync_key] {
|
||||||
|
let versions = target.stored_versions(&target_bucket, key);
|
||||||
|
assert_eq!(
|
||||||
|
versions.len(),
|
||||||
|
1,
|
||||||
|
"{key}: a re-drive against a target that mints its own version ids must not mint another one: {versions:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
target.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rustfs/backlog#2340 (target-version ledger): on a target that mints its own
|
||||||
|
/// version ids and answers NoSuchVersion to an unknown id (the Wasabi shape),
|
||||||
|
/// every version-addressed mutation must land on the version the target
|
||||||
|
/// assigned, which the replication PUT recorded on the source:
|
||||||
|
/// - a tag update changes the existing target version, no new version;
|
||||||
|
/// - a retention extension and legal hold ON/OFF change that version too;
|
||||||
|
/// - a permanent delete of the older of two same-content generations removes
|
||||||
|
/// exactly that replica and keeps the live one (content identity alone
|
||||||
|
/// could not tell them apart).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn matrix_mint_own_version_ids_addresses_mutations_through_the_ledger() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let target = FakeS3Target::start().await?;
|
||||||
|
let target_bucket = "matrix-mint-own-ledger-dst".to_string();
|
||||||
|
target.create_bucket_with_object_lock(target_bucket.clone());
|
||||||
|
TargetMode::MintOwnVersionIds.apply(&target);
|
||||||
|
|
||||||
|
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", ""),
|
||||||
|
// The scanner heal pass retries a purge the first attempt lost.
|
||||||
|
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||||
|
("RUSTFS_SCANNER_START_DELAY_SECS", "1"),
|
||||||
|
]);
|
||||||
|
let env = OdmTestEnv::start_with(OdmEnvOptions {
|
||||||
|
env: env_vars,
|
||||||
|
..OdmEnvOptions::default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let source_env = &env.rustfs;
|
||||||
|
|
||||||
|
let source_bucket = "matrix-mint-own-ledger-src";
|
||||||
|
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_with_delete_statuses(source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
|
||||||
|
let target_client = fake_source_client(&target);
|
||||||
|
|
||||||
|
// Tag update on an existing version.
|
||||||
|
let tag_key = "ledger/tags.bin";
|
||||||
|
let tagged = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(tag_key)
|
||||||
|
.body(ByteStream::from(payload(4 * 1024, 0x01)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let tag_source_version = tagged.version_id().ok_or("source PUT returned no version id")?.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_terminal_replication_status(&source_client, source_bucket, tag_key).await?,
|
||||||
|
"COMPLETED"
|
||||||
|
);
|
||||||
|
let tag_target_version = single_target_version(&target, &target_bucket, tag_key)?;
|
||||||
|
source_client
|
||||||
|
.put_object_tagging()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(tag_key)
|
||||||
|
.version_id(&tag_source_version)
|
||||||
|
.tagging(
|
||||||
|
Tagging::builder()
|
||||||
|
.tag_set(Tag::builder().key("phase").value("after").build()?)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_until("tag update on the existing target version", || async {
|
||||||
|
let tags = target_client
|
||||||
|
.get_object_tagging()
|
||||||
|
.bucket(&target_bucket)
|
||||||
|
.key(tag_key)
|
||||||
|
.version_id(&tag_target_version)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(tags
|
||||||
|
.tag_set()
|
||||||
|
.iter()
|
||||||
|
.any(|tag| tag.key() == "phase" && tag.value() == "after"))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert_stable_single_version(&target, &target_bucket, tag_key, &tag_target_version).await?;
|
||||||
|
|
||||||
|
// Retention extension and legal hold on an existing version.
|
||||||
|
let lock_key = "ledger/lock.bin";
|
||||||
|
let locked = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(lock_key)
|
||||||
|
.body(ByteStream::from(payload(4 * 1024, 0x02)))
|
||||||
|
.object_lock_mode(ObjectLockMode::Governance)
|
||||||
|
.object_lock_retain_until_date(retain_until())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let lock_source_version = locked.version_id().ok_or("source PUT returned no version id")?.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_terminal_replication_status(&source_client, source_bucket, lock_key).await?,
|
||||||
|
"COMPLETED"
|
||||||
|
);
|
||||||
|
let lock_target_version = single_target_version(&target, &target_bucket, lock_key)?;
|
||||||
|
let extended = DateTime::from_secs(retain_until().secs() + 86_400);
|
||||||
|
source_client
|
||||||
|
.put_object_retention()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(lock_key)
|
||||||
|
.version_id(&lock_source_version)
|
||||||
|
.retention(
|
||||||
|
ObjectLockRetention::builder()
|
||||||
|
.mode(ObjectLockRetentionMode::Governance)
|
||||||
|
.retain_until_date(extended)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
source_client
|
||||||
|
.put_object_legal_hold()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(lock_key)
|
||||||
|
.version_id(&lock_source_version)
|
||||||
|
.legal_hold(ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::On).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_until("retention extension and legal hold on the existing target version", || async {
|
||||||
|
let head = target_client
|
||||||
|
.head_object()
|
||||||
|
.bucket(&target_bucket)
|
||||||
|
.key(lock_key)
|
||||||
|
.version_id(&lock_target_version)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(head.object_lock_retain_until_date().map(|date| date.secs()) == Some(extended.secs())
|
||||||
|
&& head.object_lock_legal_hold_status() == Some(&ObjectLockLegalHoldStatus::On))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
source_client
|
||||||
|
.put_object_legal_hold()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(lock_key)
|
||||||
|
.version_id(&lock_source_version)
|
||||||
|
.legal_hold(ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::Off).build())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_until("legal hold removal on the existing target version", || async {
|
||||||
|
let head = target_client
|
||||||
|
.head_object()
|
||||||
|
.bucket(&target_bucket)
|
||||||
|
.key(lock_key)
|
||||||
|
.version_id(&lock_target_version)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(head.object_lock_legal_hold_status() == Some(&ObjectLockLegalHoldStatus::Off))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert_stable_single_version(&target, &target_bucket, lock_key, &lock_target_version).await?;
|
||||||
|
|
||||||
|
// Permanent delete of the older of two same-content generations.
|
||||||
|
let generations_key = "ledger/generations.bin";
|
||||||
|
let body = payload(4 * 1024, 0x03);
|
||||||
|
let older = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(generations_key)
|
||||||
|
.body(ByteStream::from(body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let older_version = older.version_id().ok_or("source PUT returned no version id")?.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_terminal_replication_status(&source_client, source_bucket, generations_key).await?,
|
||||||
|
"COMPLETED"
|
||||||
|
);
|
||||||
|
let older_replica = single_target_version(&target, &target_bucket, generations_key)?;
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(generations_key)
|
||||||
|
.body(ByteStream::from(body))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_terminal_replication_status(&source_client, source_bucket, generations_key).await?,
|
||||||
|
"COMPLETED"
|
||||||
|
);
|
||||||
|
wait_until("both generations replicated", || async {
|
||||||
|
Ok(target.stored_versions(&target_bucket, generations_key).len() == 2)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let newer_replica = target
|
||||||
|
.stored_versions(&target_bucket, generations_key)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(version_id, _)| version_id)
|
||||||
|
.find(|version_id| version_id != &older_replica)
|
||||||
|
.ok_or("the second generation must have its own target version")?;
|
||||||
|
|
||||||
|
source_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(generations_key)
|
||||||
|
.version_id(&older_version)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_until("permanent delete of the older generation's replica", || async {
|
||||||
|
let versions: Vec<String> = target
|
||||||
|
.stored_versions(&target_bucket, generations_key)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(version_id, _)| version_id)
|
||||||
|
.collect();
|
||||||
|
Ok(versions == [newer_replica.clone()])
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert_stable_single_version(&target, &target_bucket, generations_key, &newer_replica).await?;
|
||||||
|
|
||||||
|
// No mutation above may have gone out as a re-PUT: one upload per key.
|
||||||
|
for key in [tag_key, lock_key] {
|
||||||
|
let puts = target
|
||||||
|
.requests()
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.key.as_deref() == Some(key) && record.operation == FakeTargetOperation::PutObject)
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
puts, 1,
|
||||||
|
"{key}: a metadata update must not re-PUT the object on a target that mints its own ids"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
target.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rustfs/backlog#2340 (pending purge lifecycle): a permanent delete whose
|
||||||
|
/// replication keeps failing leaves the version in xl.meta as a PENDING purge,
|
||||||
|
/// hidden from listings. Once the bucket's replication configuration is
|
||||||
|
/// removed nothing can ever confirm that purge remotely, so the delete worker
|
||||||
|
/// must settle it locally (abandoned, with the replica left on the former
|
||||||
|
/// target) — otherwise the bucket stays `BucketNotEmpty` forever with a
|
||||||
|
/// residue the client cannot see.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn matrix_removed_replication_config_abandons_pending_purge() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let target = FakeS3Target::start().await?;
|
||||||
|
let target_bucket = "matrix-abandoned-purge-dst".to_string();
|
||||||
|
target.create_bucket_with_object_lock(target_bucket.clone());
|
||||||
|
TargetMode::MintOwnVersionIds.apply(&target);
|
||||||
|
|
||||||
|
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", ""),
|
||||||
|
// The scanner heal pass is what revisits a pending purge.
|
||||||
|
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||||
|
("RUSTFS_SCANNER_START_DELAY_SECS", "1"),
|
||||||
|
]);
|
||||||
|
let env = OdmTestEnv::start_with(OdmEnvOptions {
|
||||||
|
env: env_vars,
|
||||||
|
..OdmEnvOptions::default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let source_env = &env.rustfs;
|
||||||
|
|
||||||
|
let source_bucket = "matrix-abandoned-purge-src";
|
||||||
|
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_with_delete_statuses(source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
|
||||||
|
|
||||||
|
let key = "purge/orphaned.bin";
|
||||||
|
let put = source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from(payload(4 * 1024, 0x07)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let source_version = put.version_id().ok_or("source PUT returned no version id")?.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_terminal_replication_status(&source_client, source_bucket, key).await?,
|
||||||
|
"COMPLETED"
|
||||||
|
);
|
||||||
|
let replica = single_target_version(&target, &target_bucket, key)?;
|
||||||
|
|
||||||
|
// The target refuses every purge: the version stays a pending purge.
|
||||||
|
// More refusals than any scanner cycle can consume within the test.
|
||||||
|
target.inject_for_key(FakeTargetOperation::DeleteObject, key, FakeTargetFault::ResponseStatus(503), 4_000);
|
||||||
|
source_client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(&source_version)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_until("the refused purge to reach the target at least once", || async {
|
||||||
|
Ok(target.count_requests(FakeTargetOperation::DeleteObject, key) >= 1)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let listed = source_client.list_object_versions().bucket(source_bucket).send().await?;
|
||||||
|
assert!(
|
||||||
|
listed.versions().is_empty() && listed.delete_markers().is_empty(),
|
||||||
|
"a pending purge is hidden from listings: {listed:?}"
|
||||||
|
);
|
||||||
|
let blocked = source_client.delete_bucket().bucket(source_bucket).send().await;
|
||||||
|
assert!(
|
||||||
|
blocked
|
||||||
|
.as_ref()
|
||||||
|
.err()
|
||||||
|
.and_then(|err| err.as_service_error())
|
||||||
|
.is_some_and(|err| err.code() == Some("BucketNotEmpty")),
|
||||||
|
"the hidden pending purge must block DeleteBucket while the target is still configured: {blocked:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Removing the replication configuration orphans the purge; the scanner
|
||||||
|
// heal pass must settle it locally so the bucket becomes deletable.
|
||||||
|
let response = delete_bucket_replication(source_env, source_bucket).await?;
|
||||||
|
assert!(response.status().is_success(), "DeleteBucketReplication: {}", response.status());
|
||||||
|
wait_until("DeleteBucket to succeed once the orphaned purge is abandoned", || async {
|
||||||
|
match source_client.delete_bucket().bucket(source_bucket).send().await {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(err) if err.as_service_error().is_some_and(|err| err.code() == Some("BucketNotEmpty")) => Ok(false),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
// Abandoned means abandoned: the replica stays on the former target and,
|
||||||
|
// once the attempts in flight at removal time have drained, no further
|
||||||
|
// purge attempts are sent to it.
|
||||||
|
assert_eq!(
|
||||||
|
single_target_version(&target, &target_bucket, key)?,
|
||||||
|
replica,
|
||||||
|
"an abandoned purge must not touch the replica on the former target"
|
||||||
|
);
|
||||||
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
let settled = target.count_requests(FakeTargetOperation::DeleteObject, key);
|
||||||
|
sleep(Duration::from_secs(3)).await;
|
||||||
|
assert_eq!(
|
||||||
|
target.count_requests(FakeTargetOperation::DeleteObject, key),
|
||||||
|
settled,
|
||||||
|
"purge attempts must stop once the target is no longer configured"
|
||||||
|
);
|
||||||
|
|
||||||
|
target.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn single_target_version(target: &FakeS3Target, target_bucket: &str, key: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||||
|
let versions = target.stored_versions(target_bucket, key);
|
||||||
|
match versions.as_slice() {
|
||||||
|
[(version_id, false)] => Ok(version_id.clone()),
|
||||||
|
other => Err(format!("{key}: expected exactly one live target version, got {other:?}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target keeps holding exactly `version_id` for a few scanner cycles: a
|
||||||
|
/// re-driven PUT or a wrong delete would show up here.
|
||||||
|
async fn assert_stable_single_version(target: &FakeS3Target, target_bucket: &str, key: &str, version_id: &str) -> TestResult {
|
||||||
|
for _ in 0..8 {
|
||||||
|
let versions = target.stored_versions(target_bucket, key);
|
||||||
|
if versions.len() != 1 || versions[0].0 != version_id {
|
||||||
|
return Err(
|
||||||
|
format!("{key}: target versions drifted from the single expected replica {version_id}: {versions:?}").into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_until<F, Fut>(what: &str, mut probe: F) -> TestResult
|
||||||
|
where
|
||||||
|
F: FnMut() -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<bool, Box<dyn Error + Send + Sync>>>,
|
||||||
|
{
|
||||||
|
let wait = async {
|
||||||
|
loop {
|
||||||
|
if probe().await? {
|
||||||
|
return Ok::<_, Box<dyn Error + Send + Sync>>(());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timeout(Duration::from_secs(90), wait)
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("{what} did not happen within 90 seconds"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until `key` is COMPLETED on the source and, for the observation
|
||||||
|
/// window after that, the target still holds exactly one live version of it.
|
||||||
|
async fn wait_for_replication_status_and_single_version(
|
||||||
|
source_client: &Client,
|
||||||
|
source_bucket: &str,
|
||||||
|
target: &FakeS3Target,
|
||||||
|
target_bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
) -> TestResult {
|
||||||
|
// The lost PUT response first settles the object FAILED; only the next
|
||||||
|
// scanner heal pass can turn that into COMPLETED, so FAILED is transient
|
||||||
|
// here and the wait is for COMPLETED alone.
|
||||||
|
let converged = async {
|
||||||
|
loop {
|
||||||
|
let head = source_client.head_object().bucket(source_bucket).key(key).send().await?;
|
||||||
|
if head.replication_status().is_some_and(|status| status.as_str() == "COMPLETED") {
|
||||||
|
return Ok::<_, Box<dyn Error + Send + Sync>>(());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timeout(Duration::from_secs(90), converged)
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("{key}: heal re-drive did not converge to COMPLETED within 90 seconds"))??;
|
||||||
|
// The heal pass keeps visiting the key for a few scanner cycles; a
|
||||||
|
// duplicate would show up here as a second stored version.
|
||||||
|
for _ in 0..12 {
|
||||||
|
let versions = target.stored_versions(target_bucket, key);
|
||||||
|
assert_eq!(versions.len(), 1, "{key}: target minted another version on re-drive: {versions:?}");
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn matrix_baseline_target() -> TestResult {
|
async fn matrix_baseline_target() -> TestResult {
|
||||||
run_row(TargetMode::Baseline).await
|
run_row(TargetMode::Baseline).await
|
||||||
@@ -270,11 +902,15 @@ async fn run_row(mode: TargetMode) -> TestResult {
|
|||||||
target.create_bucket_with_object_lock(target_bucket.clone());
|
target.create_bucket_with_object_lock(target_bucket.clone());
|
||||||
mode.apply(&target);
|
mode.apply(&target);
|
||||||
|
|
||||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
|
||||||
let mut env_vars = replication_fast_env();
|
let mut env_vars = replication_fast_env();
|
||||||
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_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", "")]);
|
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 env = OdmTestEnv::start_with(OdmEnvOptions {
|
||||||
|
env: env_vars,
|
||||||
|
..OdmEnvOptions::default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let source_env = &env.rustfs;
|
||||||
|
|
||||||
let source_bucket = format!("matrix-{}-src", mode.slug());
|
let source_bucket = format!("matrix-{}-src", mode.slug());
|
||||||
let source_client = source_env.create_s3_client();
|
let source_client = source_env.create_s3_client();
|
||||||
@@ -284,9 +920,9 @@ async fn run_row(mode: TargetMode) -> TestResult {
|
|||||||
.object_lock_enabled_for_bucket(true)
|
.object_lock_enabled_for_bucket(true)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
enable_bucket_versioning(&source_env, &source_bucket).await?;
|
enable_bucket_versioning(source_env, &source_bucket).await?;
|
||||||
let target_arn = set_replication_target_with_options(
|
let target_arn = set_replication_target_with_options(
|
||||||
&source_env,
|
source_env,
|
||||||
&source_bucket,
|
&source_bucket,
|
||||||
ReplicationTargetOptions {
|
ReplicationTargetOptions {
|
||||||
endpoint: &target.address(),
|
endpoint: &target.address(),
|
||||||
@@ -299,14 +935,21 @@ async fn run_row(mode: TargetMode) -> TestResult {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
put_bucket_replication(&source_env, &source_bucket, &target_arn).await?;
|
put_bucket_replication(source_env, &source_bucket, &target_arn).await?;
|
||||||
|
|
||||||
let target_client = fake_source_client(&target);
|
let target_client = fake_source_client(&target);
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
for shape in ObjectShape::ALL {
|
for shape in ObjectShape::ALL {
|
||||||
let cell = format!("{}/{:?}", mode.slug(), shape);
|
let cell = format!("{}/{:?}", mode.slug(), shape);
|
||||||
let expected_body = shape.put(&source_client, &source_bucket).await?;
|
let expected_body = shape.put(&env, &source_bucket).await?;
|
||||||
let status = wait_for_terminal_replication_status(&source_client, &source_bucket, shape.key()).await?;
|
let status = wait_for_terminal_replication_status(&source_client, &source_bucket, shape.key()).await?;
|
||||||
|
if shape == ObjectShape::OdmPreservedMd5Multipart {
|
||||||
|
assert_eq!(
|
||||||
|
env.source.count_requests(FakeTargetOperation::GetObject, shape.key()),
|
||||||
|
2,
|
||||||
|
"one passthrough GET plus one background pull; replication must read the persisted local parts"
|
||||||
|
);
|
||||||
|
}
|
||||||
let journal = target.requests();
|
let journal = target.requests();
|
||||||
let outcome = match expectation(mode, shape) {
|
let outcome = match expectation(mode, shape) {
|
||||||
Expectation::Completed => {
|
Expectation::Completed => {
|
||||||
@@ -379,6 +1022,36 @@ async fn check_completed_cell(
|
|||||||
if uploads.is_empty() {
|
if uploads.is_empty() {
|
||||||
return Err("no upload reached the target although the source reports COMPLETED".into());
|
return Err("no upload reached the target although the source reports COMPLETED".into());
|
||||||
}
|
}
|
||||||
|
if shape == ObjectShape::OdmPreservedMd5Multipart {
|
||||||
|
let key_requests: Vec<_> = journal
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.key.as_deref() == Some(shape.key()))
|
||||||
|
.collect();
|
||||||
|
for operation in [
|
||||||
|
FakeTargetOperation::CreateMultipartUpload,
|
||||||
|
FakeTargetOperation::CompleteMultipartUpload,
|
||||||
|
] {
|
||||||
|
if !key_requests.iter().any(|record| record.operation == operation) {
|
||||||
|
return Err(format!("preserved-MD5 multipart object did not use {operation:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key_requests
|
||||||
|
.iter()
|
||||||
|
.any(|record| record.operation == FakeTargetOperation::PutObject)
|
||||||
|
{
|
||||||
|
return Err("preserved-MD5 multipart object used a single PutObject".into());
|
||||||
|
}
|
||||||
|
let mut part_numbers: Vec<_> = key_requests
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.operation == FakeTargetOperation::UploadPart)
|
||||||
|
.map(|record| record.part_number)
|
||||||
|
.collect();
|
||||||
|
part_numbers.sort_unstable();
|
||||||
|
part_numbers.dedup();
|
||||||
|
if part_numbers != [Some(1), Some(2)] {
|
||||||
|
return Err(format!("preserved-MD5 multipart object uploaded unexpected parts: {part_numbers:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(framed) = uploads.iter().find(|record| record.transport.aws_chunked) {
|
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());
|
return Err(format!("{cell}: an upload went out aws-chunked (rustfs#6853 framing): {framed:?}").into());
|
||||||
}
|
}
|
||||||
@@ -401,6 +1074,19 @@ async fn check_completed_cell(
|
|||||||
}) {
|
}) {
|
||||||
return Err(format!("a locked PutObject went out without any integrity header (rustfs#7082): {bare:?}").into());
|
return Err(format!("a locked PutObject went out without any integrity header (rustfs#7082): {bare:?}").into());
|
||||||
}
|
}
|
||||||
|
// rustfs/backlog#2340 contract: a source checksum reaches the target as
|
||||||
|
// the `x-amz-checksum-*` header, not as user metadata; every PutObject of
|
||||||
|
// the shape carries it.
|
||||||
|
if let Some(header) = shape.forwarded_checksum_header()
|
||||||
|
&& let Some(missing) = uploads.iter().find(|record| {
|
||||||
|
record.operation == FakeTargetOperation::PutObject
|
||||||
|
&& !record.transport.checksum_headers.iter().any(|name| name == header)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
format!("a PutObject went out without the source's {header} header (rustfs/backlog#2340): {missing:?}").into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +1141,71 @@ async fn wait_for_terminal_replication_status(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn odm_preserved_md5_multipart(env: &OdmTestEnv, bucket: &str, key: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
|
||||||
|
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||||
|
let origin_bucket = format!("{bucket}-origin");
|
||||||
|
env.source.create_bucket_with_mode(&origin_bucket, BucketMode::Unversioned);
|
||||||
|
let mut spec = env.fake_source_spec(&origin_bucket);
|
||||||
|
// Below the 16 MiB inline default the pull is one tee'd PUT with a single
|
||||||
|
// part; force the passthrough + background multipart write-back instead.
|
||||||
|
spec.policy.inline_max_bytes = 4096;
|
||||||
|
spec.policy.multipart_part_size_bytes = PART_SIZE as u64;
|
||||||
|
spec.policy.preserve_etag = true;
|
||||||
|
env.configure_and_wait(bucket, &spec).await?;
|
||||||
|
|
||||||
|
// A normal source PUT produces the MD5 ETag; only ODM chooses the local parts.
|
||||||
|
let body = payload(PART_SIZE + 4096, 0x66);
|
||||||
|
let source_put = env
|
||||||
|
.source_client()
|
||||||
|
.put_object()
|
||||||
|
.bucket(&origin_bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from(body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let source_etag = source_put.e_tag().ok_or("source PUT omitted its ETag")?.trim_matches('"');
|
||||||
|
assert_eq!(source_etag.len(), 32, "source fixture must have a single-PUT MD5 ETag");
|
||||||
|
assert!(source_etag.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
||||||
|
|
||||||
|
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, Duration::from_secs(30)).await?,
|
||||||
|
"ODM must persist the object"
|
||||||
|
);
|
||||||
|
let attributes = env
|
||||||
|
.client
|
||||||
|
.get_object_attributes()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.object_attributes(ObjectAttributes::Etag)
|
||||||
|
.object_attributes(ObjectAttributes::ObjectParts)
|
||||||
|
.object_attributes(ObjectAttributes::Checksum)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(attributes.e_tag().map(|etag| etag.trim_matches('"')), Some(source_etag));
|
||||||
|
let parts = attributes
|
||||||
|
.object_parts()
|
||||||
|
.ok_or("the ODM copy must expose its two local parts")?;
|
||||||
|
assert_eq!(parts.total_parts_count(), Some(2));
|
||||||
|
assert_eq!(
|
||||||
|
parts
|
||||||
|
.parts()
|
||||||
|
.iter()
|
||||||
|
.map(|part| (part.part_number(), part.size()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[(Some(1), Some(PART_SIZE as i64)), (Some(2), Some(4096))]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
attributes
|
||||||
|
.checksum()
|
||||||
|
.is_none_or(|checksum| checksum == &Checksum::builder().build()),
|
||||||
|
"multipart routing must work without an object checksum record"
|
||||||
|
);
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
async fn multipart_put(
|
async fn multipart_put(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ workspace = true
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
|
||||||
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
||||||
# production binary (it is always available to tests). Off by default so the
|
# production binary (it is always available to tests). Off by default so the
|
||||||
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
|
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
|
||||||
@@ -212,8 +213,8 @@ aws-smithy-runtime-api = { workspace = true, features = ["http-1x"] }
|
|||||||
parking_lot = { workspace = true }
|
parking_lot = { workspace = true }
|
||||||
base64-simd.workspace = true
|
base64-simd.workspace = true
|
||||||
serde_urlencoded.workspace = true
|
serde_urlencoded.workspace = true
|
||||||
google-cloud-storage = { workspace = true }
|
google-cloud-storage = { workspace = true, optional = true }
|
||||||
google-cloud-auth = { workspace = true }
|
google-cloud-auth = { workspace = true, optional = true }
|
||||||
faster-hex = { workspace = true }
|
faster-hex = { workspace = true }
|
||||||
ratelimit = { workspace = true }
|
ratelimit = { workspace = true }
|
||||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
||||||
@@ -225,7 +226,7 @@ metrics = { workspace = true }
|
|||||||
# crates.io. The guard scripts/check_no_tokio_io_uring.sh allows an explicit
|
# crates.io. The guard scripts/check_no_tokio_io_uring.sh allows an explicit
|
||||||
# io-uring integration; only the tokio "io-uring" runtime feature is banned.
|
# io-uring integration; only the tokio "io-uring" runtime feature is banned.
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
rustfs-uring = "0.2.1"
|
rustfs-uring = "0.2.2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winapi-util.workspace = true
|
winapi-util.workspace = true
|
||||||
@@ -244,6 +245,7 @@ windows-sys = { workspace = true, features = [
|
|||||||
windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] }
|
windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
aws-smithy-async.workspace = true
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
|
||||||
criterion = { workspace = true, features = ["html_reports"] }
|
criterion = { workspace = true, features = ["html_reports"] }
|
||||||
temp-env = { workspace = true, features = ["async_closure"] }
|
temp-env = { workspace = true, features = ["async_closure"] }
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub mod bucket {
|
|||||||
pub mod bucket_target_sys {
|
pub mod bucket_target_sys {
|
||||||
pub use crate::bucket::bucket_target_sys::{
|
pub use crate::bucket::bucket_target_sys::{
|
||||||
AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError,
|
AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError,
|
||||||
SsecPassthroughCapability, TargetClient, append_version_id_query,
|
SsecPassthroughCapability, TargetClient, VersionIdentityCapability, append_version_id_query,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +69,33 @@ pub mod bucket {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod recovery_control {
|
||||||
|
pub use crate::bucket::lifecycle::recovery_control::{
|
||||||
|
IlmRecoveryClassification, IlmRecoveryControlPage, IlmRecoveryControlView, IlmRecoveryProtocol,
|
||||||
|
inspect_recovery_control, list_recovery_controls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod recovery_disposition {
|
||||||
|
pub use crate::bucket::lifecycle::recovery_disposition::{
|
||||||
|
IlmRecoveryDispositionExecutionOutcome, IlmRecoveryDispositionReasonCode, IlmRecoveryDispositionState,
|
||||||
|
dry_run_recovery_disposition, execute_recovery_disposition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod recovery_export {
|
||||||
|
pub use crate::bucket::lifecycle::recovery_export::{
|
||||||
|
IlmRecoveryExportCreated, IlmRecoveryExportObservation, create_recovery_export,
|
||||||
|
inspect_recovery_export_observation, load_recovery_export,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub mod transition_transaction {
|
pub mod transition_transaction {
|
||||||
pub use crate::bucket::lifecycle::transition_transaction::{
|
pub use crate::bucket::lifecycle::transition_transaction::{
|
||||||
TransitionOperatorDeleteResult, TransitionOperatorError, TransitionOperatorProbe, TransitionOperatorStatus,
|
TransitionOperatorDeleteResult, TransitionOperatorError, TransitionOperatorProbe, TransitionOperatorStatus,
|
||||||
delete_transition_candidate_for_operator, finalize_missing_transition_transaction_for_operator,
|
TransitionRecoveryRetryResult, TransitionRecoveryRetryStatus, delete_transition_candidate_for_operator,
|
||||||
inspect_transition_transaction_for_operator,
|
finalize_missing_transition_transaction_for_operator, inspect_transition_recovery_retry_for_operator,
|
||||||
|
inspect_transition_transaction_for_operator, retry_transition_recovery_for_operator,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
pub use crate::bucket::lifecycle::transition_transaction::{
|
pub use crate::bucket::lifecycle::transition_transaction::{
|
||||||
@@ -89,8 +111,9 @@ pub mod bucket {
|
|||||||
#[allow(clippy::module_inception)]
|
#[allow(clippy::module_inception)]
|
||||||
pub mod lifecycle {
|
pub mod lifecycle {
|
||||||
pub use crate::bucket::lifecycle::lifecycle::{
|
pub use crate::bucket::lifecycle::lifecycle::{
|
||||||
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate,
|
Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate,
|
||||||
TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time, object_opts_from_object_info,
|
ObjectOpts, RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time,
|
||||||
|
object_opts_from_object_info,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,66 +169,25 @@ 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 {
|
pub mod metadata_sys {
|
||||||
#[cfg(feature = "test-util")]
|
|
||||||
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
|
|
||||||
pub use crate::bucket::metadata_sys::{
|
pub use crate::bucket::metadata_sys::{
|
||||||
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
BUCKET_CONFIG_PUBLISH_HOOK, BucketConfigPublishHook, BucketMetadataMutationGuard, BucketMetadataSys,
|
||||||
acquire_bucket_metadata_transaction_lock_for_incarnation, capture_bucket_metadata_incarnation, delete,
|
ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||||
delete_if_incarnation, delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy,
|
acquire_bucket_metadata_transaction_lock_for_incarnation, acquire_scanner_bucket_incarnation_fence,
|
||||||
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, delete_if_incarnation_at,
|
||||||
|
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_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_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
|
||||||
get_quota_config, get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config,
|
get_on_demand_migration_config_in, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||||
get_versioning_config, get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata,
|
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||||
remove_bucket_metadata, set_bucket_metadata, update, update_bucket_targets_under_transaction_lock,
|
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
||||||
update_config_with, update_if_incarnation, update_quota_if_incarnation, update_under_transaction_lock,
|
update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||||
|
update_if_incarnation_at, update_quota_if_incarnation, update_quota_if_incarnation_at, update_under_transaction_lock,
|
||||||
|
update_under_transaction_lock_at,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub use crate::bucket::metadata_sys::{ConfigWriteLockProbe, test_support};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod migration {
|
pub mod migration {
|
||||||
@@ -248,7 +230,7 @@ pub mod bucket {
|
|||||||
pub mod remote_s3_client {
|
pub mod remote_s3_client {
|
||||||
pub use crate::bucket::remote_s3_client::{
|
pub use crate::bucket::remote_s3_client::{
|
||||||
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
|
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
|
||||||
validate_remote_endpoint,
|
build_remote_s3_config, validate_remote_endpoint, validate_target_ca_pem,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +308,7 @@ pub mod cache {
|
|||||||
pub mod capacity {
|
pub mod capacity {
|
||||||
pub use crate::core::pools::{
|
pub use crate::core::pools::{
|
||||||
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
|
||||||
path2_bucket_object, path2_bucket_object_with_base_path,
|
is_pool_activation_fleet_proof_error, path2_bucket_object, path2_bucket_object_with_base_path,
|
||||||
};
|
};
|
||||||
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
pub use crate::store::utils::is_reserved_or_invalid_bucket;
|
||||||
}
|
}
|
||||||
@@ -479,9 +461,14 @@ pub mod notification {
|
|||||||
#[cfg(any(test, feature = "test-util"))]
|
#[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::rotate_cross_pool_fence_fleet_proof_for_test;
|
||||||
pub use crate::services::notification_sys::{
|
pub use crate::services::notification_sys::{
|
||||||
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
|
||||||
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||||
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||||
|
ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_local_process_epoch,
|
||||||
|
ilm_recovery_export_member_epochs_sha256, ilm_recovery_export_topology_generation,
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_matches, new_global_notification_sys,
|
||||||
|
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +479,9 @@ pub mod object {
|
|||||||
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
|
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
|
||||||
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||||
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
|
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
|
||||||
ScannerPublicationCommitState, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
|
ScannerPublicationCommitState, StreamConsumer, WriteCompletion, get_object_body_cache_plaintext_len,
|
||||||
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
|
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
|
||||||
unregister_object_mutation_hook,
|
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||||
};
|
};
|
||||||
pub use crate::store::{
|
pub use crate::store::{
|
||||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||||
@@ -532,7 +519,8 @@ pub mod rpc {
|
|||||||
pub use crate::cluster::rpc::{
|
pub use crate::cluster::rpc::{
|
||||||
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
|
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
|
||||||
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
||||||
ScannerBucketListing, ScannerPeerActivity, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, TONIC_RPC_PREFIX,
|
ScannerBucketListing, ScannerDirtyUsageAcknowledgement, ScannerPeerActivity, ScannerPeerDirtyUsageBucket,
|
||||||
|
ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, ScannerScopedDirtyUsageAckEntry, TONIC_RPC_PREFIX,
|
||||||
TonicInterceptor, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
|
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,
|
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,
|
gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth,
|
||||||
@@ -558,6 +546,12 @@ pub mod set_disk {
|
|||||||
pub mod test_util {
|
pub mod test_util {
|
||||||
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
|
||||||
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
|
||||||
|
|
||||||
|
/// Keep a namespace commit pending until the returned owner is dropped.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hold_namespace_commit(store: &crate::store::ECStore) -> impl Send + Sync {
|
||||||
|
store.ctx.begin_namespace_commit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use crate::bucket::metadata_sys::get_replication_config;
|
|||||||
use crate::bucket::remote_s3_client::{
|
use crate::bucket::remote_s3_client::{
|
||||||
PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, build_remote_s3_client,
|
PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, build_remote_s3_client,
|
||||||
};
|
};
|
||||||
use crate::bucket::replication::{ObjectLockIntegrity, object_lock_put_integrity};
|
use crate::bucket::replication::{ObjectLockIntegrity, object_lock_put_integrity, replication_etags_match};
|
||||||
use crate::bucket::replication::{ReplicationStatusType, ReplicationTargetConfigBridge};
|
use crate::bucket::replication::{ReplicationStatusType, ReplicationTargetConfigBridge};
|
||||||
use crate::bucket::target::ARN;
|
use crate::bucket::target::ARN;
|
||||||
use crate::bucket::target::BucketTargetType;
|
use crate::bucket::target::BucketTargetType;
|
||||||
@@ -33,6 +33,8 @@ use aws_sdk_s3::operation::get_object::{GetObjectError, GetObjectOutput};
|
|||||||
use aws_sdk_s3::operation::get_object_tagging::{GetObjectTaggingError, GetObjectTaggingOutput};
|
use aws_sdk_s3::operation::get_object_tagging::{GetObjectTaggingError, GetObjectTaggingOutput};
|
||||||
use aws_sdk_s3::operation::head_bucket::HeadBucketError;
|
use aws_sdk_s3::operation::head_bucket::HeadBucketError;
|
||||||
use aws_sdk_s3::operation::head_object::HeadObjectError;
|
use aws_sdk_s3::operation::head_object::HeadObjectError;
|
||||||
|
use aws_sdk_s3::operation::put_object_legal_hold::{PutObjectLegalHoldError, PutObjectLegalHoldOutput};
|
||||||
|
use aws_sdk_s3::operation::put_object_retention::{PutObjectRetentionError, PutObjectRetentionOutput};
|
||||||
use aws_sdk_s3::operation::put_object_tagging::{PutObjectTaggingError, PutObjectTaggingOutput};
|
use aws_sdk_s3::operation::put_object_tagging::{PutObjectTaggingError, PutObjectTaggingOutput};
|
||||||
use aws_sdk_s3::operation::upload_part::UploadPartOutput;
|
use aws_sdk_s3::operation::upload_part::UploadPartOutput;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
@@ -42,6 +44,7 @@ use aws_sdk_s3::types::{
|
|||||||
ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
|
ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
|
||||||
ServerSideEncryption,
|
ServerSideEncryption,
|
||||||
};
|
};
|
||||||
|
use aws_sdk_s3::types::{ObjectLockLegalHold, ObjectLockRetention};
|
||||||
use aws_sdk_s3::{Client as S3Client, operation::head_object::HeadObjectOutput};
|
use aws_sdk_s3::{Client as S3Client, operation::head_object::HeadObjectOutput};
|
||||||
use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
|
use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
|
||||||
use futures::{StreamExt, stream};
|
use futures::{StreamExt, stream};
|
||||||
@@ -59,7 +62,7 @@ use rustfs_utils::http::{
|
|||||||
insert_header,
|
insert_header,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::str::FromStr as _;
|
use std::str::FromStr as _;
|
||||||
@@ -126,6 +129,25 @@ impl From<&BucketTarget> for RemoteS3EndpointSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type HeadObjectSdkError = Box<SdkError<HeadObjectError>>;
|
pub type HeadObjectSdkError = Box<SdkError<HeadObjectError>>;
|
||||||
|
|
||||||
|
/// Whether an edited bucket target still addresses the same remote service
|
||||||
|
/// (endpoint, bucket, path style, TLS and identity), so a verdict learned
|
||||||
|
/// about that service stays valid across the edit.
|
||||||
|
fn same_replication_service(edited: &BucketTarget, previous: &BucketTarget) -> bool {
|
||||||
|
let access_key = |target: &BucketTarget| target.credentials.as_ref().map(|credentials| credentials.access_key.clone());
|
||||||
|
edited.endpoint == previous.endpoint
|
||||||
|
&& edited.target_bucket == previous.target_bucket
|
||||||
|
&& edited.secure == previous.secure
|
||||||
|
&& edited.path == previous.path
|
||||||
|
&& access_key(edited) == access_key(previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page size and page budget for [`TargetClient::locate_replica_by_etag`].
|
||||||
|
const FIND_VERSION_BY_ETAG_PAGE_SIZE: i32 = 1000;
|
||||||
|
const FIND_VERSION_BY_ETAG_MAX_PAGES: usize = 8;
|
||||||
|
/// Candidate cap for [`TargetClient::replica_candidates_by_etag`]: more than
|
||||||
|
/// this many same-content versions of one key is ambiguity by any measure.
|
||||||
|
const FIND_VERSION_BY_ETAG_MAX_MATCHES: usize = 16;
|
||||||
pub type GetObjectSdkError = Box<SdkError<GetObjectError>>;
|
pub type GetObjectSdkError = Box<SdkError<GetObjectError>>;
|
||||||
pub type GetObjectTaggingSdkError = Box<SdkError<GetObjectTaggingError>>;
|
pub type GetObjectTaggingSdkError = Box<SdkError<GetObjectTaggingError>>;
|
||||||
pub type PutObjectTaggingSdkError = Box<SdkError<PutObjectTaggingError>>;
|
pub type PutObjectTaggingSdkError = Box<SdkError<PutObjectTaggingError>>;
|
||||||
@@ -349,6 +371,13 @@ struct TargetClientBuildProbe {
|
|||||||
/// their import path while the verdict vocabulary lives with the
|
/// their import path while the verdict vocabulary lives with the
|
||||||
/// replication decision logic.
|
/// replication decision logic.
|
||||||
pub use crate::bucket::replication::SsecPassthroughCapability;
|
pub use crate::bucket::replication::SsecPassthroughCapability;
|
||||||
|
/// Version-identity verdicts (see the enum's own docs in
|
||||||
|
/// `rustfs-replication`) are cached here per target ARN and follow the same
|
||||||
|
/// `arn_remotes_map` lifecycle. They carry no TTL: the verdict is refreshed
|
||||||
|
/// by every replication write's response, so it can only go stale on a
|
||||||
|
/// target that receives no writes — and a stale `MintsOwn` costs one extra
|
||||||
|
/// content-identity lookup before a PUT, never a lost replica.
|
||||||
|
pub use crate::bucket::replication::VersionIdentityCapability;
|
||||||
|
|
||||||
/// How long an audited SSE-C passthrough verdict stays authoritative.
|
/// How long an audited SSE-C passthrough verdict stays authoritative.
|
||||||
///
|
///
|
||||||
@@ -375,7 +404,17 @@ pub struct BucketTargetSys {
|
|||||||
/// SSE-C passthrough capability verdicts keyed by target ARN. See
|
/// SSE-C passthrough capability verdicts keyed by target ARN. See
|
||||||
/// [`SsecPassthroughCapability`]; reset alongside `arn_remotes_map`.
|
/// [`SsecPassthroughCapability`]; reset alongside `arn_remotes_map`.
|
||||||
ssec_passthrough_map: Arc<RwLock<HashMap<String, SsecPassthroughRecord>>>,
|
ssec_passthrough_map: Arc<RwLock<HashMap<String, SsecPassthroughRecord>>>,
|
||||||
|
/// Version-identity verdicts keyed by target ARN. See
|
||||||
|
/// [`VersionIdentityCapability`]; reset alongside `arn_remotes_map`. A std
|
||||||
|
/// lock (never held across an await) so the replication worker can record
|
||||||
|
/// a verdict from inside its synchronous PUT-response audit.
|
||||||
|
version_identity_map: Arc<std::sync::RwLock<HashMap<String, VersionIdentityCapability>>>,
|
||||||
pub targets_map: Arc<RwLock<HashMap<String, Vec<BucketTarget>>>>,
|
pub targets_map: Arc<RwLock<HashMap<String, Vec<BucketTarget>>>>,
|
||||||
|
/// Buckets whose persisted `bucket-targets.json` exists but cannot be
|
||||||
|
/// decoded (rustfs/backlog#2282). Written under the bucket's update mutex
|
||||||
|
/// alongside `targets_map`, and read before it so an unreadable
|
||||||
|
/// configuration surfaces as a typed error instead of an empty target set.
|
||||||
|
unreadable_targets: Arc<RwLock<HashSet<String>>>,
|
||||||
pub h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>,
|
pub h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>,
|
||||||
target_h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>,
|
target_h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>,
|
||||||
pub hc_client: Arc<HttpClient>,
|
pub hc_client: Arc<HttpClient>,
|
||||||
@@ -418,7 +457,9 @@ impl BucketTargetSys {
|
|||||||
Self {
|
Self {
|
||||||
arn_remotes_map: Arc::new(RwLock::new(HashMap::new())),
|
arn_remotes_map: Arc::new(RwLock::new(HashMap::new())),
|
||||||
ssec_passthrough_map: Arc::new(RwLock::new(HashMap::new())),
|
ssec_passthrough_map: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
version_identity_map: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||||
targets_map: Arc::new(RwLock::new(HashMap::new())),
|
targets_map: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
unreadable_targets: Arc::new(RwLock::new(HashSet::new())),
|
||||||
h_mutex: Arc::new(RwLock::new(HashMap::new())),
|
h_mutex: Arc::new(RwLock::new(HashMap::new())),
|
||||||
target_h_mutex: Arc::new(RwLock::new(HashMap::new())),
|
target_h_mutex: Arc::new(RwLock::new(HashMap::new())),
|
||||||
hc_client: Arc::new(build_health_check_client()),
|
hc_client: Arc::new(build_health_check_client()),
|
||||||
@@ -628,12 +669,19 @@ impl BucketTargetSys {
|
|||||||
health_map.clone()
|
health_map.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_targets(&self, bucket: &str, arn_type: &str) -> Vec<BucketTarget> {
|
/// Targets of one bucket, or of every bucket when `bucket` is empty.
|
||||||
|
///
|
||||||
|
/// A bucket that simply has no targets yields an empty list; a bucket
|
||||||
|
/// whose persisted configuration cannot be decoded is an error, so an
|
||||||
|
/// admin listing reports the fault instead of an empty list that reads as
|
||||||
|
/// "replication is not configured" (rustfs/backlog#2282).
|
||||||
|
pub async fn list_targets(&self, bucket: &str, arn_type: &str) -> Result<Vec<BucketTarget>, BucketTargetError> {
|
||||||
let health_stats = self.target_health_stats().await;
|
let health_stats = self.target_health_stats().await;
|
||||||
let mut targets = Vec::new();
|
let mut targets = Vec::new();
|
||||||
|
|
||||||
if !bucket.is_empty() {
|
if !bucket.is_empty() {
|
||||||
if let Ok(bucket_targets) = self.list_bucket_targets(bucket).await {
|
match self.list_bucket_targets(bucket).await {
|
||||||
|
Ok(bucket_targets) => {
|
||||||
for mut target in bucket_targets.targets {
|
for mut target in bucket_targets.targets {
|
||||||
if arn_type.is_empty() || target.target_type.to_string() == arn_type {
|
if arn_type.is_empty() || target.target_type.to_string() == arn_type {
|
||||||
if let Some(health) = health_stats.get(&target.arn) {
|
if let Some(health) = health_stats.get(&target.arn) {
|
||||||
@@ -651,7 +699,10 @@ impl BucketTargetSys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return targets;
|
Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
return Ok(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
let targets_map = self.targets_map.read().await;
|
let targets_map = self.targets_map.read().await;
|
||||||
@@ -674,10 +725,16 @@ impl BucketTargetSys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
targets
|
Ok(targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_bucket_targets(&self, bucket: &str) -> Result<BucketTargets, BucketTargetError> {
|
pub async fn list_bucket_targets(&self, bucket: &str) -> Result<BucketTargets, BucketTargetError> {
|
||||||
|
if self.unreadable_targets.read().await.contains(bucket) {
|
||||||
|
return Err(BucketTargetError::BucketRemoteTargetsUnreadable {
|
||||||
|
bucket: bucket.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let targets_map = self.targets_map.read().await;
|
let targets_map = self.targets_map.read().await;
|
||||||
if let Some(targets) = targets_map.get(bucket) {
|
if let Some(targets) = targets_map.get(bucket) {
|
||||||
Ok(BucketTargets {
|
Ok(BucketTargets {
|
||||||
@@ -690,13 +747,30 @@ impl BucketTargetSys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record that this bucket's persisted targets configuration exists but
|
||||||
|
/// cannot be decoded (rustfs/backlog#2282).
|
||||||
|
///
|
||||||
|
/// Any snapshot published from an earlier readable load is deliberately
|
||||||
|
/// left in place: withdrawing it would produce exactly the silent "no
|
||||||
|
/// targets configured" state this marker exists to prevent. The marker is
|
||||||
|
/// cleared by the next successful publish, which is what makes a repaired
|
||||||
|
/// configuration take effect without a restart.
|
||||||
|
pub async fn mark_targets_unreadable(&self, bucket: &str) {
|
||||||
|
let update_mutex = self.target_update_mutex(bucket).await;
|
||||||
|
let _update_guard = update_mutex.lock().await;
|
||||||
|
|
||||||
|
self.unreadable_targets.write().await.insert(bucket.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, bucket: &str) {
|
pub async fn delete(&self, bucket: &str) {
|
||||||
let update_mutex = self.target_update_mutex(bucket).await;
|
let update_mutex = self.target_update_mutex(bucket).await;
|
||||||
let _update_guard = update_mutex.lock().await;
|
let _update_guard = update_mutex.lock().await;
|
||||||
|
|
||||||
// Lock order: targets_map, then arn_remotes_map, then target_h_mutex,
|
// Lock order: unreadable_targets, then targets_map, then
|
||||||
// then ssec_passthrough_map (always last; also taken standalone by the
|
// arn_remotes_map, then target_h_mutex, then ssec_passthrough_map
|
||||||
// capability accessors).
|
// (always last; also taken standalone by the capability accessors).
|
||||||
|
self.unreadable_targets.write().await.remove(bucket);
|
||||||
|
|
||||||
let mut targets_map = self.targets_map.write().await;
|
let mut targets_map = self.targets_map.write().await;
|
||||||
let mut arn_remotes_map = self.arn_remotes_map.write().await;
|
let mut arn_remotes_map = self.arn_remotes_map.write().await;
|
||||||
let mut health_map = self.target_h_mutex.write().await;
|
let mut health_map = self.target_h_mutex.write().await;
|
||||||
@@ -707,10 +781,40 @@ impl BucketTargetSys {
|
|||||||
arn_remotes_map.remove(&target.arn);
|
arn_remotes_map.remove(&target.arn);
|
||||||
health_map.remove(&target.arn);
|
health_map.remove(&target.arn);
|
||||||
ssec_map.remove(&target.arn);
|
ssec_map.remove(&target.arn);
|
||||||
|
self.forget_version_identity_capability(&target.arn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached version-identity verdict for a target ARN; `Unknown` until a
|
||||||
|
/// replication write or a replication-check VersionFidelity probe judged
|
||||||
|
/// it since the target was built.
|
||||||
|
pub fn version_identity_capability(&self, arn: &str) -> VersionIdentityCapability {
|
||||||
|
self.version_identity_map
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.get(arn)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a version-identity verdict for a target ARN. Written by the
|
||||||
|
/// replication worker after every PutObject / CompleteMultipartUpload
|
||||||
|
/// response and by the replication-check VersionFidelity phase.
|
||||||
|
pub fn record_version_identity_capability(&self, arn: &str, capability: VersionIdentityCapability) {
|
||||||
|
self.version_identity_map
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.insert(arn.to_string(), capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forget_version_identity_capability(&self, arn: &str) {
|
||||||
|
self.version_identity_map
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.remove(arn);
|
||||||
|
}
|
||||||
|
|
||||||
/// Cached SSE-C passthrough capability for a target ARN, plus whether the
|
/// Cached SSE-C passthrough capability for a target ARN, plus whether the
|
||||||
/// verdict is older than [`SSEC_PASSTHROUGH_CAPABILITY_TTL`]. `(Unknown,
|
/// verdict is older than [`SSEC_PASSTHROUGH_CAPABILITY_TTL`]. `(Unknown,
|
||||||
/// false)` when no verdict has been recorded since the target was built.
|
/// false)` when no verdict has been recorded since the target was built.
|
||||||
@@ -755,17 +859,24 @@ impl BucketTargetSys {
|
|||||||
) -> Result<BucketTargets, BucketTargetError> {
|
) -> Result<BucketTargets, BucketTargetError> {
|
||||||
self.validate_target(bucket, target).await?;
|
self.validate_target(bucket, target).await?;
|
||||||
|
|
||||||
let mut bucket_targets = match self.list_bucket_targets(bucket).await {
|
let mut bucket_targets = self.targets_base_for_write(bucket).await?;
|
||||||
Ok(targets) => targets,
|
|
||||||
Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => BucketTargets::default(),
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self::upsert_target_entry(&mut bucket_targets.targets, target, update)?;
|
Self::upsert_target_entry(&mut bucket_targets.targets, target, update)?;
|
||||||
|
|
||||||
Ok(bucket_targets)
|
Ok(bucket_targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ordinary writes must not turn an unreadable cached snapshot into an
|
||||||
|
/// empty configuration. Explicit repair belongs to the metadata transaction
|
||||||
|
/// that can inspect the current persisted state.
|
||||||
|
async fn targets_base_for_write(&self, bucket: &str) -> Result<BucketTargets, BucketTargetError> {
|
||||||
|
match self.list_bucket_targets(bucket).await {
|
||||||
|
Ok(targets) => Ok(targets),
|
||||||
|
Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => Ok(BucketTargets::default()),
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn validate_target(&self, bucket: &str, target: &BucketTarget) -> Result<(), BucketTargetError> {
|
pub async fn validate_target(&self, bucket: &str, target: &BucketTarget) -> Result<(), BucketTargetError> {
|
||||||
if !target.target_type.is_valid() {
|
if !target.target_type.is_valid() {
|
||||||
return Err(BucketTargetError::BucketRemoteArnTypeInvalid {
|
return Err(BucketTargetError::BucketRemoteArnTypeInvalid {
|
||||||
@@ -824,7 +935,9 @@ impl BucketTargetSys {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upsert_target_entry(
|
/// Merge a validated target into a caller-owned snapshot. The caller must
|
||||||
|
/// protect that snapshot through persistence.
|
||||||
|
pub fn upsert_target_entry(
|
||||||
bucket_targets: &mut Vec<BucketTarget>,
|
bucket_targets: &mut Vec<BucketTarget>,
|
||||||
target: &BucketTarget,
|
target: &BucketTarget,
|
||||||
update: bool,
|
update: bool,
|
||||||
@@ -1093,6 +1206,11 @@ impl BucketTargetSys {
|
|||||||
/// Keeping persisted-config reads under the same mutex prevents a stale
|
/// Keeping persisted-config reads under the same mutex prevents a stale
|
||||||
/// reload from overwriting a concurrent credential rotation.
|
/// reload from overwriting a concurrent credential rotation.
|
||||||
async fn update_all_targets_locked(&self, bucket: &str, targets: Option<&BucketTargets>) {
|
async fn update_all_targets_locked(&self, bucket: &str, targets: Option<&BucketTargets>) {
|
||||||
|
// Reaching here means the persisted configuration decoded, so the
|
||||||
|
// unreadable marker (if any) is stale. Cleared before the maps below
|
||||||
|
// so `unreadable_targets` stays the outermost of this module's locks.
|
||||||
|
self.unreadable_targets.write().await.remove(bucket);
|
||||||
|
|
||||||
let mut clients = Vec::new();
|
let mut clients = Vec::new();
|
||||||
if let Some(new_targets) = targets {
|
if let Some(new_targets) = targets {
|
||||||
for target in &new_targets.targets {
|
for target in &new_targets.targets {
|
||||||
@@ -1100,21 +1218,41 @@ impl BucketTargetSys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock order: targets_map, then arn_remotes_map, then target_h_mutex,
|
// Lock order: unreadable_targets (above), then targets_map, then
|
||||||
// then ssec_passthrough_map (always last; also taken standalone by the
|
// arn_remotes_map, then target_h_mutex, then ssec_passthrough_map
|
||||||
// capability accessors).
|
// (always last; also taken standalone by the capability accessors).
|
||||||
let mut targets_map = self.targets_map.write().await;
|
let mut targets_map = self.targets_map.write().await;
|
||||||
let mut arn_remotes_map = self.arn_remotes_map.write().await;
|
let mut arn_remotes_map = self.arn_remotes_map.write().await;
|
||||||
let mut health_map = self.target_h_mutex.write().await;
|
let mut health_map = self.target_h_mutex.write().await;
|
||||||
// Remove existing targets
|
// Remove existing targets
|
||||||
if let Some(existing_targets) = targets_map.remove(bucket) {
|
if let Some(existing_targets) = targets_map.remove(bucket) {
|
||||||
let mut ssec_map = self.ssec_passthrough_map.write().await;
|
let mut ssec_map = self.ssec_passthrough_map.write().await;
|
||||||
|
let unchanged_service: HashMap<&str, &BucketTarget> = targets
|
||||||
|
.map(|new_targets| {
|
||||||
|
new_targets
|
||||||
|
.targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| (target.arn.as_str(), target))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
for target in existing_targets {
|
for target in existing_targets {
|
||||||
arn_remotes_map.remove(&target.arn);
|
arn_remotes_map.remove(&target.arn);
|
||||||
health_map.remove(&target.arn);
|
health_map.remove(&target.arn);
|
||||||
// A rebuilt/edited target may point at a different service:
|
// A rebuilt/edited target may point at a different service:
|
||||||
// the SSE-C passthrough verdict must be re-audited from Unknown.
|
// the SSE-C passthrough verdict must be re-audited from Unknown.
|
||||||
ssec_map.remove(&target.arn);
|
ssec_map.remove(&target.arn);
|
||||||
|
// The version-identity verdict survives an edit that keeps the
|
||||||
|
// same remote service (a resync start or a bandwidth change
|
||||||
|
// rewrites the entry in place): forgetting it there would make
|
||||||
|
// the very resync that follows re-drive every object as a
|
||||||
|
// duplicate on a target that mints its own version ids.
|
||||||
|
if unchanged_service
|
||||||
|
.get(target.arn.as_str())
|
||||||
|
.is_none_or(|edited| !same_replication_service(edited, &target))
|
||||||
|
{
|
||||||
|
self.forget_version_identity_capability(&target.arn);
|
||||||
|
}
|
||||||
self.update_bandwidth_limit(bucket, &target.arn, 0);
|
self.update_bandwidth_limit(bucket, &target.arn, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1161,6 +1299,11 @@ impl BucketTargetSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set(&self, bucket: &str, meta: &BucketMetadata) {
|
pub async fn set(&self, bucket: &str, meta: &BucketMetadata) {
|
||||||
|
if meta.bucket_targets_unreadable() {
|
||||||
|
self.mark_targets_unreadable(bucket).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let Some(config) = &meta.bucket_target_config else {
|
let Some(config) = &meta.bucket_target_config else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1178,9 +1321,13 @@ impl BucketTargetSys {
|
|||||||
return (String::new(), false);
|
return (String::new(), false);
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
|
||||||
let targets_map = self.targets_map.read().await;
|
let targets_map = self.targets_map.read().await;
|
||||||
if let Some(targets) = targets_map.get(bucket) {
|
let targets = targets_map.get(bucket).map(Vec::as_slice).unwrap_or_default();
|
||||||
|
Self::remote_arn_for_targets(targets, target, depl_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve create idempotency against the snapshot the caller will persist.
|
||||||
|
pub fn remote_arn_for_targets(targets: &[BucketTarget], target: &BucketTarget, depl_id: &str) -> (String, bool) {
|
||||||
for tgt in targets {
|
for tgt in targets {
|
||||||
if tgt.target_type == target.target_type
|
if tgt.target_type == target.target_type
|
||||||
&& tgt.target_bucket == target.target_bucket
|
&& tgt.target_bucket == target.target_bucket
|
||||||
@@ -1197,8 +1344,6 @@ impl BucketTargetSys {
|
|||||||
return (tgt.arn.clone(), true);
|
return (tgt.arn.clone(), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !target.target_type.is_valid() {
|
if !target.target_type.is_valid() {
|
||||||
return (String::new(), false);
|
return (String::new(), false);
|
||||||
@@ -1224,6 +1369,7 @@ fn generate_arn(t: &BucketTarget, depl_id: &str) -> String {
|
|||||||
arn.to_string()
|
arn.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct RemoveObjectOptions {
|
pub struct RemoveObjectOptions {
|
||||||
pub force_delete: bool,
|
pub force_delete: bool,
|
||||||
pub governance_bypass: bool,
|
pub governance_bypass: bool,
|
||||||
@@ -1832,6 +1978,125 @@ impl TargetClient {
|
|||||||
.map_err(Box::new)
|
.map_err(Box::new)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Candidate replicas by content identity on a target that mints its own
|
||||||
|
/// version ids: page `ListObjectVersions` under the exact key and report
|
||||||
|
/// the live versions whose ETag matches `source_etag`, newest first.
|
||||||
|
/// Delete markers and prefix siblings never match. Bounded to
|
||||||
|
/// [`FIND_VERSION_BY_ETAG_MAX_PAGES`] pages and
|
||||||
|
/// [`FIND_VERSION_BY_ETAG_MAX_MATCHES`] candidates so a key with a very
|
||||||
|
/// deep history cannot turn one convergence check into an unbounded scan;
|
||||||
|
/// a replica beyond that window reads as missing, which only costs a
|
||||||
|
/// re-PUT (today's behaviour), never a lost object.
|
||||||
|
///
|
||||||
|
/// Content identity is not version identity: two source generations with
|
||||||
|
/// the same bytes have the same ETag. Callers drop the candidates other
|
||||||
|
/// source versions already claim through their ledgers and refuse an
|
||||||
|
/// [`ReplicaLocation::Ambiguous`] remainder before mutating or deleting.
|
||||||
|
pub async fn replica_candidates_by_etag(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
source_etag: &str,
|
||||||
|
) -> Result<Vec<String>, Box<SdkError<aws_sdk_s3::operation::list_object_versions::ListObjectVersionsError>>> {
|
||||||
|
let mut key_marker: Option<String> = None;
|
||||||
|
let mut version_id_marker: Option<String> = None;
|
||||||
|
let mut matches: Vec<String> = Vec::new();
|
||||||
|
for _ in 0..FIND_VERSION_BY_ETAG_MAX_PAGES {
|
||||||
|
let page = self
|
||||||
|
.client
|
||||||
|
.list_object_versions()
|
||||||
|
.bucket(bucket)
|
||||||
|
.prefix(object)
|
||||||
|
.max_keys(FIND_VERSION_BY_ETAG_PAGE_SIZE)
|
||||||
|
.set_key_marker(key_marker.take())
|
||||||
|
.set_version_id_marker(version_id_marker.take())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(Box::new)?;
|
||||||
|
matches.extend(
|
||||||
|
page.versions()
|
||||||
|
.iter()
|
||||||
|
.filter(|version| {
|
||||||
|
version.key() == Some(object)
|
||||||
|
&& version.version_id().is_some_and(|id| !id.is_empty())
|
||||||
|
&& replication_etags_match(Some(source_etag), version.e_tag())
|
||||||
|
})
|
||||||
|
.filter_map(|version| version.version_id().map(str::to_string)),
|
||||||
|
);
|
||||||
|
// A listing that moved past the exact key (every listed key is >=
|
||||||
|
// the prefix), ended, or already filled the candidate cap decides.
|
||||||
|
if matches.len() >= FIND_VERSION_BY_ETAG_MAX_MATCHES
|
||||||
|
|| page
|
||||||
|
.versions()
|
||||||
|
.iter()
|
||||||
|
.any(|version| version.key().is_some_and(|key| key > object))
|
||||||
|
|| !page.is_truncated().unwrap_or(false)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
key_marker = page.next_key_marker().map(str::to_string);
|
||||||
|
version_id_marker = page.next_version_id_marker().map(str::to_string);
|
||||||
|
if key_marker.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches.truncate(FIND_VERSION_BY_ETAG_MAX_MATCHES);
|
||||||
|
Ok(matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PutObjectRetention against a replica version on a target that does not
|
||||||
|
/// take retention through the replication PUT's own headers (it mints its
|
||||||
|
/// own version ids, so a re-PUT would create another version instead of
|
||||||
|
/// updating this one). Anti-loop marker always added.
|
||||||
|
pub async fn put_object_retention(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
version_id: Option<String>,
|
||||||
|
mode: ObjectLockRetentionMode,
|
||||||
|
retain_until: aws_sdk_s3::primitives::DateTime,
|
||||||
|
) -> Result<PutObjectRetentionOutput, Box<SdkError<PutObjectRetentionError>>> {
|
||||||
|
let headers = proxy_outbound_headers(HeaderMap::new());
|
||||||
|
self.client
|
||||||
|
.put_object_retention()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(object)
|
||||||
|
.set_version_id(resolve_read_api_version_id(version_id))
|
||||||
|
.retention(
|
||||||
|
ObjectLockRetention::builder()
|
||||||
|
.mode(mode)
|
||||||
|
.retain_until_date(retain_until)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.customize()
|
||||||
|
.map_request(move |req| apply_extra_headers(req, &headers))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(Box::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PutObjectLegalHold counterpart of [`Self::put_object_retention`].
|
||||||
|
pub async fn put_object_legal_hold(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
version_id: Option<String>,
|
||||||
|
status: ObjectLockLegalHoldStatus,
|
||||||
|
) -> Result<PutObjectLegalHoldOutput, Box<SdkError<PutObjectLegalHoldError>>> {
|
||||||
|
let headers = proxy_outbound_headers(HeaderMap::new());
|
||||||
|
self.client
|
||||||
|
.put_object_legal_hold()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(object)
|
||||||
|
.set_version_id(resolve_read_api_version_id(version_id))
|
||||||
|
.legal_hold(ObjectLockLegalHold::builder().status(status).build())
|
||||||
|
.customize()
|
||||||
|
.map_request(move |req| apply_extra_headers(req, &headers))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(Box::new)
|
||||||
|
}
|
||||||
|
|
||||||
/// HEAD used by the read-proxy path (GET/HEAD of an object not yet
|
/// HEAD used by the read-proxy path (GET/HEAD of an object not yet
|
||||||
/// replicated locally, MinIO `proxyHeadToRepTarget`).
|
/// replicated locally, MinIO `proxyHeadToRepTarget`).
|
||||||
///
|
///
|
||||||
@@ -2004,7 +2269,15 @@ impl TargetClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match builder
|
// A forwarded source checksum is this PUT's integrity header. In
|
||||||
|
// streaming-checksum mode (`RUSTFS_REPLICATION_STREAMING_CHECKSUMS`)
|
||||||
|
// the SDK would still add its default CRC32 trailer, and a target that
|
||||||
|
// receives both keeps the trailer's algorithm: a forwarded SHA256
|
||||||
|
// vanished from the replica while the source reported COMPLETED. Pin
|
||||||
|
// this request to WhenRequired so nothing is sent beside the source's
|
||||||
|
// own checksum.
|
||||||
|
let forwards_source_checksum = headers.keys().any(|name| name.as_str().starts_with("x-amz-checksum-"));
|
||||||
|
let mut operation = builder
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(object)
|
.key(object)
|
||||||
.content_length(size)
|
.content_length(size)
|
||||||
@@ -2024,10 +2297,14 @@ impl TargetClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
|
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
|
||||||
})
|
});
|
||||||
.send()
|
if forwards_source_checksum {
|
||||||
.await
|
operation = operation.config_override(
|
||||||
{
|
aws_sdk_s3::config::Builder::new()
|
||||||
|
.request_checksum_calculation(aws_sdk_s3::config::RequestChecksumCalculation::WhenRequired),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match operation.send().await {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
// Under SSE-KMS/DSSE or SSE-C the target's ETag is not the MD5
|
// Under SSE-KMS/DSSE or SSE-C the target's ETag is not the MD5
|
||||||
// of the stored plaintext, so it cannot be compared against the
|
// of the stored plaintext, so it cannot be compared against the
|
||||||
@@ -2271,11 +2548,57 @@ impl TargetClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a replica stands on a target that mints its own version ids, by
|
||||||
|
/// content identity (exact key + ETag) after the candidates other source
|
||||||
|
/// versions claim were removed. See
|
||||||
|
/// [`TargetClient::replica_candidates_by_etag`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ReplicaLocation {
|
||||||
|
/// No live version under the key carries the source ETag.
|
||||||
|
Missing,
|
||||||
|
/// Exactly one live version carries it: safe to address.
|
||||||
|
Unique(String),
|
||||||
|
/// More than one live version carries it (same bytes replicated for
|
||||||
|
/// several source generations). `newest` is the most recently listed
|
||||||
|
/// one — good enough to prove the replica exists, never good enough to
|
||||||
|
/// pick which one to mutate or delete.
|
||||||
|
Ambiguous { newest: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplicaLocation {
|
||||||
|
/// `matches` newest first, as the target listed them.
|
||||||
|
pub fn from_matches(mut matches: Vec<String>) -> Self {
|
||||||
|
match matches.len() {
|
||||||
|
0 => Self::Missing,
|
||||||
|
1 => Self::Unique(matches.remove(0)),
|
||||||
|
_ => Self::Ambiguous {
|
||||||
|
newest: matches.remove(0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version to read for existence/ETag checks, where an ambiguous
|
||||||
|
/// match is still a located replica.
|
||||||
|
pub fn any_version_id(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::Missing => None,
|
||||||
|
Self::Unique(version_id) | Self::Ambiguous { newest: version_id } => Some(version_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BucketTargetError {
|
pub enum BucketTargetError {
|
||||||
BucketRemoteTargetNotFound {
|
BucketRemoteTargetNotFound {
|
||||||
bucket: String,
|
bucket: String,
|
||||||
},
|
},
|
||||||
|
/// The bucket's persisted targets configuration exists but cannot be
|
||||||
|
/// decoded. Distinct from `BucketRemoteTargetNotFound`, which means the
|
||||||
|
/// bucket genuinely has no targets: callers must not degrade this one to
|
||||||
|
/// an empty target set (rustfs/backlog#2282).
|
||||||
|
BucketRemoteTargetsUnreadable {
|
||||||
|
bucket: String,
|
||||||
|
},
|
||||||
BucketRemoteArnTypeInvalid {
|
BucketRemoteArnTypeInvalid {
|
||||||
bucket: String,
|
bucket: String,
|
||||||
},
|
},
|
||||||
@@ -2309,6 +2632,9 @@ impl fmt::Display for BucketTargetError {
|
|||||||
BucketTargetError::BucketRemoteTargetNotFound { bucket } => {
|
BucketTargetError::BucketRemoteTargetNotFound { bucket } => {
|
||||||
write!(f, "Remote target not found for bucket: {bucket}")
|
write!(f, "Remote target not found for bucket: {bucket}")
|
||||||
}
|
}
|
||||||
|
BucketTargetError::BucketRemoteTargetsUnreadable { bucket } => {
|
||||||
|
write!(f, "Persisted replication target configuration is unreadable for bucket: {bucket}")
|
||||||
|
}
|
||||||
BucketTargetError::BucketRemoteArnTypeInvalid { bucket } => {
|
BucketTargetError::BucketRemoteArnTypeInvalid { bucket } => {
|
||||||
write!(f, "Invalid ARN type for bucket: {bucket}")
|
write!(f, "Invalid ARN type for bucket: {bucket}")
|
||||||
}
|
}
|
||||||
@@ -2437,13 +2763,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn header_recording_target_client(response_headers: Vec<(String, String)>) -> (TargetClient, RecordedHeaders) {
|
fn header_recording_target_client(response_headers: Vec<(String, String)>) -> (TargetClient, RecordedHeaders) {
|
||||||
|
header_recording_target_client_with_checksums(response_headers, replication_request_checksum_calculation())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_recording_target_client_with_checksums(
|
||||||
|
response_headers: Vec<(String, String)>,
|
||||||
|
checksums: RequestChecksumCalculation,
|
||||||
|
) -> (TargetClient, RecordedHeaders) {
|
||||||
let request_headers: RecordedHeaders = Arc::new(std::sync::Mutex::new(Vec::new()));
|
let request_headers: RecordedHeaders = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
let connector = SharedHttpConnector::new(RecordingHeaderConnector {
|
let connector = SharedHttpConnector::new(RecordingHeaderConnector {
|
||||||
request_headers: Arc::clone(&request_headers),
|
request_headers: Arc::clone(&request_headers),
|
||||||
response_headers,
|
response_headers,
|
||||||
});
|
});
|
||||||
let http_client = http_client_fn(move |_settings, _components| connector.clone());
|
let http_client = http_client_fn(move |_settings, _components| connector.clone());
|
||||||
let client = s3_client_for_test(443, Some(http_client));
|
let client =
|
||||||
|
s3_client_for_endpoint_test_with_checksums("https://localhost:443".to_string(), Some(http_client), checksums);
|
||||||
(
|
(
|
||||||
TargetClient {
|
TargetClient {
|
||||||
endpoint: "https://localhost:443".to_string(),
|
endpoint: "https://localhost:443".to_string(),
|
||||||
@@ -2610,6 +2944,47 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// With streaming checksums enabled the SDK adds a CRC32 trailer to every
|
||||||
|
/// upload. A PUT that forwards the source's checksum must not get that
|
||||||
|
/// second algorithm: a target that receives both keeps the trailer's and
|
||||||
|
/// the forwarded SHA256 never reaches the replica (rustfs/backlog#2340).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn streaming_put_object_with_forwarded_checksum_sends_no_sdk_checksum() {
|
||||||
|
let (client, recorded) =
|
||||||
|
header_recording_target_client_with_checksums(Vec::new(), RequestChecksumCalculation::WhenSupported);
|
||||||
|
let mut forwarded = PutObjectOptions::default();
|
||||||
|
forwarded.user_metadata.insert(
|
||||||
|
"x-amz-checksum-sha256".to_string(),
|
||||||
|
"OoJ3yNhRwv3wwtZoGqEIPrPX9xwTnfLl+ka0wStN1g0=".to_string(),
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.put_object("target-bucket", "object", 4, streaming_test_body(b"data"), &forwarded)
|
||||||
|
.await
|
||||||
|
.expect("recorded put_object should succeed");
|
||||||
|
client
|
||||||
|
.put_object("target-bucket", "object", 4, streaming_test_body(b"data"), &PutObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("recorded put_object should succeed");
|
||||||
|
let recorded = recorded.lock().expect("recorded header lock should not be poisoned");
|
||||||
|
let with_forwarded = &recorded[0];
|
||||||
|
assert_eq!(
|
||||||
|
recorded_header(with_forwarded, "x-amz-checksum-sha256"),
|
||||||
|
Some("OoJ3yNhRwv3wwtZoGqEIPrPX9xwTnfLl+ka0wStN1g0=")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
recorded_header(with_forwarded, "x-amz-trailer"),
|
||||||
|
None,
|
||||||
|
"the SDK must not add a trailer checksum"
|
||||||
|
);
|
||||||
|
assert_eq!(recorded_header(with_forwarded, "x-amz-sdk-checksum-algorithm"), None);
|
||||||
|
// Control: the same client still streams a trailer when nothing is forwarded.
|
||||||
|
let without_forwarded = &recorded[1];
|
||||||
|
assert!(
|
||||||
|
recorded_header(without_forwarded, "x-amz-trailer").is_some(),
|
||||||
|
"streaming mode must still apply to uploads without a forwarded checksum: {without_forwarded:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A forwarded source checksum already satisfies the rule; nothing is added.
|
/// A forwarded source checksum already satisfies the rule; nothing is added.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn locked_put_object_keeps_a_forwarded_source_checksum() {
|
async fn locked_put_object_keeps_a_forwarded_source_checksum() {
|
||||||
@@ -2975,6 +3350,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn s3_client_for_endpoint_test(endpoint: String, http_client: Option<SharedHttpClient>) -> S3Client {
|
fn s3_client_for_endpoint_test(endpoint: String, http_client: Option<SharedHttpClient>) -> S3Client {
|
||||||
|
s3_client_for_endpoint_test_with_checksums(endpoint, http_client, replication_request_checksum_calculation())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn s3_client_for_endpoint_test_with_checksums(
|
||||||
|
endpoint: String,
|
||||||
|
http_client: Option<SharedHttpClient>,
|
||||||
|
checksums: RequestChecksumCalculation,
|
||||||
|
) -> S3Client {
|
||||||
let credentials = SdkCredentials::builder()
|
let credentials = SdkCredentials::builder()
|
||||||
.access_key_id("test-access")
|
.access_key_id("test-access")
|
||||||
.secret_access_key("test-secret")
|
.secret_access_key("test-secret")
|
||||||
@@ -2988,7 +3371,7 @@ mod tests {
|
|||||||
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||||
// Mirror the production remote-target builder so recorded requests
|
// Mirror the production remote-target builder so recorded requests
|
||||||
// exercise the same checksum/framing behavior (#6853).
|
// exercise the same checksum/framing behavior (#6853).
|
||||||
.request_checksum_calculation(replication_request_checksum_calculation());
|
.request_checksum_calculation(checksums);
|
||||||
if let Some(http_client) = http_client {
|
if let Some(http_client) = http_client {
|
||||||
config = config.http_client(http_client);
|
config = config.http_client(http_client);
|
||||||
}
|
}
|
||||||
@@ -3151,6 +3534,64 @@ mod tests {
|
|||||||
assert!(message.contains("connection refused"));
|
assert!(message.contains("connection refused"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_replication_service_ignores_resync_and_bandwidth_edits() {
|
||||||
|
let base = BucketTarget {
|
||||||
|
endpoint: "target.example:9000".to_string(),
|
||||||
|
target_bucket: "replica".to_string(),
|
||||||
|
secure: true,
|
||||||
|
path: "on".to_string(),
|
||||||
|
arn: "arn:rustfs:replication:us-east-1:bucket:same".to_string(),
|
||||||
|
credentials: Some(Credentials {
|
||||||
|
access_key: "access".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let resync_edit = BucketTarget {
|
||||||
|
reset_id: "reset-1".to_string(),
|
||||||
|
bandwidth_limit: 1024,
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert!(same_replication_service(&resync_edit, &base));
|
||||||
|
for moved in [
|
||||||
|
BucketTarget {
|
||||||
|
endpoint: "other.example:9000".to_string(),
|
||||||
|
..base.clone()
|
||||||
|
},
|
||||||
|
BucketTarget {
|
||||||
|
target_bucket: "other".to_string(),
|
||||||
|
..base.clone()
|
||||||
|
},
|
||||||
|
BucketTarget {
|
||||||
|
secure: false,
|
||||||
|
..base.clone()
|
||||||
|
},
|
||||||
|
BucketTarget {
|
||||||
|
credentials: Some(Credentials {
|
||||||
|
access_key: "rotated".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..base.clone()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(!same_replication_service(&moved, &base));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_identity_verdict_is_per_arn_and_forgotten_with_the_target() {
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let arn = "arn:rustfs:replication:us-east-1:bucket:identity";
|
||||||
|
assert_eq!(sys.version_identity_capability(arn), VersionIdentityCapability::Unknown);
|
||||||
|
sys.record_version_identity_capability(arn, VersionIdentityCapability::MintsOwn);
|
||||||
|
assert_eq!(sys.version_identity_capability(arn), VersionIdentityCapability::MintsOwn);
|
||||||
|
assert_eq!(sys.version_identity_capability("other"), VersionIdentityCapability::Unknown);
|
||||||
|
// A rebuilt target may point at a different service.
|
||||||
|
sys.forget_version_identity_capability(arn);
|
||||||
|
assert_eq!(sys.version_identity_capability(arn), VersionIdentityCapability::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn endpoint_health_key_preserves_explicit_port() {
|
fn endpoint_health_key_preserves_explicit_port() {
|
||||||
let url = Url::parse("https://remote.example:9443").expect("url should parse");
|
let url = Url::parse("https://remote.example:9443").expect("url should parse");
|
||||||
@@ -3256,7 +3697,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
|
|
||||||
let targets = sys.list_targets("", "").await;
|
let targets = sys.list_targets("", "").await.expect("listing every bucket's targets");
|
||||||
|
|
||||||
assert_eq!(targets.len(), 1);
|
assert_eq!(targets.len(), 1);
|
||||||
assert!(!targets[0].online);
|
assert!(!targets[0].online);
|
||||||
@@ -4254,4 +4695,41 @@ mod tests {
|
|||||||
let window = LastMinuteLatency::new();
|
let window = LastMinuteLatency::new();
|
||||||
assert_eq!(window.get_total().avg, Duration::from_secs(0));
|
assert_eq!(window.get_total().avg, Duration::from_secs(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn repair_target(bucket: &str, id: &str) -> BucketTarget {
|
||||||
|
BucketTarget {
|
||||||
|
source_bucket: bucket.to_string(),
|
||||||
|
endpoint: "remote.example.com".to_string(),
|
||||||
|
target_bucket: "remote".to_string(),
|
||||||
|
arn: format!("arn:rustfs:replication:us-east-1:{bucket}:{id}"),
|
||||||
|
target_type: BucketTargetType::ReplicationService,
|
||||||
|
region: "us-east-1".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unreadable_target_set_refuses_cached_writes() {
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let bucket = "targets-repair-opt-in";
|
||||||
|
sys.mark_targets_unreadable(bucket).await;
|
||||||
|
assert!(matches!(
|
||||||
|
sys.targets_base_for_write(bucket).await,
|
||||||
|
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_readable_target_set_remains_the_write_base() {
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let bucket = "targets-repair-readable";
|
||||||
|
let existing = repair_target(bucket, "keep");
|
||||||
|
sys.targets_map
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(bucket.to_string(), vec![existing.clone()]);
|
||||||
|
let base = sys.targets_base_for_write(bucket).await.expect("read targets");
|
||||||
|
assert_eq!(base.targets.len(), 1);
|
||||||
|
assert_eq!(base.targets[0].arn, existing.arn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,21 @@ where
|
|||||||
com::read_config(api, file).await
|
com::read_config(api, file).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_config_limited_preserve_empty<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
|
||||||
|
where
|
||||||
|
S: ObjectIO<
|
||||||
|
Error = Error,
|
||||||
|
RangeSpec = HTTPRangeSpec,
|
||||||
|
HeaderMap = HeaderMap,
|
||||||
|
ObjectOptions = ObjectOptions,
|
||||||
|
ObjectInfo = ObjectInfo,
|
||||||
|
GetObjectReader = GetObjectReader,
|
||||||
|
PutObjectReader = PutObjReader,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
com::read_config_limited_preserve_empty(api, file, max_bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn read_config_with_metadata<S>(api: Arc<S>, file: &str, opts: &ObjectOptions) -> Result<(Vec<u8>, ObjectInfo)>
|
pub(crate) async fn read_config_with_metadata<S>(api: Arc<S>, file: &str, opts: &ObjectOptions) -> Result<(Vec<u8>, ObjectInfo)>
|
||||||
where
|
where
|
||||||
S: ObjectIO<
|
S: ObjectIO<
|
||||||
@@ -56,6 +71,26 @@ where
|
|||||||
com::read_config_with_metadata(api, file, opts).await
|
com::read_config_with_metadata(api, file, opts).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_config_limited_preserve_empty_with_metadata<S>(
|
||||||
|
api: Arc<S>,
|
||||||
|
file: &str,
|
||||||
|
opts: &ObjectOptions,
|
||||||
|
max_bytes: usize,
|
||||||
|
) -> Result<(Vec<u8>, ObjectInfo)>
|
||||||
|
where
|
||||||
|
S: ObjectIO<
|
||||||
|
Error = Error,
|
||||||
|
RangeSpec = HTTPRangeSpec,
|
||||||
|
HeaderMap = HeaderMap,
|
||||||
|
ObjectOptions = ObjectOptions,
|
||||||
|
ObjectInfo = ObjectInfo,
|
||||||
|
GetObjectReader = GetObjectReader,
|
||||||
|
PutObjectReader = PutObjReader,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
com::read_config_limited_preserve_empty_with_metadata_opts(api, file, opts, max_bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn save_config<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
pub(crate) async fn save_config<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
||||||
where
|
where
|
||||||
S: ObjectIO<
|
S: ObjectIO<
|
||||||
@@ -126,20 +161,30 @@ where
|
|||||||
DeletedObject = DeletedObject,
|
DeletedObject = DeletedObject,
|
||||||
>,
|
>,
|
||||||
{
|
{
|
||||||
match api
|
delete_config_if_match_with_opts(api, file, etag, ObjectOptions::default()).await
|
||||||
.delete_object(
|
}
|
||||||
RUSTFS_META_BUCKET,
|
|
||||||
file,
|
pub(crate) async fn delete_config_if_match_with_opts<S>(
|
||||||
ObjectOptions {
|
api: Arc<S>,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
file: &str,
|
||||||
|
etag: &str,
|
||||||
|
mut options: ObjectOptions,
|
||||||
|
) -> Result<()>
|
||||||
|
where
|
||||||
|
S: ObjectOperations<
|
||||||
|
Error = Error,
|
||||||
|
ObjectInfo = ObjectInfo,
|
||||||
|
ObjectOptions = ObjectOptions,
|
||||||
|
FileInfo = FileInfo,
|
||||||
|
ObjectToDelete = ObjectToDelete,
|
||||||
|
DeletedObject = DeletedObject,
|
||||||
|
>,
|
||||||
|
{
|
||||||
|
options.http_preconditions = Some(HTTPPreconditions {
|
||||||
if_match: Some(etag.to_string()),
|
if_match: Some(etag.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
});
|
||||||
..Default::default()
|
match api.delete_object(RUSTFS_META_BUCKET, file, options).await {
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
|
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
use crate::object_api::ObjectInfo;
|
use crate::object_api::ObjectInfo;
|
||||||
|
|
||||||
pub use rustfs_lifecycle::{
|
pub use rustfs_lifecycle::{
|
||||||
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate, TRANSITION_COMPLETE,
|
Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate, ObjectOpts,
|
||||||
TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due, expected_expiry_time,
|
RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due,
|
||||||
expiration_action_has_valid_target,
|
expected_expiry_time, expiration_action_has_valid_target,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts {
|
pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1170,6 +1170,7 @@ pub async fn save_manual_transition_job_record_if_current(
|
|||||||
data.clone(),
|
data.clone(),
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_match: Some(current_etag.to_string()),
|
if_match: Some(current_etag.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1242,6 +1243,7 @@ pub(crate) async fn save_manual_transition_worker_result_if_absent(
|
|||||||
data,
|
data,
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_none_match: Some("*".to_string()),
|
if_none_match: Some("*".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1270,6 +1272,7 @@ pub(crate) async fn save_manual_transition_task_if_absent(
|
|||||||
data,
|
data,
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_none_match: Some("*".to_string()),
|
if_none_match: Some("*".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1621,6 +1624,7 @@ pub async fn save_manual_transition_scope_admission_if_absent(
|
|||||||
data.clone(),
|
data.clone(),
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_none_match: Some("*".to_string()),
|
if_none_match: Some("*".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1672,6 +1676,7 @@ pub async fn save_manual_transition_scope_admission_if_current(
|
|||||||
data.clone(),
|
data.clone(),
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_match: Some(current_etag.to_string()),
|
if_match: Some(current_etag.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs, g
|
|||||||
mod object_handlers_common;
|
mod object_handlers_common;
|
||||||
mod object_lock_boundary;
|
mod object_lock_boundary;
|
||||||
pub use self::core as lifecycle;
|
pub use self::core as lifecycle;
|
||||||
|
pub mod recovery_control;
|
||||||
|
pub mod recovery_disposition;
|
||||||
|
pub(crate) mod recovery_disposition_runtime;
|
||||||
|
pub mod recovery_export;
|
||||||
mod replication_sink;
|
mod replication_sink;
|
||||||
pub mod rule;
|
pub mod rule;
|
||||||
mod runtime_boundary;
|
mod runtime_boundary;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,840 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use std::{collections::HashSet, sync::Arc};
|
||||||
|
|
||||||
|
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::config_boundary;
|
||||||
|
use super::recovery_control::{
|
||||||
|
IlmRecoveryClassification, IlmRecoveryControl, IlmRecoveryProtocol, IlmRecoverySourceCopy, IlmRecoverySourceGeneration,
|
||||||
|
MAX_ILM_RECOVERY_CONTROL_SIZE, ObservedIlmRecoveryControl, ObservedIlmRecoverySource, recovery_control_record_object_name,
|
||||||
|
};
|
||||||
|
use super::tier_delete_journal::{
|
||||||
|
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, validate_legacy_tier_delete_recovery_source,
|
||||||
|
};
|
||||||
|
use crate::disk::RUSTFS_META_BUCKET;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::object_api::{ObjectOptions, WriteCompletion};
|
||||||
|
use crate::services::notification_sys::{
|
||||||
|
acquire_ilm_recovery_export_fleet_proof, ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_member_epochs_sha256,
|
||||||
|
ilm_recovery_export_topology_generation,
|
||||||
|
};
|
||||||
|
use crate::storage_api_contracts::{list::ListOperations as _, namespace::NamespaceLocking as _, object::HTTPPreconditions};
|
||||||
|
use crate::store::ECStore;
|
||||||
|
|
||||||
|
pub const ILM_RECOVERY_EXPORT_SCHEMA: &str = "rustfs-ilm-recovery-export-v1";
|
||||||
|
pub const ILM_RECOVERY_EXPORT_PREFIX: &str = "ilm/recovery-exports";
|
||||||
|
pub const MAX_ILM_RECOVERY_EXPORT_SIZE: usize = 128 * 1024;
|
||||||
|
const MAX_ILM_RECOVERY_EXPORTS: usize = 10_000;
|
||||||
|
const MAX_ILM_RECOVERY_EXPORT_BYTES: u64 = 1024 * 1024 * 1024;
|
||||||
|
const MAX_ACTOR_EXPORTS_PER_MINUTE: usize = 10;
|
||||||
|
const MAX_CLUSTER_EXPORTS_PER_MINUTE: usize = 100;
|
||||||
|
const EXPORT_RETENTION_NANOS: i64 = 90 * 24 * 60 * 60 * 1_000_000_000;
|
||||||
|
const EXPORT_ADMISSION_LOCK: &str = "ilm/recovery-admission/export.lock";
|
||||||
|
const MAX_LEGACY_TIER_DELETE_SOURCE_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct IlmRecoveryExportObservation {
|
||||||
|
pub control_id: String,
|
||||||
|
pub protocol: IlmRecoveryProtocol,
|
||||||
|
pub control_etag: String,
|
||||||
|
pub control_revision: u64,
|
||||||
|
pub classification: IlmRecoveryClassification,
|
||||||
|
pub canonical_source_path: String,
|
||||||
|
pub source_generation: IlmRecoverySourceGeneration,
|
||||||
|
pub topology_generation: String,
|
||||||
|
pub member_epochs_sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct IlmRecoveryExport {
|
||||||
|
pub export_id: String,
|
||||||
|
pub control_id: String,
|
||||||
|
pub protocol: IlmRecoveryProtocol,
|
||||||
|
pub control_etag: String,
|
||||||
|
pub control_revision: u64,
|
||||||
|
pub classification: IlmRecoveryClassification,
|
||||||
|
pub canonical_source_path: String,
|
||||||
|
pub source_generation: IlmRecoverySourceGeneration,
|
||||||
|
pub topology_generation: String,
|
||||||
|
pub member_epochs_sha256: String,
|
||||||
|
pub creator_sha256: String,
|
||||||
|
pub created_at_unix_nanos: i64,
|
||||||
|
pub retain_until_unix_nanos: i64,
|
||||||
|
pub source_bytes_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct PersistedIlmRecoveryExport {
|
||||||
|
schema: String,
|
||||||
|
content_sha256: String,
|
||||||
|
export: IlmRecoveryExport,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct IlmRecoveryExportCreated {
|
||||||
|
pub export_id: String,
|
||||||
|
pub content_sha256: String,
|
||||||
|
pub encoded: Vec<u8>,
|
||||||
|
pub replayed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IlmRecoveryExport {
|
||||||
|
fn validate(&self) -> Result<()> {
|
||||||
|
self.source_generation.validate().map_err(Error::other)?;
|
||||||
|
validate_sha256(&self.export_id, "ILM recovery export ID is invalid")?;
|
||||||
|
validate_sha256(&self.control_id, "ILM recovery export control ID is invalid")?;
|
||||||
|
validate_sha256(&self.topology_generation, "ILM recovery export topology generation is invalid")?;
|
||||||
|
validate_sha256(&self.member_epochs_sha256, "ILM recovery export member epoch digest is invalid")?;
|
||||||
|
validate_sha256(&self.creator_sha256, "ILM recovery export creator digest is invalid")?;
|
||||||
|
if self.protocol != IlmRecoveryProtocol::TierDeleteJournal
|
||||||
|
|| self.classification != IlmRecoveryClassification::RetainedAmbiguous
|
||||||
|
|| !is_legacy_export_schema(&self.source_generation.source_schema)
|
||||||
|
{
|
||||||
|
return Err(Error::other("ILM recovery export source is not an exportable legacy journal"));
|
||||||
|
}
|
||||||
|
if self.control_etag.trim().is_empty() || self.control_revision == 0 {
|
||||||
|
return Err(Error::other("ILM recovery export control generation is invalid"));
|
||||||
|
}
|
||||||
|
if self.canonical_source_path.is_empty()
|
||||||
|
|| self.canonical_source_path.starts_with('/')
|
||||||
|
|| self.canonical_source_path.ends_with('/')
|
||||||
|
|| self.canonical_source_path.split('/').any(str::is_empty)
|
||||||
|
{
|
||||||
|
return Err(Error::other("ILM recovery export source path is invalid"));
|
||||||
|
}
|
||||||
|
if self.created_at_unix_nanos <= 0
|
||||||
|
|| self.retain_until_unix_nanos < self.created_at_unix_nanos.saturating_add(EXPORT_RETENTION_NANOS)
|
||||||
|
{
|
||||||
|
return Err(Error::other("ILM recovery export retention is invalid"));
|
||||||
|
}
|
||||||
|
let source = base64_simd::STANDARD
|
||||||
|
.decode_to_vec(self.source_bytes_base64.as_bytes())
|
||||||
|
.map_err(|_| Error::other("ILM recovery export source encoding is invalid"))?;
|
||||||
|
validate_legacy_tier_delete_recovery_source(&self.canonical_source_path, &self.source_generation.source_schema, &source)?;
|
||||||
|
let encoded_len = u64::try_from(source.len()).map_err(|_| Error::other("ILM recovery export source length overflow"))?;
|
||||||
|
if source.is_empty()
|
||||||
|
|| source.len() > MAX_LEGACY_TIER_DELETE_SOURCE_SIZE
|
||||||
|
|| hex_sha256(&source, ToOwned::to_owned) != self.source_generation.content_sha256
|
||||||
|
|| self.source_generation.copies.iter().any(|copy| {
|
||||||
|
copy.canonical_path != self.canonical_source_path
|
||||||
|
|| copy.etag != self.source_generation.source_etag
|
||||||
|
|| copy.content_sha256 != self.source_generation.content_sha256
|
||||||
|
|| copy.encoded_len != encoded_len
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(Error::other("ILM recovery export source bytes do not match the observed generation"));
|
||||||
|
}
|
||||||
|
if recovery_export_id(&self.control_id, &self.source_generation)? != self.export_id {
|
||||||
|
return Err(Error::other("ILM recovery export ID does not match its source generation"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(&self) -> Result<Vec<u8>> {
|
||||||
|
self.validate()?;
|
||||||
|
let export_bytes = serde_json::to_vec(self).map_err(Error::other)?;
|
||||||
|
let persisted = PersistedIlmRecoveryExport {
|
||||||
|
schema: ILM_RECOVERY_EXPORT_SCHEMA.to_string(),
|
||||||
|
content_sha256: hex_sha256(&export_bytes, ToOwned::to_owned),
|
||||||
|
export: self.clone(),
|
||||||
|
};
|
||||||
|
let encoded = serde_json::to_vec(&persisted).map_err(Error::other)?;
|
||||||
|
if encoded.len() > MAX_ILM_RECOVERY_EXPORT_SIZE {
|
||||||
|
return Err(Error::other("encoded ILM recovery export exceeds maximum size"));
|
||||||
|
}
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(expected_export_id: &str, data: &[u8]) -> Result<Self> {
|
||||||
|
validate_sha256(expected_export_id, "ILM recovery export ID is invalid")?;
|
||||||
|
if data.len() > MAX_ILM_RECOVERY_EXPORT_SIZE {
|
||||||
|
return Err(Error::other("encoded ILM recovery export exceeds maximum size"));
|
||||||
|
}
|
||||||
|
let persisted: PersistedIlmRecoveryExport = serde_json::from_slice(data).map_err(Error::other)?;
|
||||||
|
if persisted.schema != ILM_RECOVERY_EXPORT_SCHEMA {
|
||||||
|
return Err(Error::other("ILM recovery export schema is unsupported"));
|
||||||
|
}
|
||||||
|
validate_sha256(&persisted.content_sha256, "ILM recovery export checksum is invalid")?;
|
||||||
|
let export_bytes = serde_json::to_vec(&persisted.export).map_err(Error::other)?;
|
||||||
|
if hex_sha256(&export_bytes, ToOwned::to_owned) != persisted.content_sha256 {
|
||||||
|
return Err(Error::other("ILM recovery export checksum mismatch"));
|
||||||
|
}
|
||||||
|
persisted.export.validate()?;
|
||||||
|
if persisted.export.export_id != expected_export_id {
|
||||||
|
return Err(Error::other("ILM recovery export ID does not match record key"));
|
||||||
|
}
|
||||||
|
Ok(persisted.export)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recovery_export_record_object_name(protocol: IlmRecoveryProtocol, export_id: &str) -> Result<String> {
|
||||||
|
validate_sha256(export_id, "ILM recovery export ID is invalid")?;
|
||||||
|
Ok(format!(
|
||||||
|
"{}/{}/{}/{}/{}.json",
|
||||||
|
ILM_RECOVERY_EXPORT_PREFIX,
|
||||||
|
protocol.as_str(),
|
||||||
|
&export_id[..2],
|
||||||
|
&export_id[2..4],
|
||||||
|
export_id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recovery_export_id_from_record_object_name(object: &str) -> Result<(IlmRecoveryProtocol, String)> {
|
||||||
|
let suffix = object
|
||||||
|
.strip_prefix(ILM_RECOVERY_EXPORT_PREFIX)
|
||||||
|
.and_then(|suffix| suffix.strip_prefix('/'))
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export path has wrong prefix"))?;
|
||||||
|
let mut parts = suffix.split('/');
|
||||||
|
let protocol = match parts.next() {
|
||||||
|
Some("tier_delete_journal") => IlmRecoveryProtocol::TierDeleteJournal,
|
||||||
|
_ => return Err(Error::other("ILM recovery export protocol is invalid")),
|
||||||
|
};
|
||||||
|
let shard_a = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export path is incomplete"))?;
|
||||||
|
let shard_b = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export path is incomplete"))?;
|
||||||
|
let export_id = parts
|
||||||
|
.next()
|
||||||
|
.and_then(|name| name.strip_suffix(".json"))
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export suffix is invalid"))?;
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Err(Error::other("ILM recovery export path is not canonical"));
|
||||||
|
}
|
||||||
|
validate_sha256(export_id, "ILM recovery export ID is invalid")?;
|
||||||
|
if shard_a != &export_id[..2] || shard_b != &export_id[2..4] {
|
||||||
|
return Err(Error::other("ILM recovery export shard does not match export ID"));
|
||||||
|
}
|
||||||
|
Ok((protocol, export_id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn inspect_recovery_export_observation(api: Arc<ECStore>, control_id: &str) -> Result<IlmRecoveryExportObservation> {
|
||||||
|
let proof = acquire_ilm_recovery_export_fleet_proof()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export fleet proof is unavailable"))?;
|
||||||
|
let observed_control = load_exportable_control(api.clone(), control_id).await?;
|
||||||
|
let observed_source = observe_export_source(
|
||||||
|
api,
|
||||||
|
&observed_control.control.identity.canonical_source_path,
|
||||||
|
&observed_control.control.observed_source_generation.source_schema,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !observed_source.is_consistent()
|
||||||
|
|| observed_source.generation != observed_control.control.observed_source_generation
|
||||||
|
|| !ilm_recovery_export_fleet_proof_matches(&proof).await
|
||||||
|
{
|
||||||
|
return Err(Error::other("ILM recovery export observation changed or is incomplete"));
|
||||||
|
}
|
||||||
|
Ok(IlmRecoveryExportObservation {
|
||||||
|
control_id: control_id.to_string(),
|
||||||
|
protocol: observed_control.control.identity.protocol,
|
||||||
|
control_etag: observed_control.etag,
|
||||||
|
control_revision: observed_control.control.revision,
|
||||||
|
classification: observed_control.control.classification,
|
||||||
|
canonical_source_path: observed_control.control.identity.canonical_source_path,
|
||||||
|
source_generation: observed_source.generation,
|
||||||
|
topology_generation: ilm_recovery_export_topology_generation(&proof),
|
||||||
|
member_epochs_sha256: ilm_recovery_export_member_epochs_sha256(&proof),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_recovery_export(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
observation: &IlmRecoveryExportObservation,
|
||||||
|
creator_sha256: &str,
|
||||||
|
) -> Result<IlmRecoveryExportCreated> {
|
||||||
|
validate_sha256(creator_sha256, "ILM recovery export creator digest is invalid")?;
|
||||||
|
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, EXPORT_ADMISSION_LOCK).await?;
|
||||||
|
let admission_guard = lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||||
|
|
||||||
|
let proof = acquire_ilm_recovery_export_fleet_proof()
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export fleet proof is unavailable"))?;
|
||||||
|
if ilm_recovery_export_topology_generation(&proof) != observation.topology_generation
|
||||||
|
|| ilm_recovery_export_member_epochs_sha256(&proof) != observation.member_epochs_sha256
|
||||||
|
{
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
let control_object = recovery_control_record_object_name(IlmRecoveryProtocol::TierDeleteJournal, &observation.control_id)
|
||||||
|
.map_err(Error::other)?;
|
||||||
|
let control_lock = api.new_ns_lock(RUSTFS_META_BUCKET, &control_object).await?;
|
||||||
|
let control_guard = control_lock
|
||||||
|
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||||
|
.await?;
|
||||||
|
let source_lock = api
|
||||||
|
.new_ns_lock(RUSTFS_META_BUCKET, &observation.canonical_source_path)
|
||||||
|
.await?;
|
||||||
|
let source_guard = source_lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||||
|
let locks_current = || !admission_guard.is_lock_lost() && !control_guard.is_lock_lost() && !source_guard.is_lock_lost();
|
||||||
|
let (current, current_source_bytes) = current_observation_under_proof_no_lock(api.clone(), observation, &proof).await?;
|
||||||
|
if ¤t != observation || !locks_current() {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
let current_source_base64 = base64_simd::STANDARD.encode_to_string(current_source_bytes);
|
||||||
|
let candidate_export_id = recovery_export_id(¤t.control_id, ¤t.source_generation)?;
|
||||||
|
let object = recovery_export_record_object_name(current.protocol, &candidate_export_id)?;
|
||||||
|
match load_recovery_export_decoded(api.clone(), &candidate_export_id).await {
|
||||||
|
Ok((existing, export)) if export_matches_observation(&export, observation) => {
|
||||||
|
if !locks_current() || !ilm_recovery_export_fleet_proof_matches(&proof).await {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
api.record_durable_ilm_decommission_progress(&object, &existing.encoded)
|
||||||
|
.await?;
|
||||||
|
if !locks_current() {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
return Ok(existing.with_replayed());
|
||||||
|
}
|
||||||
|
Ok(_) => return Err(Error::PreconditionFailed),
|
||||||
|
Err(Error::ConfigNotFound) => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
let inventory = collect_export_inventory(api.clone()).await?;
|
||||||
|
if !locks_current() || !ilm_recovery_export_fleet_proof_matches(&proof).await {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
let created_at_unix_nanos = now_unix_nanos()?;
|
||||||
|
let export = build_export_from_source(¤t, creator_sha256, created_at_unix_nanos, ¤t_source_base64)?;
|
||||||
|
let encoded = export.encode()?;
|
||||||
|
inventory.check(creator_sha256, encoded.len(), created_at_unix_nanos)?;
|
||||||
|
|
||||||
|
let mut write_options = ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
write_completion: WriteCompletion::TailDrained,
|
||||||
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
write_options.add_namespace_lock_guard(&admission_guard);
|
||||||
|
write_options.add_namespace_lock_guard(&control_guard);
|
||||||
|
write_options.add_namespace_lock_guard(&source_guard);
|
||||||
|
if !locks_current() {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
let write_result = config_boundary::save_config_with_opts(api.clone(), &object, encoded.clone(), &write_options).await;
|
||||||
|
let stored = match load_recovery_export(api.clone(), &export.export_id).await {
|
||||||
|
Ok(stored) if stored.encoded == encoded => stored,
|
||||||
|
Ok(_) => return Err(Error::PreconditionFailed),
|
||||||
|
Err(read_err) => return Err(write_result.err().unwrap_or(read_err)),
|
||||||
|
};
|
||||||
|
if !locks_current() || !ilm_recovery_export_fleet_proof_matches(&proof).await {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
api.record_durable_ilm_decommission_progress(&object, &encoded).await?;
|
||||||
|
if !locks_current() {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
Ok(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_recovery_export(api: Arc<ECStore>, export_id: &str) -> Result<IlmRecoveryExportCreated> {
|
||||||
|
let (created, _) = load_recovery_export_decoded(api, export_id).await?;
|
||||||
|
Ok(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_recovery_export_decoded(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
export_id: &str,
|
||||||
|
) -> Result<(IlmRecoveryExportCreated, IlmRecoveryExport)> {
|
||||||
|
let object = recovery_export_record_object_name(IlmRecoveryProtocol::TierDeleteJournal, export_id)?;
|
||||||
|
let encoded = config_boundary::read_config_limited_preserve_empty(api, &object, MAX_ILM_RECOVERY_EXPORT_SIZE).await?;
|
||||||
|
let export = IlmRecoveryExport::decode(export_id, &encoded)?;
|
||||||
|
let content_sha256 = hex_sha256(&encoded, ToOwned::to_owned);
|
||||||
|
Ok((
|
||||||
|
IlmRecoveryExportCreated {
|
||||||
|
export_id: export.export_id.clone(),
|
||||||
|
content_sha256,
|
||||||
|
encoded,
|
||||||
|
replayed: false,
|
||||||
|
},
|
||||||
|
export,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IlmRecoveryExportCreated {
|
||||||
|
fn with_replayed(mut self) -> Self {
|
||||||
|
self.replayed = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_exportable_control(api: Arc<ECStore>, control_id: &str) -> Result<ObservedIlmRecoveryControl> {
|
||||||
|
load_exportable_control_with_options(api, control_id, &ObjectOptions::default()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_exportable_control_no_lock(api: Arc<ECStore>, control_id: &str) -> Result<ObservedIlmRecoveryControl> {
|
||||||
|
load_exportable_control_with_options(
|
||||||
|
api,
|
||||||
|
control_id,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_exportable_control_with_options(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
control_id: &str,
|
||||||
|
options: &ObjectOptions,
|
||||||
|
) -> Result<ObservedIlmRecoveryControl> {
|
||||||
|
let object = recovery_control_record_object_name(IlmRecoveryProtocol::TierDeleteJournal, control_id).map_err(Error::other)?;
|
||||||
|
let (data, metadata) =
|
||||||
|
config_boundary::read_config_limited_preserve_empty_with_metadata(api, &object, options, MAX_ILM_RECOVERY_CONTROL_SIZE)
|
||||||
|
.await?;
|
||||||
|
let etag = metadata
|
||||||
|
.etag
|
||||||
|
.filter(|etag| !etag.trim().is_empty())
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery control is missing an ETag"))?;
|
||||||
|
let control = IlmRecoveryControl::decode(control_id, &data).map_err(Error::other)?;
|
||||||
|
if control.identity.protocol != IlmRecoveryProtocol::TierDeleteJournal
|
||||||
|
|| control.classification != IlmRecoveryClassification::RetainedAmbiguous
|
||||||
|
|| !is_legacy_export_schema(&control.observed_source_generation.source_schema)
|
||||||
|
{
|
||||||
|
return Err(Error::other("ILM recovery control is not exportable"));
|
||||||
|
}
|
||||||
|
Ok(ObservedIlmRecoveryControl { control, etag })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn current_observation_under_proof_no_lock(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
expected: &IlmRecoveryExportObservation,
|
||||||
|
proof: &crate::services::notification_sys::IlmRecoveryExportFleetProofToken,
|
||||||
|
) -> Result<(IlmRecoveryExportObservation, Vec<u8>)> {
|
||||||
|
let observed_control = load_exportable_control_no_lock(api.clone(), &expected.control_id).await?;
|
||||||
|
let observed_source = observe_export_source_no_lock(
|
||||||
|
api,
|
||||||
|
&observed_control.control.identity.canonical_source_path,
|
||||||
|
&observed_control.control.observed_source_generation.source_schema,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let source_bytes = observed_source
|
||||||
|
.canonical_data
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export source copies diverge"))?;
|
||||||
|
if !observed_source.is_consistent()
|
||||||
|
|| observed_source.generation != observed_control.control.observed_source_generation
|
||||||
|
|| !ilm_recovery_export_fleet_proof_matches(proof).await
|
||||||
|
{
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
IlmRecoveryExportObservation {
|
||||||
|
control_id: expected.control_id.clone(),
|
||||||
|
protocol: observed_control.control.identity.protocol,
|
||||||
|
control_etag: observed_control.etag,
|
||||||
|
control_revision: observed_control.control.revision,
|
||||||
|
classification: observed_control.control.classification,
|
||||||
|
canonical_source_path: observed_control.control.identity.canonical_source_path,
|
||||||
|
source_generation: observed_source.generation,
|
||||||
|
topology_generation: ilm_recovery_export_topology_generation(proof),
|
||||||
|
member_epochs_sha256: ilm_recovery_export_member_epochs_sha256(proof),
|
||||||
|
},
|
||||||
|
source_bytes,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn observe_export_source(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
canonical_path: &str,
|
||||||
|
source_schema: &str,
|
||||||
|
) -> Result<ObservedIlmRecoverySource> {
|
||||||
|
if canonical_path.is_empty()
|
||||||
|
|| canonical_path.starts_with('/')
|
||||||
|
|| canonical_path.ends_with('/')
|
||||||
|
|| canonical_path.split('/').any(str::is_empty)
|
||||||
|
|| !is_legacy_export_schema(source_schema)
|
||||||
|
{
|
||||||
|
return Err(Error::other("ILM recovery export source identity is invalid"));
|
||||||
|
}
|
||||||
|
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, canonical_path).await?;
|
||||||
|
let _guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||||
|
observe_export_source_no_lock(api, canonical_path, source_schema).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn observe_export_source_no_lock(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
canonical_path: &str,
|
||||||
|
source_schema: &str,
|
||||||
|
) -> Result<ObservedIlmRecoverySource> {
|
||||||
|
let mut copies = Vec::new();
|
||||||
|
let mut canonical: Option<(String, String, Vec<u8>)> = None;
|
||||||
|
let mut consistent = true;
|
||||||
|
for set in api.all_set_disks() {
|
||||||
|
let authority = format!("pool-{}/set-{}", set.pool_index, set.set_index);
|
||||||
|
let result = config_boundary::read_config_limited_preserve_empty_with_metadata(
|
||||||
|
set,
|
||||||
|
canonical_path,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
MAX_LEGACY_TIER_DELETE_SOURCE_SIZE,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok((data, metadata)) => {
|
||||||
|
if data.is_empty() || data.len() > MAX_LEGACY_TIER_DELETE_SOURCE_SIZE {
|
||||||
|
return Err(Error::other("ILM recovery export source exceeds its protocol size limit"));
|
||||||
|
}
|
||||||
|
validate_legacy_tier_delete_recovery_source(canonical_path, source_schema, &data)?;
|
||||||
|
let etag = metadata
|
||||||
|
.etag
|
||||||
|
.filter(|etag| !etag.trim().is_empty())
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export source copy is missing an ETag"))?;
|
||||||
|
let content_sha256 = hex_sha256(&data, ToOwned::to_owned);
|
||||||
|
let encoded_len =
|
||||||
|
u64::try_from(data.len()).map_err(|_| Error::other("ILM recovery export source length does not fit u64"))?;
|
||||||
|
copies.push(IlmRecoverySourceCopy {
|
||||||
|
authority,
|
||||||
|
canonical_path: canonical_path.to_string(),
|
||||||
|
etag: etag.clone(),
|
||||||
|
encoded_len,
|
||||||
|
content_sha256: content_sha256.clone(),
|
||||||
|
});
|
||||||
|
match canonical.as_ref() {
|
||||||
|
Some((first_etag, first_digest, first_data)) => {
|
||||||
|
consistent &= first_etag == &etag && first_digest == &content_sha256 && first_data == &data;
|
||||||
|
}
|
||||||
|
None => canonical = Some((etag, content_sha256, data)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) if export_source_is_missing(&err) => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some((source_etag, content_sha256, source_bytes)) = canonical else {
|
||||||
|
return Err(Error::ConfigNotFound);
|
||||||
|
};
|
||||||
|
let generation =
|
||||||
|
IlmRecoverySourceGeneration::new(source_schema, source_etag, content_sha256, copies).map_err(Error::other)?;
|
||||||
|
Ok(ObservedIlmRecoverySource {
|
||||||
|
generation,
|
||||||
|
canonical_data: consistent.then_some(source_bytes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_source_is_missing(err: &Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
Error::ConfigNotFound | Error::FileNotFound | Error::ObjectNotFound(_, _) | Error::VersionNotFound(_, _, _)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_export_from_source(
|
||||||
|
observation: &IlmRecoveryExportObservation,
|
||||||
|
creator_sha256: &str,
|
||||||
|
created_at_unix_nanos: i64,
|
||||||
|
source_bytes_base64: &str,
|
||||||
|
) -> Result<IlmRecoveryExport> {
|
||||||
|
let retain_until_unix_nanos = created_at_unix_nanos
|
||||||
|
.checked_add(EXPORT_RETENTION_NANOS)
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export retention timestamp overflow"))?;
|
||||||
|
let export = IlmRecoveryExport {
|
||||||
|
export_id: recovery_export_id(&observation.control_id, &observation.source_generation)?,
|
||||||
|
control_id: observation.control_id.clone(),
|
||||||
|
protocol: observation.protocol,
|
||||||
|
control_etag: observation.control_etag.clone(),
|
||||||
|
control_revision: observation.control_revision,
|
||||||
|
classification: observation.classification,
|
||||||
|
canonical_source_path: observation.canonical_source_path.clone(),
|
||||||
|
source_generation: observation.source_generation.clone(),
|
||||||
|
topology_generation: observation.topology_generation.clone(),
|
||||||
|
member_epochs_sha256: observation.member_epochs_sha256.clone(),
|
||||||
|
creator_sha256: creator_sha256.to_string(),
|
||||||
|
created_at_unix_nanos,
|
||||||
|
retain_until_unix_nanos,
|
||||||
|
source_bytes_base64: source_bytes_base64.to_string(),
|
||||||
|
};
|
||||||
|
export.validate()?;
|
||||||
|
Ok(export)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn recovery_export_id(control_id: &str, generation: &IlmRecoverySourceGeneration) -> Result<String> {
|
||||||
|
validate_sha256(control_id, "ILM recovery export control ID is invalid")?;
|
||||||
|
validate_sha256(&generation.content_sha256, "ILM recovery export source checksum is invalid")?;
|
||||||
|
validate_sha256(&generation.copy_set_sha256, "ILM recovery export copy-set checksum is invalid")?;
|
||||||
|
let mut data = Vec::new();
|
||||||
|
for part in [control_id, &generation.content_sha256, &generation.copy_set_sha256] {
|
||||||
|
data.extend_from_slice(&(part.len() as u64).to_be_bytes());
|
||||||
|
data.extend_from_slice(part.as_bytes());
|
||||||
|
}
|
||||||
|
Ok(hex_sha256(&data, ToOwned::to_owned))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_matches_observation(export: &IlmRecoveryExport, observation: &IlmRecoveryExportObservation) -> bool {
|
||||||
|
export.control_id == observation.control_id
|
||||||
|
&& export.protocol == observation.protocol
|
||||||
|
&& export.classification == observation.classification
|
||||||
|
&& export.canonical_source_path == observation.canonical_source_path
|
||||||
|
&& export.source_generation == observation.source_generation
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct IlmRecoveryExportInventory {
|
||||||
|
count: usize,
|
||||||
|
bytes: u64,
|
||||||
|
creations: Vec<(i64, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IlmRecoveryExportInventory {
|
||||||
|
fn check(&self, creator_sha256: &str, candidate_len: usize, now: i64) -> Result<()> {
|
||||||
|
let recent_after = now.saturating_sub(60 * 1_000_000_000);
|
||||||
|
let cluster_recent = self
|
||||||
|
.creations
|
||||||
|
.iter()
|
||||||
|
.filter(|(created_at, _)| *created_at > recent_after)
|
||||||
|
.count();
|
||||||
|
let actor_recent = self
|
||||||
|
.creations
|
||||||
|
.iter()
|
||||||
|
.filter(|(created_at, creator)| *created_at > recent_after && creator == creator_sha256)
|
||||||
|
.count();
|
||||||
|
check_export_admission(self.count, self.bytes, actor_recent, cluster_recent, candidate_len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_export_inventory(api: Arc<ECStore>) -> Result<IlmRecoveryExportInventory> {
|
||||||
|
let mut marker = None;
|
||||||
|
let mut seen_markers = HashSet::new();
|
||||||
|
let mut inventory = IlmRecoveryExportInventory::default();
|
||||||
|
loop {
|
||||||
|
let page = api
|
||||||
|
.clone()
|
||||||
|
.list_objects_v2(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
&format!("{ILM_RECOVERY_EXPORT_PREFIX}/"),
|
||||||
|
marker.clone(),
|
||||||
|
None,
|
||||||
|
1_000,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
for object in page.objects {
|
||||||
|
let (_, export_id) = recovery_export_id_from_record_object_name(&object.name)?;
|
||||||
|
let (stored, export) = load_recovery_export_decoded(api.clone(), &export_id).await?;
|
||||||
|
inventory.count = inventory
|
||||||
|
.count
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export count overflow"))?;
|
||||||
|
inventory.bytes = inventory
|
||||||
|
.bytes
|
||||||
|
.checked_add(u64::try_from(stored.encoded.len()).map_err(|_| Error::other("ILM recovery export size overflow"))?)
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export byte total overflow"))?;
|
||||||
|
inventory
|
||||||
|
.creations
|
||||||
|
.push((export.created_at_unix_nanos, export.creator_sha256));
|
||||||
|
}
|
||||||
|
if !page.is_truncated {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let next = page
|
||||||
|
.next_continuation_token
|
||||||
|
.ok_or_else(|| Error::other("ILM recovery export inventory omitted its continuation marker"))?;
|
||||||
|
marker = Some(record_export_inventory_marker(&mut seen_markers, next)?);
|
||||||
|
}
|
||||||
|
Ok(inventory)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_export_inventory_marker(seen_markers: &mut HashSet<String>, next: String) -> Result<String> {
|
||||||
|
if !seen_markers.insert(next.clone()) {
|
||||||
|
return Err(Error::other("ILM recovery export inventory repeated its continuation marker"));
|
||||||
|
}
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_export_admission(
|
||||||
|
count: usize,
|
||||||
|
bytes: u64,
|
||||||
|
actor_recent: usize,
|
||||||
|
cluster_recent: usize,
|
||||||
|
candidate_len: usize,
|
||||||
|
) -> Result<()> {
|
||||||
|
let candidate_len = u64::try_from(candidate_len).map_err(|_| Error::other("ILM recovery export size does not fit u64"))?;
|
||||||
|
if count >= MAX_ILM_RECOVERY_EXPORTS
|
||||||
|
|| bytes
|
||||||
|
.checked_add(candidate_len)
|
||||||
|
.is_none_or(|total| total > MAX_ILM_RECOVERY_EXPORT_BYTES)
|
||||||
|
|| actor_recent >= MAX_ACTOR_EXPORTS_PER_MINUTE
|
||||||
|
|| cluster_recent >= MAX_CLUSTER_EXPORTS_PER_MINUTE
|
||||||
|
{
|
||||||
|
return Err(Error::SlowDown);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_legacy_export_schema(schema: &str) -> bool {
|
||||||
|
matches!(schema, TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA | TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_sha256(value: &str, message: &'static str) -> Result<()> {
|
||||||
|
if !is_sha256_checksum(value)
|
||||||
|
|| value
|
||||||
|
.bytes()
|
||||||
|
.any(|byte| byte.is_ascii_hexdigit() && byte.is_ascii_uppercase())
|
||||||
|
{
|
||||||
|
return Err(Error::other(message));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix_nanos() -> Result<i64> {
|
||||||
|
i64::try_from(time::OffsetDateTime::now_utc().unix_timestamp_nanos())
|
||||||
|
.map_err(|_| Error::other("ILM recovery export timestamp does not fit i64"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::bucket::lifecycle::recovery_control::IlmRecoverySourceCopy;
|
||||||
|
|
||||||
|
const PINNED_V1_EXPORT: &[u8] = br#"{"schema":"rustfs-ilm-recovery-export-v1","content_sha256":"3dfb3ec3892256e909de1211c1a963ca7008963ff32b3a869f7161a7b9b44028","export":{"export_id":"2b78e7a825bfc2edbf7f773d0b6ed3bf93e360ff1702d73a449109c11bfaa105","control_id":"0fcd568a5cb9bdb4677b69354b11ee415af8f784519cff3da49a26f84eaee7f2","protocol":"tier_delete_journal","control_etag":"control-etag","control_revision":1,"classification":"retained_ambiguous","canonical_source_path":"ilm/tier-delete-journal/872072554f66ab326f10ce7adbae11422b7a4b0663aa7112d6061a8f6ed41b94.json","source_generation":{"source_schema":"rustfs-tier-delete-journal-v1","source_etag":"etag-a","content_sha256":"0e0b010ebdeeb7b41473fe8575e989d6bb1303c0ca551dd984e9400f0ae306bd","copy_set_sha256":"5a7406115b6c3923ffe79dcd1f43ccae7beed786e557163f019dd10ec409a653","copies":[{"authority":"pool-0/set-0","canonical_path":"ilm/tier-delete-journal/872072554f66ab326f10ce7adbae11422b7a4b0663aa7112d6061a8f6ed41b94.json","etag":"etag-a","encoded_len":81,"content_sha256":"0e0b010ebdeeb7b41473fe8575e989d6bb1303c0ca551dd984e9400f0ae306bd"}]},"topology_generation":"e6e2b826e31fca5c36125c48f130dcb6f961e698ff8a8776a1f290cf0892e8e6","member_epochs_sha256":"612dd8a861161819a4ad8f6f3e2a0567602877c043a2353ca933a13c78dc0ed4","creator_sha256":"50c9c4aeb40b5b206b6d98f516f8b8c0efd29ce2e56a76b345fb9240c225a1b7","created_at_unix_nanos":1000000000,"retain_until_unix_nanos":7776001000000000,"source_bytes_base64":"eyJ2ZXJzaW9uIjoxLCJvYmpfbmFtZSI6ImxlZ2FjeS9yZW1vdGUiLCJ2ZXJzaW9uX2lkIjoib3BhcXVlIiwidGllcl9uYW1lIjoiV0FSTSJ9"}}"#;
|
||||||
|
|
||||||
|
fn legacy_source() -> Vec<u8> {
|
||||||
|
br#"{"version":1,"obj_name":"legacy/remote","version_id":"opaque","tier_name":"WARM"}"#.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observation() -> IlmRecoveryExportObservation {
|
||||||
|
let source = legacy_source();
|
||||||
|
let source_path = super::super::tier_delete_journal::tier_delete_journal_object_name(
|
||||||
|
&super::super::tier_delete_journal::decode_tier_delete_journal_entry(&source).expect("legacy fixture should decode"),
|
||||||
|
);
|
||||||
|
let source_sha256 = hex_sha256(&source, ToOwned::to_owned);
|
||||||
|
let generation = IlmRecoverySourceGeneration::new(
|
||||||
|
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA,
|
||||||
|
"etag-a",
|
||||||
|
source_sha256.clone(),
|
||||||
|
vec![IlmRecoverySourceCopy {
|
||||||
|
authority: "pool-0/set-0".to_string(),
|
||||||
|
canonical_path: source_path.clone(),
|
||||||
|
etag: "etag-a".to_string(),
|
||||||
|
encoded_len: source.len() as u64,
|
||||||
|
content_sha256: source_sha256,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("generation should be valid");
|
||||||
|
IlmRecoveryExportObservation {
|
||||||
|
control_id: hex_sha256(b"control", ToOwned::to_owned),
|
||||||
|
protocol: IlmRecoveryProtocol::TierDeleteJournal,
|
||||||
|
control_etag: "control-etag".to_string(),
|
||||||
|
control_revision: 1,
|
||||||
|
classification: IlmRecoveryClassification::RetainedAmbiguous,
|
||||||
|
canonical_source_path: source_path,
|
||||||
|
source_generation: generation,
|
||||||
|
topology_generation: hex_sha256(b"topology", ToOwned::to_owned),
|
||||||
|
member_epochs_sha256: hex_sha256(b"epochs", ToOwned::to_owned),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_export_round_trip_is_strict_and_deterministic() {
|
||||||
|
let observed = observation();
|
||||||
|
let creator = hex_sha256(b"actor", ToOwned::to_owned);
|
||||||
|
let export = build_export_from_source(
|
||||||
|
&observed,
|
||||||
|
&creator,
|
||||||
|
1_000_000_000,
|
||||||
|
&base64_simd::STANDARD.encode_to_string(legacy_source()),
|
||||||
|
)
|
||||||
|
.expect("export should be valid");
|
||||||
|
assert_eq!(
|
||||||
|
export.export_id,
|
||||||
|
recovery_export_id(&observed.control_id, &observed.source_generation).unwrap()
|
||||||
|
);
|
||||||
|
let encoded = export.encode().expect("export should encode");
|
||||||
|
assert_eq!(encoded, PINNED_V1_EXPORT, "v1 export wire format must remain pinned");
|
||||||
|
assert_eq!(IlmRecoveryExport::decode(&export.export_id, &encoded).unwrap(), export);
|
||||||
|
assert_eq!(
|
||||||
|
IlmRecoveryExport::decode("2b78e7a825bfc2edbf7f773d0b6ed3bf93e360ff1702d73a449109c11bfaa105", PINNED_V1_EXPORT)
|
||||||
|
.unwrap(),
|
||||||
|
export,
|
||||||
|
);
|
||||||
|
|
||||||
|
let path = recovery_export_record_object_name(export.protocol, &export.export_id).unwrap();
|
||||||
|
let durable = super::super::durable_namespace::validate_durable_ilm_record(&path, &encoded)
|
||||||
|
.expect("export should be registered as a durable ILM record");
|
||||||
|
assert_eq!(durable.namespace, "recovery-export");
|
||||||
|
assert_eq!(durable.id_kind, "export_id");
|
||||||
|
assert_eq!(durable.id, export.export_id);
|
||||||
|
|
||||||
|
let mut wrong_source = export.clone();
|
||||||
|
wrong_source.source_bytes_base64 = base64_simd::STANDARD.encode_to_string(b"changed");
|
||||||
|
assert!(wrong_source.encode().is_err());
|
||||||
|
|
||||||
|
let mut persisted: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
|
||||||
|
persisted["unknown"] = serde_json::json!(true);
|
||||||
|
assert!(IlmRecoveryExport::decode(&export.export_id, &serde_json::to_vec(&persisted).unwrap()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_inventory_rejects_non_adjacent_continuation_cycles() {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
assert_eq!(record_export_inventory_marker(&mut seen, "a".to_string()).unwrap(), "a");
|
||||||
|
assert_eq!(record_export_inventory_marker(&mut seen, "b".to_string()).unwrap(), "b");
|
||||||
|
record_export_inventory_marker(&mut seen, "a".to_string())
|
||||||
|
.expect_err("a non-adjacent continuation marker cycle must fail closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_export_path_rejects_noncanonical_shards() {
|
||||||
|
let id = hex_sha256(b"export", ToOwned::to_owned);
|
||||||
|
let path = recovery_export_record_object_name(IlmRecoveryProtocol::TierDeleteJournal, &id).unwrap();
|
||||||
|
assert_eq!(recovery_export_id_from_record_object_name(&path).unwrap().1, id);
|
||||||
|
let wrong_shard = path.replacen(&format!("/{}/", &id[..2]), "/zz/", 1);
|
||||||
|
assert!(recovery_export_id_from_record_object_name(&wrong_shard).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_replay_survives_fleet_rotation_but_not_source_change() {
|
||||||
|
let observed = observation();
|
||||||
|
let creator = hex_sha256(b"actor", ToOwned::to_owned);
|
||||||
|
let export = build_export_from_source(
|
||||||
|
&observed,
|
||||||
|
&creator,
|
||||||
|
1_000_000_000,
|
||||||
|
&base64_simd::STANDARD.encode_to_string(legacy_source()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut rotated = observed;
|
||||||
|
rotated.control_etag = "new-control-etag".to_string();
|
||||||
|
rotated.control_revision += 1;
|
||||||
|
rotated.topology_generation = hex_sha256(b"new-topology", ToOwned::to_owned);
|
||||||
|
rotated.member_epochs_sha256 = hex_sha256(b"new-members", ToOwned::to_owned);
|
||||||
|
assert!(export_matches_observation(&export, &rotated));
|
||||||
|
|
||||||
|
rotated.source_generation.content_sha256 = hex_sha256(b"changed", ToOwned::to_owned);
|
||||||
|
assert!(!export_matches_observation(&export, &rotated));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_admission_enforces_exact_count_byte_and_rate_boundaries() {
|
||||||
|
assert!(check_export_admission(9_999, MAX_ILM_RECOVERY_EXPORT_BYTES - 1, 9, 99, 1).is_ok());
|
||||||
|
assert!(check_export_admission(10_000, 0, 0, 0, 1).is_err());
|
||||||
|
assert!(check_export_admission(0, MAX_ILM_RECOVERY_EXPORT_BYTES, 0, 0, 1).is_err());
|
||||||
|
assert!(check_export_admission(0, 0, 10, 0, 1).is_err());
|
||||||
|
assert!(check_export_admission(0, 0, 0, 100, 1).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,10 @@ use crate::bucket::lifecycle::config_boundary;
|
|||||||
use crate::bucket::lifecycle::durable_namespace::{
|
use crate::bucket::lifecycle::durable_namespace::{
|
||||||
TIER_DELETE_JOURNAL_NAMESPACE, TIER_DELETE_JOURNAL_V6_NAMESPACE, validate_durable_ilm_record,
|
TIER_DELETE_JOURNAL_NAMESPACE, TIER_DELETE_JOURNAL_V6_NAMESPACE, validate_durable_ilm_record,
|
||||||
};
|
};
|
||||||
|
use crate::bucket::lifecycle::recovery_control::{
|
||||||
|
IlmRecoveryClassification, IlmRecoveryControl, IlmRecoveryControlIdentity, IlmRecoveryErrorCode, IlmRecoveryProtocol,
|
||||||
|
load_recovery_control, observe_recovery_source, save_recovery_control_if_absent,
|
||||||
|
};
|
||||||
use crate::bucket::lifecycle::runtime_boundary;
|
use crate::bucket::lifecycle::runtime_boundary;
|
||||||
use crate::bucket::lifecycle::tier_sweeper::{
|
use crate::bucket::lifecycle::tier_sweeper::{
|
||||||
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||||
@@ -78,6 +82,13 @@ const TIER_DELETE_DISPATCH_MEMBER_DELETE_CONCURRENCY: usize = 32;
|
|||||||
const TIER_DELETE_DISPATCH_PREPARE_CONCURRENCY: usize = 16;
|
const TIER_DELETE_DISPATCH_PREPARE_CONCURRENCY: usize = 16;
|
||||||
const TIER_DELETE_DISPATCH_CAS_CONCURRENCY: usize = 32;
|
const TIER_DELETE_DISPATCH_CAS_CONCURRENCY: usize = 32;
|
||||||
const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
|
const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
|
||||||
|
pub(crate) const TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA: &str = "rustfs-tier-delete-journal-v1";
|
||||||
|
pub(crate) const TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA: &str = "rustfs-tier-delete-journal-v2";
|
||||||
|
const TIER_DELETE_JOURNAL_UNKNOWN_RECOVERY_SCHEMA: &str = "rustfs-tier-delete-journal-unknown";
|
||||||
|
const TIER_DELETE_JOURNAL_V1_RECOVERY_CLASS: &str = "tier_delete_journal_v1";
|
||||||
|
const TIER_DELETE_JOURNAL_V2_RECOVERY_CLASS: &str = "tier_delete_journal_v2";
|
||||||
|
const TIER_DELETE_JOURNAL_CORRUPT_RECOVERY_CLASS: &str = "tier_delete_journal_corrupt";
|
||||||
|
const CORRUPT_TIER_DELETE_JOURNAL_IDENTITY: &str = "corrupt";
|
||||||
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
|
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
|
||||||
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
|
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
|
||||||
const TIER_DELETE_JOURNAL_TRANSACTION_VERSION: u8 = 5;
|
const TIER_DELETE_JOURNAL_TRANSACTION_VERSION: u8 = 5;
|
||||||
@@ -873,6 +884,23 @@ struct PersistedTierDeleteJournalEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PersistedTierDeleteJournalEntry {
|
impl PersistedTierDeleteJournalEntry {
|
||||||
|
fn validate_legacy_recovery_shape(&self) -> Result<()> {
|
||||||
|
let has_later_version_fields = self.version_id_exact.is_some()
|
||||||
|
|| self.version_state.is_some()
|
||||||
|
|| self.state.is_some()
|
||||||
|
|| self.source.is_some()
|
||||||
|
|| self.dispatch.is_some();
|
||||||
|
match self.version {
|
||||||
|
1 if self.backend_identity.is_none() && !has_later_version_fields => Ok(()),
|
||||||
|
TIER_DELETE_JOURNAL_VERSION if self.backend_identity.is_some() && !has_later_version_fields => Ok(()),
|
||||||
|
1 => Err(Error::other("tier delete journal v1 entry contains fields from a later version")),
|
||||||
|
TIER_DELETE_JOURNAL_VERSION => Err(Error::other(
|
||||||
|
"tier delete journal v2 entry is missing its identity or contains fields from a later version",
|
||||||
|
)),
|
||||||
|
_ => Err(Error::other("tier delete journal is not an exportable legacy version")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn from_jentry(je: &Jentry) -> Result<Self> {
|
fn from_jentry(je: &Jentry) -> Result<Self> {
|
||||||
validate_version_state(je.version_state, &je.version_id, je.version_id_exact)?;
|
validate_version_state(je.version_state, &je.version_id, je.version_id_exact)?;
|
||||||
let legacy_unknown = je.version_state == rustfs_filemeta::TransitionVersionState::Unknown;
|
let legacy_unknown = je.version_state == rustfs_filemeta::TransitionVersionState::Unknown;
|
||||||
@@ -1733,6 +1761,7 @@ async fn save_config_if_none_fenced(
|
|||||||
data.clone(),
|
data.clone(),
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_none_match: Some("*".to_string()),
|
if_none_match: Some("*".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1832,6 +1861,7 @@ async fn save_decommission_manifest_checkpoint_if_match(
|
|||||||
|
|
||||||
let mut opts = ObjectOptions {
|
let mut opts = ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
no_lock: true,
|
no_lock: true,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_match: Some(observed_etag),
|
if_match: Some(observed_etag),
|
||||||
@@ -1960,6 +1990,7 @@ async fn save_config_if_match_fenced(
|
|||||||
data.clone(),
|
data.clone(),
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_match: Some(etag.to_string()),
|
if_match: Some(etag.to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -3780,6 +3811,7 @@ where
|
|||||||
data.clone(),
|
data.clone(),
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_none_match: Some("*".to_string()),
|
if_none_match: Some("*".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -3869,6 +3901,7 @@ where
|
|||||||
data.clone(),
|
data.clone(),
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_match: Some(etag),
|
if_match: Some(etag),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -3893,6 +3926,7 @@ where
|
|||||||
data.clone(),
|
data.clone(),
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(HTTPPreconditions {
|
http_preconditions: Some(HTTPPreconditions {
|
||||||
if_none_match: Some("*".to_string()),
|
if_none_match: Some("*".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -5503,6 +5537,146 @@ enum TierDeleteJournalEntryRecoveryOutcome {
|
|||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn canonical_legacy_tier_delete_journal_identity(object_name: &str) -> Option<&str> {
|
||||||
|
let identity = object_name
|
||||||
|
.strip_prefix(TIER_DELETE_JOURNAL_LEGACY_PREFIX)?
|
||||||
|
.strip_suffix(".json")?;
|
||||||
|
(rustfs_utils::crypto::is_sha256_checksum(identity)
|
||||||
|
&& !identity
|
||||||
|
.bytes()
|
||||||
|
.any(|byte| byte.is_ascii_hexdigit() && byte.is_ascii_uppercase()))
|
||||||
|
.then_some(identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_legacy_tier_delete_recovery_path(object_name: &str) -> Result<()> {
|
||||||
|
canonical_legacy_tier_delete_journal_identity(object_name)
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or_else(|| Error::other("legacy tier delete journal path is not canonical"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_tier_delete_recovery_descriptor(entry: &Jentry) -> Option<(&'static str, &'static str)> {
|
||||||
|
match entry.persisted_version {
|
||||||
|
1 => Some((TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V1_RECOVERY_CLASS)),
|
||||||
|
TIER_DELETE_JOURNAL_VERSION => Some((TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_CLASS)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_legacy_tier_delete_recovery_source(object_name: &str, source_schema: &str, data: &[u8]) -> Result<()> {
|
||||||
|
validate_legacy_tier_delete_recovery_path(object_name)?;
|
||||||
|
let persisted: PersistedTierDeleteJournalEntry =
|
||||||
|
serde_json::from_slice(data).map_err(|err| Error::other_with_context("decode tier delete journal failed", err))?;
|
||||||
|
persisted.validate_legacy_recovery_shape()?;
|
||||||
|
let entry = persisted.into_jentry()?;
|
||||||
|
let Some((decoded_schema, _)) = legacy_tier_delete_recovery_descriptor(&entry) else {
|
||||||
|
return Err(Error::other("tier delete journal is not an exportable legacy version"));
|
||||||
|
};
|
||||||
|
if decoded_schema != source_schema || tier_delete_journal_object_name(&entry) != object_name {
|
||||||
|
return Err(Error::other("legacy tier delete journal identity does not match its recovery source"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_tier_delete_control_matches(
|
||||||
|
control: &IlmRecoveryControl,
|
||||||
|
identity: &IlmRecoveryControlIdentity,
|
||||||
|
generation: &crate::bucket::lifecycle::recovery_control::IlmRecoverySourceGeneration,
|
||||||
|
classification: IlmRecoveryClassification,
|
||||||
|
error_code: IlmRecoveryErrorCode,
|
||||||
|
) -> bool {
|
||||||
|
control.identity == *identity
|
||||||
|
&& control.observed_source_generation == *generation
|
||||||
|
&& control.classification == classification
|
||||||
|
&& control.last_error_code == error_code
|
||||||
|
&& control.owner.is_none()
|
||||||
|
&& control.attempt_count == 0
|
||||||
|
&& control.consecutive_failure_count == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_tier_delete_control_is_scheduler_fence(control: &IlmRecoveryControl, identity: &IlmRecoveryControlIdentity) -> bool {
|
||||||
|
control.identity == *identity && control.owner.is_none() && !control.classification.permits_automatic_attempt()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_legacy_tier_delete_recovery_control(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
object_name: &str,
|
||||||
|
observed_data: &[u8],
|
||||||
|
stable_operation_identity: String,
|
||||||
|
(source_schema, record_class): (&'static str, &'static str),
|
||||||
|
intended_classification: IlmRecoveryClassification,
|
||||||
|
intended_error_code: IlmRecoveryErrorCode,
|
||||||
|
) -> Result<()> {
|
||||||
|
let identity = IlmRecoveryControlIdentity {
|
||||||
|
protocol: IlmRecoveryProtocol::TierDeleteJournal,
|
||||||
|
canonical_source_path: object_name.to_string(),
|
||||||
|
stable_operation_identity,
|
||||||
|
record_class: record_class.to_string(),
|
||||||
|
};
|
||||||
|
let control_id = identity.source_operation_digest().map_err(Error::other)?;
|
||||||
|
match load_recovery_control(api.clone(), IlmRecoveryProtocol::TierDeleteJournal, &control_id).await {
|
||||||
|
Ok(observed) if legacy_tier_delete_control_is_scheduler_fence(&observed.control, &identity) => return Ok(()),
|
||||||
|
Ok(_) => return Err(Error::PreconditionFailed),
|
||||||
|
Err(Error::ConfigNotFound) => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = observe_recovery_source(api.clone(), object_name, source_schema).await?;
|
||||||
|
let exact_source = source.is_consistent() && source.canonical_data.as_deref() == Some(observed_data);
|
||||||
|
let (classification, error_code) = if exact_source {
|
||||||
|
(intended_classification, intended_error_code)
|
||||||
|
} else {
|
||||||
|
(IlmRecoveryClassification::Corrupt, IlmRecoveryErrorCode::SourceDivergent)
|
||||||
|
};
|
||||||
|
let candidate = IlmRecoveryControl::new(
|
||||||
|
identity.clone(),
|
||||||
|
source.generation.clone(),
|
||||||
|
classification,
|
||||||
|
i64::try_from(time::OffsetDateTime::now_utc().unix_timestamp_nanos())
|
||||||
|
.map_err(|_| Error::other("tier delete journal recovery timestamp does not fit i64"))?,
|
||||||
|
error_code,
|
||||||
|
)
|
||||||
|
.map_err(Error::other)?;
|
||||||
|
|
||||||
|
match save_recovery_control_if_absent(api.clone(), &candidate).await {
|
||||||
|
Ok(()) | Err(Error::PreconditionFailed) => {}
|
||||||
|
Err(save_error) => match load_recovery_control(api.clone(), IlmRecoveryProtocol::TierDeleteJournal, &control_id).await {
|
||||||
|
Ok(observed)
|
||||||
|
if legacy_tier_delete_control_matches(
|
||||||
|
&observed.control,
|
||||||
|
&identity,
|
||||||
|
&source.generation,
|
||||||
|
classification,
|
||||||
|
error_code,
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(_) | Err(_) => return Err(save_error),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let observed = load_recovery_control(api, IlmRecoveryProtocol::TierDeleteJournal, &control_id).await?;
|
||||||
|
if !legacy_tier_delete_control_matches(&observed.control, &identity, &source.generation, classification, error_code) {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn retain_corrupt_legacy_tier_delete_journal(api: Arc<ECStore>, object_name: &str, data: &[u8]) -> Result<()> {
|
||||||
|
canonical_legacy_tier_delete_journal_identity(object_name)
|
||||||
|
.ok_or_else(|| Error::other("tier delete journal path is not canonical"))?;
|
||||||
|
persist_legacy_tier_delete_recovery_control(
|
||||||
|
api,
|
||||||
|
object_name,
|
||||||
|
data,
|
||||||
|
CORRUPT_TIER_DELETE_JOURNAL_IDENTITY.to_string(),
|
||||||
|
(TIER_DELETE_JOURNAL_UNKNOWN_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_CORRUPT_RECOVERY_CLASS),
|
||||||
|
IlmRecoveryClassification::Corrupt,
|
||||||
|
IlmRecoveryErrorCode::SourceCorrupt,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: String) -> TierDeleteJournalEntryRecoveryOutcome {
|
async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: String) -> TierDeleteJournalEntryRecoveryOutcome {
|
||||||
let data = match config_boundary::read_config(api.clone(), &object_name).await {
|
let data = match config_boundary::read_config(api.clone(), &object_name).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
@@ -5523,6 +5697,22 @@ async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: Strin
|
|||||||
let je = match decode_tier_delete_journal_entry(&data) {
|
let je = match decode_tier_delete_journal_entry(&data) {
|
||||||
Ok(je) => je,
|
Ok(je) => je,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
if canonical_legacy_tier_delete_journal_identity(&object_name).is_some() {
|
||||||
|
return match retain_corrupt_legacy_tier_delete_journal(api, &object_name, &data).await {
|
||||||
|
Ok(()) => TierDeleteJournalEntryRecoveryOutcome::Retained,
|
||||||
|
Err(control_error) => {
|
||||||
|
warn!(
|
||||||
|
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||||
|
journal_object = %object_name,
|
||||||
|
error = ?control_error,
|
||||||
|
"Failed to retain corrupt tier delete journal recovery control"
|
||||||
|
);
|
||||||
|
TierDeleteJournalEntryRecoveryOutcome::Failed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
warn!(
|
warn!(
|
||||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -5536,6 +5726,22 @@ async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: Strin
|
|||||||
};
|
};
|
||||||
|
|
||||||
if tier_delete_journal_object_name(&je) != object_name {
|
if tier_delete_journal_object_name(&je) != object_name {
|
||||||
|
if canonical_legacy_tier_delete_journal_identity(&object_name).is_some() {
|
||||||
|
return match retain_corrupt_legacy_tier_delete_journal(api, &object_name, &data).await {
|
||||||
|
Ok(()) => TierDeleteJournalEntryRecoveryOutcome::Retained,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||||
|
journal_object = %object_name,
|
||||||
|
error = ?err,
|
||||||
|
"Failed to retain mismatched tier delete journal recovery control"
|
||||||
|
);
|
||||||
|
TierDeleteJournalEntryRecoveryOutcome::Failed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
warn!(
|
warn!(
|
||||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -5546,6 +5752,36 @@ async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: Strin
|
|||||||
return TierDeleteJournalEntryRecoveryOutcome::Failed;
|
return TierDeleteJournalEntryRecoveryOutcome::Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some((source_schema, record_class)) = legacy_tier_delete_recovery_descriptor(&je) {
|
||||||
|
let stable_operation_identity = canonical_legacy_tier_delete_journal_identity(&object_name)
|
||||||
|
.expect("decoded legacy journal path was validated against its canonical object name")
|
||||||
|
.to_string();
|
||||||
|
return match persist_legacy_tier_delete_recovery_control(
|
||||||
|
api,
|
||||||
|
&object_name,
|
||||||
|
&data,
|
||||||
|
stable_operation_identity,
|
||||||
|
(source_schema, record_class),
|
||||||
|
IlmRecoveryClassification::RetainedAmbiguous,
|
||||||
|
IlmRecoveryErrorCode::RemoteVersionUnknown,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => TierDeleteJournalEntryRecoveryOutcome::Retained,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||||
|
journal_object = %object_name,
|
||||||
|
error = ?err,
|
||||||
|
"Failed to retain legacy tier delete journal recovery control"
|
||||||
|
);
|
||||||
|
TierDeleteJournalEntryRecoveryOutcome::Failed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
match api
|
match api
|
||||||
.durable_ilm_terminal_receipt_covers_active_source(&object_name, &data)
|
.durable_ilm_terminal_receipt_covers_active_source(&object_name, &data)
|
||||||
.await
|
.await
|
||||||
@@ -5942,17 +6178,18 @@ where
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE, TIER_DELETE_DISPATCH_PARENT_VERSION,
|
PersistedTierDeleteJournalEntry, TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE,
|
||||||
TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX, TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION,
|
TIER_DELETE_DISPATCH_PARENT_VERSION, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX,
|
||||||
TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION, TIER_DELETE_JOURNAL_V6_PREFIX,
|
TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION,
|
||||||
TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState, TierDeleteDispatchParent,
|
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V6_PREFIX,
|
||||||
TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
|
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState,
|
||||||
|
TierDeleteDispatchParent, TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
|
||||||
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
|
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
|
||||||
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
|
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
|
||||||
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
|
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
|
||||||
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
|
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
|
||||||
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
|
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
|
||||||
tier_delete_source_matches_dispatch_scope,
|
tier_delete_source_matches_dispatch_scope, validate_legacy_tier_delete_recovery_source,
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::tier_sweeper::{
|
use crate::bucket::lifecycle::tier_sweeper::{
|
||||||
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||||
@@ -6411,6 +6648,72 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_recovery_export_rejects_fields_from_later_journal_versions() {
|
||||||
|
let later = bound_v6_journal_entry(TierDeleteJournalState::Prepared);
|
||||||
|
let v1 = PersistedTierDeleteJournalEntry {
|
||||||
|
version: 1,
|
||||||
|
obj_name: "remote/object".to_string(),
|
||||||
|
version_id: "opaque".to_string(),
|
||||||
|
tier_name: "WARM".to_string(),
|
||||||
|
backend_identity: None,
|
||||||
|
version_id_exact: None,
|
||||||
|
version_state: None,
|
||||||
|
state: None,
|
||||||
|
source: None,
|
||||||
|
dispatch: None,
|
||||||
|
};
|
||||||
|
let mut v2 = v1.clone();
|
||||||
|
v2.version = TIER_DELETE_JOURNAL_VERSION;
|
||||||
|
v2.backend_identity = Some([7; 32]);
|
||||||
|
|
||||||
|
let assert_rejected = |persisted: PersistedTierDeleteJournalEntry, schema: &str| {
|
||||||
|
let normalized = persisted
|
||||||
|
.clone()
|
||||||
|
.into_jentry()
|
||||||
|
.expect("the generic compatibility decoder should demonstrate the discarded field");
|
||||||
|
let object_name = tier_delete_journal_object_name(&normalized);
|
||||||
|
let encoded = serde_json::to_vec(&persisted).expect("mixed-version journal fixture should encode");
|
||||||
|
let err = validate_legacy_tier_delete_recovery_source(&object_name, schema, &encoded)
|
||||||
|
.expect_err("legacy recovery export must reject fields from later versions");
|
||||||
|
assert!(err.to_string().contains("later version"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut invalid_v1 = Vec::new();
|
||||||
|
let mut with_backend = v1.clone();
|
||||||
|
with_backend.backend_identity = Some([7; 32]);
|
||||||
|
invalid_v1.push(with_backend);
|
||||||
|
for persisted in [&v1, &v2] {
|
||||||
|
let schema = if persisted.version == 1 {
|
||||||
|
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA
|
||||||
|
} else {
|
||||||
|
TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA
|
||||||
|
};
|
||||||
|
let mut invalid = Vec::new();
|
||||||
|
let mut with_exact = persisted.clone();
|
||||||
|
with_exact.version_id_exact = Some(false);
|
||||||
|
invalid.push(with_exact);
|
||||||
|
let mut with_version_state = persisted.clone();
|
||||||
|
with_version_state.version_state = Some(rustfs_filemeta::TransitionVersionState::Unknown);
|
||||||
|
invalid.push(with_version_state);
|
||||||
|
let mut with_state = persisted.clone();
|
||||||
|
with_state.state = Some(TierDeleteJournalState::Committed);
|
||||||
|
invalid.push(with_state);
|
||||||
|
let mut with_source = persisted.clone();
|
||||||
|
with_source.source = later.source.clone();
|
||||||
|
invalid.push(with_source);
|
||||||
|
let mut with_dispatch = persisted.clone();
|
||||||
|
with_dispatch.dispatch = later.dispatch.clone();
|
||||||
|
invalid.push(with_dispatch);
|
||||||
|
for record in invalid {
|
||||||
|
assert_rejected(record, schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for record in invalid_v1 {
|
||||||
|
assert_rejected(record, TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tier_delete_journal_path_is_stable_and_sanitized() {
|
fn tier_delete_journal_path_is_stable_and_sanitized() {
|
||||||
let je = journal_entry();
|
let je = journal_entry();
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
#![allow(unused_variables)]
|
#![allow(unused_variables)]
|
||||||
#![allow(unused_mut)]
|
#![allow(unused_mut)]
|
||||||
#![allow(unused_assignments)]
|
#![allow(unused_assignments)]
|
||||||
#![allow(unused_must_use)]
|
|
||||||
#![allow(clippy::all)]
|
|
||||||
|
|
||||||
use super::runtime_boundary as runtime_sources;
|
use super::runtime_boundary as runtime_sources;
|
||||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
|
use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
|
||||||
@@ -72,9 +70,11 @@ static REMOTE_DELETE_BREAKER: LazyLock<Mutex<RemoteDeleteBreaker>> = LazyLock::n
|
|||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static REMOTE_TIER_DELETE_TEST_HOOK: std::sync::LazyLock<
|
type RemoteTierDeleteTestHook = Box<dyn Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync>;
|
||||||
std::sync::Mutex<Option<Box<dyn Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync>>>,
|
|
||||||
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
|
#[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)]
|
#[derive(Debug)]
|
||||||
struct RemoteDeleteBreaker {
|
struct RemoteDeleteBreaker {
|
||||||
@@ -107,7 +107,7 @@ impl RemoteDeleteBreaker {
|
|||||||
fn prune(&mut self, now: Instant) {
|
fn prune(&mut self, now: Instant) {
|
||||||
while let Some(ts) = self.failures.front().copied() {
|
while let Some(ts) = self.failures.front().copied() {
|
||||||
if now.duration_since(ts) > self.window {
|
if now.duration_since(ts) > self.window {
|
||||||
self.failures.pop_front();
|
let _ = self.failures.pop_front();
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -137,11 +137,11 @@ fn is_signer_header_error(err: &std::io::Error) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(source) = err.get_ref() {
|
if let Some(source) = err.get_ref()
|
||||||
if error_chain_contains_signer_header_marker(source) {
|
&& error_chain_contains_signer_header_marker(source)
|
||||||
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let message = err.to_string().to_ascii_lowercase();
|
let message = err.to_string().to_ascii_lowercase();
|
||||||
message.contains("invalid utf-8 header value")
|
message.contains("invalid utf-8 header value")
|
||||||
@@ -205,7 +205,7 @@ impl ObjSweeper {
|
|||||||
|
|
||||||
#[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")]
|
#[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 {
|
pub fn with_version(&mut self, vid: Option<Uuid>) -> &Self {
|
||||||
self.version_id = vid.clone();
|
self.version_id = vid;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ impl ObjSweeper {
|
|||||||
#[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")]
|
#[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")]
|
||||||
pub fn get_opts(&self) -> lifecycle::ObjectOpts {
|
pub fn get_opts(&self) -> lifecycle::ObjectOpts {
|
||||||
let mut opts = ObjectOpts {
|
let mut opts = ObjectOpts {
|
||||||
version_id: self.version_id.clone(),
|
version_id: self.version_id,
|
||||||
versioned: self.versioned,
|
versioned: self.versioned,
|
||||||
version_suspended: self.suspended,
|
version_suspended: self.suspended,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -388,8 +388,8 @@ impl Jentry {
|
|||||||
impl ExpiryOp for Jentry {
|
impl ExpiryOp for Jentry {
|
||||||
fn op_hash(&self) -> u64 {
|
fn op_hash(&self) -> u64 {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(format!("{}", self.tier_name).as_bytes());
|
hasher.update(self.tier_name.as_bytes());
|
||||||
hasher.update(format!("{}", self.obj_name).as_bytes());
|
hasher.update(self.obj_name.as_bytes());
|
||||||
xxh64::xxh64(hasher.finalize().as_slice(), XXHASH_SEED)
|
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_name: &str,
|
||||||
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
|
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
|
||||||
) -> Result<(), std::io::Error> {
|
) -> 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
|
.await
|
||||||
.map_err(std::io::Error::other)?;
|
.map_err(std::io::Error::other)?;
|
||||||
delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false, true).await
|
delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false, true).await
|
||||||
@@ -575,7 +575,7 @@ pub(crate) async fn delete_confirmed_transition_candidate_exact_with_lease_idemp
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||||
obj_name: &str,
|
obj_name: &str,
|
||||||
rv_id: &str,
|
rv_id: &str,
|
||||||
@@ -706,15 +706,16 @@ pub(crate) fn transitioned_delete_journal_entry_for_source(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
use super::delete_confirmed_transition_candidate_exact_with_manager_and_identity;
|
||||||
use rustfs_s3_client::signer_error::invalid_utf8_header_error;
|
use rustfs_s3_client::signer_error::invalid_utf8_header_error;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, Jentry,
|
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, Jentry,
|
||||||
RemoteDeleteBreaker, RemoteTierDeleteOutcome, TierDeleteJournalState, TierDeleteSourceIdentity,
|
RemoteDeleteBreaker, RemoteTierDeleteOutcome, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity, delete_object_from_remote_tier_idempotent,
|
delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity, is_remote_tier_not_found_error,
|
is_remote_tier_not_found_error, is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook,
|
||||||
is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook, should_record_remote_delete_failure,
|
should_record_remote_delete_failure, transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
|
||||||
transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
|
|
||||||
};
|
};
|
||||||
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
||||||
use rustfs_filemeta::TransitionVersionState;
|
use rustfs_filemeta::TransitionVersionState;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -477,28 +477,29 @@ impl BucketMetadata {
|
|||||||
!self.table_bucket_config_json.is_empty()
|
!self.table_bucket_config_json.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed per-bucket durability override, if a valid one is stored.
|
/// `bucket-targets.json` is stored for this bucket but this build cannot
|
||||||
|
/// decode it.
|
||||||
///
|
///
|
||||||
/// Absent/empty/unparsable payloads all mean "no override" (the bucket
|
/// Keeps "no replication targets configured" and "the target
|
||||||
/// follows the global durability mode); a parse failure is logged so a
|
/// configuration cannot be read" apart, the same distinction the
|
||||||
/// corrupted entry cannot silently change fsync behavior.
|
/// `fabricated` marker draws for the bucket metadata as a whole. Only
|
||||||
/// Parsed on-demand migration config, if one is stored.
|
/// meaningful after [`Self::parse_all_configs`] has run; readers must fail
|
||||||
///
|
/// closed on `true` instead of serving an empty target set.
|
||||||
/// `Ok(None)` means no config (absent or cleared). A stored payload that
|
pub fn bucket_targets_unreadable(&self) -> bool {
|
||||||
/// does not parse is an error, never a default: the runtime must not
|
!self.bucket_targets_config_json.is_empty() && self.bucket_target_config.is_none()
|
||||||
/// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opaque application-owned configuration with its persisted update time.
|
||||||
|
/// Empty bytes mean absent or cleared; decoding belongs to the consumer.
|
||||||
|
pub fn on_demand_migration_config(&self) -> Option<(&[u8], OffsetDateTime)> {
|
||||||
|
(!self.on_demand_migration_config_json.is_empty()).then_some((
|
||||||
|
self.on_demand_migration_config_json.as_slice(),
|
||||||
|
self.on_demand_migration_config_updated_at,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed per-bucket durability override, if a valid one is stored.
|
||||||
|
/// Invalid payloads follow the global mode after logging a parse failure.
|
||||||
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
|
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
|
||||||
if self.durability_config_json.is_empty() {
|
if self.durability_config_json.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -790,9 +791,22 @@ impl BucketMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace one config payload and stamp its `*_config_updated_at` with the
|
||||||
|
/// local clock. This is the entry for edits that originate here: the
|
||||||
|
/// local write time is the edit's source time.
|
||||||
pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||||
let updated = OffsetDateTime::now_utc();
|
self.update_config_at(config_file, data, OffsetDateTime::now_utc())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::update_config`] with an explicit `updated_at` stamp.
|
||||||
|
///
|
||||||
|
/// For a config replicated from another site the edit's source time is
|
||||||
|
/// the peer's `updated_at`, not the moment it lands here: staleness of
|
||||||
|
/// the next incoming item is judged against the stored stamp, so stamping
|
||||||
|
/// the local apply time would reject a newer source edit that was merely
|
||||||
|
/// delivered late (backlog#2292). Only replication receivers should pass
|
||||||
|
/// a foreign time; local edits keep [`Self::update_config`].
|
||||||
|
pub fn update_config_at(&mut self, config_file: &str, data: Vec<u8>, updated: OffsetDateTime) -> Result<OffsetDateTime> {
|
||||||
match config_file {
|
match config_file {
|
||||||
BUCKET_POLICY_CONFIG => {
|
BUCKET_POLICY_CONFIG => {
|
||||||
self.policy_config_json = data;
|
self.policy_config_json = data;
|
||||||
@@ -904,13 +918,6 @@ impl BucketMetadata {
|
|||||||
self.durability_config_updated_at = updated;
|
self.durability_config_updated_at = updated;
|
||||||
}
|
}
|
||||||
BUCKET_ON_DEMAND_MIGRATION_CONFIG => {
|
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_json = data;
|
||||||
self.on_demand_migration_config_updated_at = updated;
|
self.on_demand_migration_config_updated_at = updated;
|
||||||
}
|
}
|
||||||
@@ -964,7 +971,32 @@ impl BucketMetadata {
|
|||||||
Ok(())
|
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() {
|
if let Err(e) = self.parse_policy_config() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
event = "bucket_metadata_parse_failed",
|
event = "bucket_metadata_parse_failed",
|
||||||
@@ -1088,20 +1120,26 @@ impl BucketMetadata {
|
|||||||
"Failed to parse bucket metadata config"
|
"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 !self.bucket_targets_config_json.is_empty() {
|
||||||
if let Err(e) = serde_json::from_slice::<BucketTargets>(&self.bucket_targets_config_json)
|
match serde_json::from_slice::<BucketTargets>(&self.bucket_targets_config_json) {
|
||||||
.map(|t| self.bucket_target_config = Some(t))
|
Ok(targets) => self.bucket_target_config = Some(targets),
|
||||||
{
|
Err(e) => tracing::error!(
|
||||||
tracing::warn!(
|
|
||||||
event = "bucket_metadata_parse_failed",
|
event = "bucket_metadata_parse_failed",
|
||||||
component = "ecstore",
|
component = "ecstore",
|
||||||
subsystem = "bucket_metadata",
|
subsystem = "bucket_metadata",
|
||||||
bucket = %self.name,
|
bucket = %self.name,
|
||||||
config = "bucket_targets",
|
config = "bucket_targets",
|
||||||
error = %e,
|
error = %e,
|
||||||
"Failed to parse bucket metadata config"
|
"Bucket replication targets are unreadable; replication for this bucket fails closed"
|
||||||
);
|
),
|
||||||
self.bucket_target_config = Some(BucketTargets::default());
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.bucket_target_config = Some(BucketTargets::default());
|
self.bucket_target_config = Some(BucketTargets::default());
|
||||||
@@ -1500,6 +1538,39 @@ mod test {
|
|||||||
assert_eq!(metadata.bucket_incarnation_id, incarnation);
|
assert_eq!(metadata.bucket_incarnation_id, incarnation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// backlog#2292: a replicated config is stamped with the source
|
||||||
|
/// `updated_at` it was given, not the local clock, while the plain
|
||||||
|
/// `update_config` entry keeps stamping the local clock.
|
||||||
|
#[test]
|
||||||
|
fn update_config_at_stamps_the_given_time_and_update_config_stamps_now() {
|
||||||
|
let source_time = OffsetDateTime::now_utc() - time::Duration::hours(3);
|
||||||
|
let mut metadata = BucketMetadata::new("source-stamped");
|
||||||
|
|
||||||
|
let stamped = metadata
|
||||||
|
.update_config_at(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec(), source_time)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stamped, source_time);
|
||||||
|
assert_eq!(metadata.policy_config_updated_at, source_time);
|
||||||
|
|
||||||
|
let tagging = b"<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag></TagSet></Tagging>".to_vec();
|
||||||
|
let stamped = metadata
|
||||||
|
.update_config_at(BUCKET_TAGGING_CONFIG, tagging, source_time)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stamped, source_time);
|
||||||
|
assert_eq!(metadata.tagging_config_updated_at, source_time);
|
||||||
|
|
||||||
|
let before = OffsetDateTime::now_utc();
|
||||||
|
let stamped = metadata
|
||||||
|
.update_config(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec())
|
||||||
|
.unwrap();
|
||||||
|
assert!(stamped >= before, "a local edit is stamped with the local clock");
|
||||||
|
assert_eq!(metadata.policy_config_updated_at, stamped);
|
||||||
|
assert_eq!(
|
||||||
|
metadata.tagging_config_updated_at, source_time,
|
||||||
|
"restamping one config must not move another config's stamp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn object_locking_requires_lock_metadata_not_plain_versioning() {
|
fn object_locking_requires_lock_metadata_not_plain_versioning() {
|
||||||
use s3s::dto::ObjectLockEnabled;
|
use s3s::dto::ObjectLockEnabled;
|
||||||
@@ -1535,6 +1606,145 @@ mod test {
|
|||||||
assert_eq!(bucket_targets.targets[0].target_bucket, "target-bucket");
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rustfs/backlog#2309: the MinIO-origin `.metadata.bin` this repository
|
||||||
|
/// already carries as a compatibility fixture stores
|
||||||
|
/// `BucketTargetsConfigJSON` as a bare JSON array, which `BucketTargets`
|
||||||
|
/// (a `{"targets":[…]}` struct with no array fallback) cannot decode. The
|
||||||
|
/// bytes below are the exact payload the fixture in
|
||||||
|
/// `metadata_test.rs::TEST_BUCKET_METADATA_HEX` decodes to, so if RustFS
|
||||||
|
/// ever grows the array-shaped compatibility parse, this test is where the
|
||||||
|
/// upgrade break is pinned and where the decision has to be recorded.
|
||||||
|
#[test]
|
||||||
|
fn minio_array_shaped_bucket_targets_are_unreadable() {
|
||||||
|
let minio_array = br#"[{"endpoint":"http://target.example.com","targetBucket":"tb","region":"us-east-1"}]"#.to_vec();
|
||||||
|
let mut bm = BucketMetadata::new("minio-array-targets");
|
||||||
|
bm.bucket_targets_config_json = minio_array.clone();
|
||||||
|
|
||||||
|
bm.parse_all_configs()
|
||||||
|
.expect("a MinIO-shaped targets blob must not fail the whole metadata load");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
bm.bucket_targets_unreadable(),
|
||||||
|
"an array-shaped MinIO targets blob is unreadable, not an empty target set"
|
||||||
|
);
|
||||||
|
assert!(bm.bucket_target_config.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
bm.bucket_targets_config_json, minio_array,
|
||||||
|
"the raw MinIO bytes must survive so the configuration stays recoverable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]
|
#[test]
|
||||||
fn lifecycle_update_config_clears_parsed_config_on_delete() {
|
fn lifecycle_update_config_clears_parsed_config_on_delete() {
|
||||||
let mut bm = BucketMetadata::new("test-bucket");
|
let mut bm = BucketMetadata::new("test-bucket");
|
||||||
@@ -1824,51 +2034,30 @@ mod test {
|
|||||||
|
|
||||||
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"}}}"#;
|
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
|
/// The metadata codec preserves application-owned bytes and timestamps.
|
||||||
/// extension entry that round-trips through `update_config` and the
|
|
||||||
/// msgpack codec, clears on delete, and never parses corruption into a
|
|
||||||
/// default.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn on_demand_migration_config_round_trips_and_tracks_updates() {
|
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");
|
let mut bm = BucketMetadata::new("odm-bucket");
|
||||||
assert_eq!(bm.on_demand_migration_config(), Ok(None), "fresh metadata carries no config");
|
assert_eq!(bm.on_demand_migration_config(), 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())
|
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||||
.expect("valid config is accepted");
|
.expect("opaque config is accepted");
|
||||||
assert_ne!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
let stamped = bm.on_demand_migration_config_updated_at;
|
||||||
assert_eq!(bm.on_demand_migration_config(), Ok(Some(expected.clone())));
|
assert_ne!(stamped, OffsetDateTime::UNIX_EPOCH);
|
||||||
|
assert_eq!(bm.on_demand_migration_config(), Some((ODM_JSON, stamped)));
|
||||||
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
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_json, bm.on_demand_migration_config_json);
|
||||||
assert_eq!(
|
assert_eq!(back.on_demand_migration_config_updated_at.unix_timestamp(), stamped.unix_timestamp());
|
||||||
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();
|
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
|
||||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
assert!(bm.on_demand_migration_config_json.is_empty());
|
||||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
assert_eq!(bm.on_demand_migration_config(), None);
|
||||||
assert!(bm.on_demand_migration_config_updated_at >= stamped);
|
assert!(bm.on_demand_migration_config_updated_at >= stamped);
|
||||||
|
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, b"not-json".to_vec())
|
||||||
// Corruption that bypassed `update_config` (disk, another writer)
|
.unwrap();
|
||||||
// is a typed error, never a default.
|
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
||||||
bm.on_demand_migration_config_json = b"not-json".to_vec();
|
assert_eq!(
|
||||||
assert!(matches!(bm.on_demand_migration_config(), Err(OnDemandMigrationConfigError::Malformed(_))));
|
back.on_demand_migration_config_json, b"not-json",
|
||||||
|
"metadata must not reinterpret application bytes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
|
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
|
||||||
@@ -1880,7 +2069,7 @@ mod test {
|
|||||||
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
|
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
|
||||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
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_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
assert_eq!(bm.on_demand_migration_config(), None);
|
||||||
|
|
||||||
bm.default_timestamps();
|
bm.default_timestamps();
|
||||||
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
|
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ use super::quota::BucketQuota;
|
|||||||
use super::target::BucketTargets;
|
use super::target::BucketTargets;
|
||||||
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
||||||
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
|
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::bucket::utils::is_meta_bucketname;
|
||||||
use crate::disk::RUSTFS_META_BUCKET;
|
use crate::disk::RUSTFS_META_BUCKET;
|
||||||
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
|
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
|
||||||
@@ -49,6 +48,11 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Opaque bucket configuration notifications for application-owned services.
|
||||||
|
/// `None` withdraws a configuration; consumers validate nonempty bytes.
|
||||||
|
pub type BucketConfigPublishHook = Box<dyn Fn(&str, &str, Option<(&[u8], OffsetDateTime, Uuid)>) + Send + Sync>;
|
||||||
|
pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
@@ -360,6 +364,16 @@ async fn refresh_buckets_metadata_once(sys: Arc<RwLock<BucketMetadataSys>>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_bucket_target_sys(bucket: &str, bm: &BucketMetadata) {
|
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()
|
BucketTargetSys::get()
|
||||||
.update_all_targets(bucket, bm.bucket_target_config.as_ref())
|
.update_all_targets(bucket, bm.bucket_target_config.as_ref())
|
||||||
.await;
|
.await;
|
||||||
@@ -385,39 +399,21 @@ fn clear_bucket_durability(bucket: &str) {
|
|||||||
crate::disk::local::bucket_durability::set(bucket, None);
|
crate::disk::local::bucket_durability::set(bucket, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish the bucket's on-demand migration config (or its absence) to the
|
/// Publish application-owned bytes on every cache install path.
|
||||||
/// 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) {
|
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
|
||||||
let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() else {
|
if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
|
||||||
return;
|
hook(
|
||||||
};
|
bucket,
|
||||||
match bm.on_demand_migration_config() {
|
super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG,
|
||||||
Ok(config) => hook(bucket, config.as_ref()),
|
bm.on_demand_migration_config()
|
||||||
Err(err) => {
|
.map(|(bytes, stamp)| (bytes, stamp, bm.bucket_incarnation_id)),
|
||||||
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) {
|
fn clear_on_demand_migration(bucket: &str) {
|
||||||
if let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() {
|
if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
|
||||||
hook(bucket, None);
|
hook(bucket, super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +567,32 @@ pub async fn update_if_incarnation(
|
|||||||
config_file,
|
config_file,
|
||||||
data,
|
data,
|
||||||
Some(expected_incarnation_id),
|
Some(expected_incarnation_id),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`update_if_incarnation`] stamping the config with `updated_at` instead of
|
||||||
|
/// the local clock.
|
||||||
|
///
|
||||||
|
/// For a site-replication receiver the edit's source time is the peer's
|
||||||
|
/// `updated_at`; persisting it keeps the stored `*_config_updated_at` on the
|
||||||
|
/// source clock so the next item's staleness is judged source-time against
|
||||||
|
/// source-time (backlog#2292). See [`BucketMetadata::update_config_at`].
|
||||||
|
pub async fn update_if_incarnation_at(
|
||||||
|
bucket: &str,
|
||||||
|
config_file: &str,
|
||||||
|
data: Vec<u8>,
|
||||||
|
expected_incarnation_id: Uuid,
|
||||||
|
updated_at: OffsetDateTime,
|
||||||
|
) -> Result<OffsetDateTime> {
|
||||||
|
Box::pin(update_with_sys_expected(
|
||||||
|
get_bucket_metadata_sys()?,
|
||||||
|
bucket,
|
||||||
|
config_file,
|
||||||
|
data,
|
||||||
|
Some(expected_incarnation_id),
|
||||||
|
Some(updated_at),
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -581,6 +603,30 @@ pub async fn delete_if_incarnation(bucket: &str, config_file: &str, expected_inc
|
|||||||
bucket,
|
bucket,
|
||||||
config_file,
|
config_file,
|
||||||
Some(expected_incarnation_id),
|
Some(expected_incarnation_id),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`delete_if_incarnation`] stamping the cleared config with `updated_at`
|
||||||
|
/// (a replicated deletion's source time) instead of the local clock.
|
||||||
|
///
|
||||||
|
/// The stamp survives the deletion as the config's `*_config_updated_at`, and
|
||||||
|
/// that is what the next incoming item is judged against: a local stamp on
|
||||||
|
/// the delete would reject a newer source re-create that was merely delivered
|
||||||
|
/// later (backlog#2292). See [`update_if_incarnation_at`].
|
||||||
|
pub async fn delete_if_incarnation_at(
|
||||||
|
bucket: &str,
|
||||||
|
config_file: &str,
|
||||||
|
expected_incarnation_id: Uuid,
|
||||||
|
updated_at: OffsetDateTime,
|
||||||
|
) -> Result<OffsetDateTime> {
|
||||||
|
Box::pin(delete_with_sys_expected(
|
||||||
|
get_bucket_metadata_sys()?,
|
||||||
|
bucket,
|
||||||
|
config_file,
|
||||||
|
Some(expected_incarnation_id),
|
||||||
|
Some(updated_at),
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -602,34 +648,41 @@ async fn update_with_sys(
|
|||||||
config_file: &str,
|
config_file: &str,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
update_with_sys_expected(sys, bucket, config_file, data, None).await
|
update_with_sys_expected(sys, bucket, config_file, data, None, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `updated_at` is the stamp persisted on the config; `None` uses the local
|
||||||
|
/// clock (the edit originates here), `Some` carries a replicated edit's
|
||||||
|
/// source time (backlog#2292).
|
||||||
async fn update_with_sys_expected(
|
async fn update_with_sys_expected(
|
||||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
sys: Arc<RwLock<BucketMetadataSys>>,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
config_file: &str,
|
config_file: &str,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
expected_incarnation_id: Option<Uuid>,
|
expected_incarnation_id: Option<Uuid>,
|
||||||
|
updated_at: Option<OffsetDateTime>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
|
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
|
||||||
update_under_config_write_guard(sys, &guard, config_file, data).await
|
update_under_config_write_guard(sys, &guard, config_file, data, updated_at).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`delete`] against an explicitly supplied metadata system. See
|
/// [`delete`] against an explicitly supplied metadata system. See
|
||||||
/// [`update_with_sys`].
|
/// [`update_with_sys`].
|
||||||
async fn delete_with_sys(sys: Arc<RwLock<BucketMetadataSys>>, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
async fn delete_with_sys(sys: Arc<RwLock<BucketMetadataSys>>, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
||||||
delete_with_sys_expected(sys, bucket, config_file, None).await
|
delete_with_sys_expected(sys, bucket, config_file, None, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `updated_at`: `None` stamps the local clock; `Some` persists a replicated
|
||||||
|
/// deletion's source time (backlog#2292).
|
||||||
async fn delete_with_sys_expected(
|
async fn delete_with_sys_expected(
|
||||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
sys: Arc<RwLock<BucketMetadataSys>>,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
config_file: &str,
|
config_file: &str,
|
||||||
expected_incarnation_id: Option<Uuid>,
|
expected_incarnation_id: Option<Uuid>,
|
||||||
|
updated_at: Option<OffsetDateTime>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
|
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
|
||||||
delete_under_config_write_guard(sys, &guard, config_file).await
|
delete_under_config_write_guard(sys, &guard, config_file, updated_at).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns the complete bucket-config mutation fence.
|
/// Owns the complete bucket-config mutation fence.
|
||||||
@@ -645,6 +698,12 @@ pub struct BucketMetadataMutationGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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<()> {
|
fn ensure_valid(&self, bucket: &str) -> Result<()> {
|
||||||
if self.bucket != bucket {
|
if self.bucket != bucket {
|
||||||
return Err(Error::other("bucket metadata mutation guard does not match bucket"));
|
return Err(Error::other("bucket metadata mutation guard does not match bucket"));
|
||||||
@@ -664,6 +723,29 @@ async fn acquire_config_write_guard_for_incarnation(
|
|||||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
sys: Arc<RwLock<BucketMetadataSys>>,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
expected_incarnation_id: Option<Uuid>,
|
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> {
|
) -> Result<BucketMetadataMutationGuard> {
|
||||||
let metadata_sys = sys.read().await.clone();
|
let metadata_sys = sys.read().await.clone();
|
||||||
let lifecycle_guard = metadata_sys.api.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
let lifecycle_guard = metadata_sys.api.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||||
@@ -671,6 +753,7 @@ async fn acquire_config_write_guard_for_incarnation(
|
|||||||
// Legacy buckets are migrated while the lifecycle fence prevents a
|
// Legacy buckets are migrated while the lifecycle fence prevents a
|
||||||
// same-name replacement. The second read under the write transaction is
|
// same-name replacement. The second read under the write transaction is
|
||||||
// the CAS source of truth for the actual rewrite.
|
// the CAS source of truth for the actual rewrite.
|
||||||
|
if migrate {
|
||||||
await_bucket_namespace_operation(
|
await_bucket_namespace_operation(
|
||||||
Some(&lifecycle_guard),
|
Some(&lifecycle_guard),
|
||||||
bucket,
|
bucket,
|
||||||
@@ -678,6 +761,7 @@ async fn acquire_config_write_guard_for_incarnation(
|
|||||||
metadata_sys.get_bucket_incarnation_id(bucket),
|
metadata_sys.get_bucket_incarnation_id(bucket),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
let transaction_guard = await_bucket_namespace_operation(
|
let transaction_guard = await_bucket_namespace_operation(
|
||||||
Some(&lifecycle_guard),
|
Some(&lifecycle_guard),
|
||||||
bucket,
|
bucket,
|
||||||
@@ -745,7 +829,21 @@ pub async fn update_under_transaction_lock(
|
|||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
guard.ensure_valid(bucket)?;
|
guard.ensure_valid(bucket)?;
|
||||||
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data).await
|
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`update_under_transaction_lock`] stamping the config with `updated_at`
|
||||||
|
/// (a replicated edit's source time) instead of the local clock; see
|
||||||
|
/// [`update_if_incarnation_at`] (backlog#2292).
|
||||||
|
pub async fn update_under_transaction_lock_at(
|
||||||
|
guard: &BucketMetadataMutationGuard,
|
||||||
|
bucket: &str,
|
||||||
|
config_file: &str,
|
||||||
|
data: Vec<u8>,
|
||||||
|
updated_at: OffsetDateTime,
|
||||||
|
) -> Result<OffsetDateTime> {
|
||||||
|
guard.ensure_valid(bucket)?;
|
||||||
|
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data, Some(updated_at)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear one config file while the caller holds this bucket's transaction lock.
|
/// Clear one config file while the caller holds this bucket's transaction lock.
|
||||||
@@ -755,7 +853,7 @@ pub async fn delete_under_transaction_lock(
|
|||||||
config_file: &str,
|
config_file: &str,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
guard.ensure_valid(bucket)?;
|
guard.ensure_valid(bucket)?;
|
||||||
delete_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file).await
|
delete_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_quota_if_incarnation(
|
pub async fn update_quota_if_incarnation(
|
||||||
@@ -763,6 +861,29 @@ pub async fn update_quota_if_incarnation(
|
|||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
expected_incarnation_id: Uuid,
|
expected_incarnation_id: Uuid,
|
||||||
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
||||||
|
) -> Result<OffsetDateTime> {
|
||||||
|
update_quota_if_incarnation_stamped(bucket, data, expected_incarnation_id, proof, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`update_quota_if_incarnation`] stamping the quota config with
|
||||||
|
/// `updated_at` (a replicated edit's source time) instead of the local
|
||||||
|
/// clock; see [`update_if_incarnation_at`] (backlog#2292).
|
||||||
|
pub async fn update_quota_if_incarnation_at(
|
||||||
|
bucket: &str,
|
||||||
|
data: Vec<u8>,
|
||||||
|
expected_incarnation_id: Uuid,
|
||||||
|
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
||||||
|
updated_at: OffsetDateTime,
|
||||||
|
) -> Result<OffsetDateTime> {
|
||||||
|
update_quota_if_incarnation_stamped(bucket, data, expected_incarnation_id, proof, Some(updated_at)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_quota_if_incarnation_stamped(
|
||||||
|
bucket: &str,
|
||||||
|
data: Vec<u8>,
|
||||||
|
expected_incarnation_id: Uuid,
|
||||||
|
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
||||||
|
updated_at: Option<OffsetDateTime>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
let sys = get_bucket_metadata_sys()?;
|
let sys = get_bucket_metadata_sys()?;
|
||||||
let guard = Box::pin(acquire_config_write_guard_for_incarnation(
|
let guard = Box::pin(acquire_config_write_guard_for_incarnation(
|
||||||
@@ -780,7 +901,7 @@ pub async fn update_quota_if_incarnation(
|
|||||||
achieved: 0,
|
achieved: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data).await
|
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data, updated_at).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_bucket_targets_under_transaction_lock(
|
pub async fn update_bucket_targets_under_transaction_lock(
|
||||||
@@ -796,6 +917,7 @@ async fn update_under_config_write_guard(
|
|||||||
guard: &BucketMetadataMutationGuard,
|
guard: &BucketMetadataMutationGuard,
|
||||||
config_file: &str,
|
config_file: &str,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
|
updated_at: Option<OffsetDateTime>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
guard.ensure_valid(&guard.bucket)?;
|
guard.ensure_valid(&guard.bucket)?;
|
||||||
let metadata_sys = sys.read().await.clone();
|
let metadata_sys = sys.read().await.clone();
|
||||||
@@ -807,7 +929,7 @@ async fn update_under_config_write_guard(
|
|||||||
Some(&guard.transaction_guard),
|
Some(&guard.transaction_guard),
|
||||||
&guard.bucket,
|
&guard.bucket,
|
||||||
"bucket config transaction",
|
"bucket config transaction",
|
||||||
metadata_sys.update_checked(&guard.bucket, config_file, data, true, guard.incarnation_id),
|
metadata_sys.update_checked(&guard.bucket, config_file, data, true, guard.incarnation_id, updated_at),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -819,6 +941,7 @@ async fn delete_under_config_write_guard(
|
|||||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
sys: Arc<RwLock<BucketMetadataSys>>,
|
||||||
guard: &BucketMetadataMutationGuard,
|
guard: &BucketMetadataMutationGuard,
|
||||||
config_file: &str,
|
config_file: &str,
|
||||||
|
updated_at: Option<OffsetDateTime>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
guard.ensure_valid(&guard.bucket)?;
|
guard.ensure_valid(&guard.bucket)?;
|
||||||
let metadata_sys = sys.read().await.clone();
|
let metadata_sys = sys.read().await.clone();
|
||||||
@@ -830,7 +953,7 @@ async fn delete_under_config_write_guard(
|
|||||||
Some(&guard.transaction_guard),
|
Some(&guard.transaction_guard),
|
||||||
&guard.bucket,
|
&guard.bucket,
|
||||||
"bucket config deletion transaction",
|
"bucket config deletion transaction",
|
||||||
metadata_sys.update_checked(&guard.bucket, config_file, Vec::new(), false, guard.incarnation_id),
|
metadata_sys.update_checked(&guard.bucket, config_file, Vec::new(), false, guard.incarnation_id, updated_at),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1008,15 +1131,21 @@ pub async fn get_durability_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The bucket's on-demand migration config with its update time, or
|
/// 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
|
/// `Ok(None)` when the bucket has none. Bytes are opaque to the metadata owner.
|
||||||
/// is a typed error (`OnDemandMigrationConfigError` inside `Error::Io`).
|
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||||
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_lock = get_bucket_metadata_sys()?;
|
||||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||||
|
|
||||||
bucket_meta_sys.get_on_demand_migration_config(bucket).await
|
bucket_meta_sys.get_on_demand_migration_config(bucket).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve opaque configuration from the store's own metadata system.
|
||||||
|
pub async fn get_on_demand_migration_config_in(api: &ECStore, bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||||
|
let sys = bucket_metadata_sys_of(&api.ctx)?;
|
||||||
|
let lock = sys.read().await;
|
||||||
|
lock.get_on_demand_migration_config(bucket).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
||||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||||
@@ -1729,15 +1858,17 @@ impl BucketMetadataSys {
|
|||||||
/// `update` and the config read alone). Keep these boxed.
|
/// `update` and the config read alone). Keep these boxed.
|
||||||
pub async fn update(&self, bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
pub async fn update(&self, bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||||
let incarnation_id = Box::pin(self.get_bucket_incarnation_id(bucket)).await?;
|
let incarnation_id = Box::pin(self.get_bucket_incarnation_id(bucket)).await?;
|
||||||
Box::pin(self.update_checked(bucket, config_file, data, true, incarnation_id)).await
|
Box::pin(self.update_checked(bucket, config_file, data, true, incarnation_id, None)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
pub async fn delete(&self, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
||||||
let incarnation_id = self.get_bucket_incarnation_id(bucket).await?;
|
let incarnation_id = self.get_bucket_incarnation_id(bucket).await?;
|
||||||
self.update_checked(bucket, config_file, Vec::new(), false, incarnation_id)
|
self.update_checked(bucket, config_file, Vec::new(), false, incarnation_id, None)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `updated_at`: `None` stamps the local clock; `Some` persists a
|
||||||
|
/// replicated edit's source time (backlog#2292).
|
||||||
async fn update_checked(
|
async fn update_checked(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -1745,6 +1876,7 @@ impl BucketMetadataSys {
|
|||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
parse: bool,
|
parse: bool,
|
||||||
expected_incarnation_id: Uuid,
|
expected_incarnation_id: Uuid,
|
||||||
|
updated_at: Option<OffsetDateTime>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
// Load through this system's own store, the one `save` persists to
|
// Load through this system's own store, the one `save` persists to
|
||||||
// (backlog#1052 S7). Reading from the ambient handle instead made the
|
// (backlog#1052 S7). Reading from the ambient handle instead made the
|
||||||
@@ -1755,7 +1887,10 @@ impl BucketMetadataSys {
|
|||||||
return Err(Error::BucketNotFound(bucket.to_string()));
|
return Err(Error::BucketNotFound(bucket.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = bm.update_config(config_file, data)?;
|
let updated = match updated_at {
|
||||||
|
Some(updated_at) => bm.update_config_at(config_file, data, updated_at)?,
|
||||||
|
None => bm.update_config(config_file, data)?,
|
||||||
|
};
|
||||||
|
|
||||||
Box::pin(self.save(bm)).await?;
|
Box::pin(self.save(bm)).await?;
|
||||||
|
|
||||||
@@ -2118,7 +2253,9 @@ impl BucketMetadataSys {
|
|||||||
pub async fn get_public_access_block_config(&self, bucket: &str) -> Result<(PublicAccessBlockConfiguration, OffsetDateTime)> {
|
pub async fn get_public_access_block_config(&self, bucket: &str) -> Result<(PublicAccessBlockConfiguration, OffsetDateTime)> {
|
||||||
let (bm, _) = self.get_config(bucket).await?;
|
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))
|
Ok((config.clone(), bm.public_access_block_config_updated_at))
|
||||||
} else {
|
} else {
|
||||||
Err(Error::ConfigNotFound)
|
Err(Error::ConfigNotFound)
|
||||||
@@ -2429,7 +2566,9 @@ impl BucketMetadataSys {
|
|||||||
pub async fn get_sse_config(&self, bucket: &str) -> Result<(ServerSideEncryptionConfiguration, OffsetDateTime)> {
|
pub async fn get_sse_config(&self, bucket: &str) -> Result<(ServerSideEncryptionConfiguration, OffsetDateTime)> {
|
||||||
let (bm, _) = self.get_config(bucket).await?;
|
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))
|
Ok((config.clone(), bm.encryption_config_updated_at))
|
||||||
} else {
|
} else {
|
||||||
Err(Error::ConfigNotFound)
|
Err(Error::ConfigNotFound)
|
||||||
@@ -2500,7 +2639,9 @@ impl BucketMetadataSys {
|
|||||||
pub async fn get_quota_config(&self, bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
pub async fn get_quota_config(&self, bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
||||||
let (bm, _) = self.get_config(bucket).await?;
|
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))
|
Ok((config.clone(), bm.quota_config_updated_at))
|
||||||
} else {
|
} else {
|
||||||
Err(Error::ConfigNotFound)
|
Err(Error::ConfigNotFound)
|
||||||
@@ -2522,7 +2663,9 @@ impl BucketMetadataSys {
|
|||||||
pub async fn get_bucket_targets_config(&self, bucket: &str) -> Result<BucketTargets> {
|
pub async fn get_bucket_targets_config(&self, bucket: &str) -> Result<BucketTargets> {
|
||||||
let (bm, _) = self.get_config(bucket).await?;
|
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())
|
Ok(config.clone())
|
||||||
} else {
|
} else {
|
||||||
Err(Error::ConfigNotFound)
|
Err(Error::ConfigNotFound)
|
||||||
@@ -2530,29 +2673,27 @@ impl BucketMetadataSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// See [`get_on_demand_migration_config`].
|
/// See [`get_on_demand_migration_config`].
|
||||||
pub async fn get_on_demand_migration_config(
|
pub async fn get_on_demand_migration_config(&self, bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
|
||||||
let (bm, _) = self.get_config(bucket).await?;
|
let (bm, _) = self.get_config(bucket).await?;
|
||||||
|
|
||||||
let config = bm.on_demand_migration_config().map_err(Error::other)?;
|
Ok(bm
|
||||||
Ok(config.map(|config| (config, bm.on_demand_migration_config_updated_at)))
|
.on_demand_migration_config()
|
||||||
|
.map(|(bytes, updated_at)| (bytes.to_vec(), updated_at)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-only fixture shared with sibling modules (e.g. the quota checker
|
/// Test-only fixture shared with sibling modules (e.g. the quota checker
|
||||||
/// tests): a 4-disk `ECStore` on an isolated instance context, so tests
|
/// tests): a 4-disk `ECStore` on an isolated instance context, so tests
|
||||||
/// exercising the metadata system never touch ambient process state.
|
/// exercising the metadata system never touch ambient process state.
|
||||||
#[cfg(test)]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
pub(crate) mod test_support {
|
pub mod test_support {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::disk::endpoint::Endpoint;
|
use crate::disk::endpoint::Endpoint;
|
||||||
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
|
||||||
use crate::runtime::instance::InstanceContext;
|
use crate::runtime::instance::InstanceContext;
|
||||||
use crate::store::init_local_disks_with_instance_ctx;
|
use crate::store::init_local_disks_with_instance_ctx;
|
||||||
|
|
||||||
pub(crate) async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||||
let mut dirs = Vec::with_capacity(4);
|
let mut dirs = Vec::with_capacity(4);
|
||||||
let mut endpoints = Vec::with_capacity(4);
|
let mut endpoints = Vec::with_capacity(4);
|
||||||
for disk_idx in 0..4 {
|
for disk_idx in 0..4 {
|
||||||
@@ -2593,6 +2734,7 @@ pub(crate) mod test_support {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::test_support::isolated_store_over_temp_disks;
|
use super::test_support::isolated_store_over_temp_disks;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::bucket::bucket_target_sys::BucketTargetError;
|
||||||
use crate::bucket::metadata::{
|
use crate::bucket::metadata::{
|
||||||
BUCKET_ACCELERATE_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_LOGGING_CONFIG, BUCKET_NOTIFICATION_CONFIG,
|
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,
|
BUCKET_POLICY_CONFIG, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_REPLICATION_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG,
|
||||||
@@ -2788,6 +2930,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]
|
#[tokio::test]
|
||||||
async fn config_states_distinguish_authoritative_absence_from_fabricated_metadata() {
|
async fn config_states_distinguish_authoritative_absence_from_fabricated_metadata() {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
@@ -3127,6 +3299,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]
|
#[tokio::test]
|
||||||
async fn old_node_metadata_rewrite_cannot_replace_bucket_incarnation_sidecar() {
|
async fn old_node_metadata_rewrite_cannot_replace_bucket_incarnation_sidecar() {
|
||||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||||
@@ -3609,6 +3857,106 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// backlog#2292: the explicit-stamp write path persists the given source
|
||||||
|
/// time as the config's `*_config_updated_at` — through the incarnation
|
||||||
|
/// path and through an already-held transaction guard — and survives a
|
||||||
|
/// reload from disk, while the plain path keeps stamping the local clock.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_updated_at_is_persisted_as_the_config_stamp() {
|
||||||
|
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||||
|
let bucket = "source-stamped-config";
|
||||||
|
for dir in &dirs {
|
||||||
|
std::fs::create_dir_all(dir.path().join(bucket)).expect("bucket volume should be created");
|
||||||
|
}
|
||||||
|
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
||||||
|
let source_time = OffsetDateTime::now_utc() - Duration::from_secs(3 * 3600);
|
||||||
|
let policy = br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec();
|
||||||
|
let tagging = b"<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag></TagSet></Tagging>".to_vec();
|
||||||
|
|
||||||
|
// Incarnation path (`update_if_incarnation_at` minus the ambient lookup).
|
||||||
|
let stamped =
|
||||||
|
update_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy.clone(), None, Some(source_time))
|
||||||
|
.await
|
||||||
|
.expect("source-stamped policy write should persist");
|
||||||
|
assert_eq!(stamped, source_time);
|
||||||
|
|
||||||
|
// Held-guard path (`update_under_transaction_lock_at` minus the ambient lookup).
|
||||||
|
let guard = acquire_config_write_guard(sys.clone(), bucket).await.expect("write guard");
|
||||||
|
let stamped = update_under_config_write_guard(sys.clone(), &guard, BUCKET_TAGGING_CONFIG, tagging, Some(source_time))
|
||||||
|
.await
|
||||||
|
.expect("source-stamped tagging write should persist");
|
||||||
|
drop(guard);
|
||||||
|
assert_eq!(stamped, source_time);
|
||||||
|
|
||||||
|
let metadata_sys = sys.read().await.clone();
|
||||||
|
metadata_sys.metadata_map.write().await.clear();
|
||||||
|
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
|
||||||
|
assert_eq!(reloaded.policy_config_updated_at, source_time);
|
||||||
|
assert_eq!(reloaded.tagging_config_updated_at, source_time);
|
||||||
|
|
||||||
|
// The plain path is unchanged: a local edit is stamped with the local clock.
|
||||||
|
let before = OffsetDateTime::now_utc();
|
||||||
|
let stamped = update_with_sys(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy)
|
||||||
|
.await
|
||||||
|
.expect("locally stamped policy write should persist");
|
||||||
|
assert!(stamped >= before, "the plain write path must keep stamping the local clock");
|
||||||
|
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
|
||||||
|
assert_eq!(reloaded.policy_config_updated_at, stamped);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.tagging_config_updated_at, source_time,
|
||||||
|
"an unrelated config keeps its source stamp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// backlog#2292: a replicated delete persists the source time as the
|
||||||
|
/// cleared config's `*_config_updated_at`, so the receive-side gate
|
||||||
|
/// (source time against stored stamp) lets a newer source re-create land
|
||||||
|
/// even when the delete was applied later than the re-create's source
|
||||||
|
/// time; the plain delete keeps stamping the local clock.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_updated_at_is_persisted_by_a_delete() {
|
||||||
|
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||||
|
let bucket = "source-stamped-delete";
|
||||||
|
for dir in &dirs {
|
||||||
|
std::fs::create_dir_all(dir.path().join(bucket)).expect("bucket volume should be created");
|
||||||
|
}
|
||||||
|
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
||||||
|
let policy = br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec();
|
||||||
|
let created_at = OffsetDateTime::now_utc() - Duration::from_secs(3 * 3600);
|
||||||
|
let deleted_at = created_at + Duration::from_secs(60);
|
||||||
|
let recreated_at = deleted_at + Duration::from_secs(60);
|
||||||
|
|
||||||
|
update_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy.clone(), None, Some(created_at))
|
||||||
|
.await
|
||||||
|
.expect("source-stamped policy write should persist");
|
||||||
|
let stamped = delete_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, None, Some(deleted_at))
|
||||||
|
.await
|
||||||
|
.expect("source-stamped policy delete should persist");
|
||||||
|
assert_eq!(stamped, deleted_at);
|
||||||
|
|
||||||
|
let metadata_sys = sys.read().await.clone();
|
||||||
|
metadata_sys.metadata_map.write().await.clear();
|
||||||
|
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
|
||||||
|
assert!(reloaded.policy_config_json.is_empty(), "the delete cleared the payload");
|
||||||
|
assert_eq!(reloaded.policy_config_updated_at, deleted_at, "the delete kept the source stamp");
|
||||||
|
assert!(
|
||||||
|
recreated_at >= reloaded.policy_config_updated_at,
|
||||||
|
"a re-create newer than the delete's source time is not stale against the stored stamp"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The plain delete path is unchanged: stamped with the local clock.
|
||||||
|
update_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy, None, Some(recreated_at))
|
||||||
|
.await
|
||||||
|
.expect("re-create should persist");
|
||||||
|
let before = OffsetDateTime::now_utc();
|
||||||
|
let stamped = delete_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, None, None)
|
||||||
|
.await
|
||||||
|
.expect("locally stamped delete should persist");
|
||||||
|
assert!(stamped >= before, "the plain delete path must keep stamping the local clock");
|
||||||
|
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
|
||||||
|
assert_eq!(reloaded.policy_config_updated_at, stamped);
|
||||||
|
}
|
||||||
|
|
||||||
/// The load and the persisted write share one write guard, so concurrent
|
/// The load and the persisted write share one write guard, so concurrent
|
||||||
/// rewrites of the same config compose instead of clobbering each other.
|
/// rewrites of the same config compose instead of clobbering each other.
|
||||||
/// Moving the load outside that guard loses all but the last tag.
|
/// Moving the load outside that guard loses all but the last tag.
|
||||||
@@ -3825,8 +4173,14 @@ mod tests {
|
|||||||
let new_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.unwrap();
|
let new_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.unwrap();
|
||||||
assert_ne!(old_incarnation, new_incarnation);
|
assert_ne!(old_incarnation, new_incarnation);
|
||||||
|
|
||||||
let err =
|
let err = update_with_sys_expected(
|
||||||
update_with_sys_expected(sys.clone(), bucket, BUCKET_TAGGING_CONFIG, b"<Tagging/>".to_vec(), Some(old_incarnation))
|
sys.clone(),
|
||||||
|
bucket,
|
||||||
|
BUCKET_TAGGING_CONFIG,
|
||||||
|
b"<Tagging/>".to_vec(),
|
||||||
|
Some(old_incarnation),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("a request authorized for the deleted incarnation must fail closed");
|
.expect_err("a request authorized for the deleted incarnation must fail closed");
|
||||||
assert!(matches!(err, Error::BucketNotFound(name) if name == bucket));
|
assert!(matches!(err, Error::BucketNotFound(name) if name == bucket));
|
||||||
@@ -3863,7 +4217,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
update_under_config_write_guard(sys, &guard, BUCKET_TAGGING_CONFIG, tagging)
|
update_under_config_write_guard(sys, &guard, BUCKET_TAGGING_CONFIG, tagging, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!delete.is_finished());
|
assert!(!delete.is_finished());
|
||||||
@@ -4066,6 +4420,114 @@ mod tests {
|
|||||||
target_sys.delete(bucket).await;
|
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]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn metadata_reload_clears_stale_bucket_targets_when_config_is_removed() {
|
async fn metadata_reload_clears_stale_bucket_targets_when_config_is_removed() {
|
||||||
@@ -4121,19 +4583,26 @@ mod tests {
|
|||||||
|
|
||||||
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"}}}"#;
|
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"}}}"#;
|
||||||
|
|
||||||
|
type RecordedOdmConfig = Option<(Vec<u8>, OffsetDateTime, Uuid)>;
|
||||||
|
type RecordedOdmHookCall = (String, RecordedOdmConfig);
|
||||||
|
|
||||||
/// Every `(bucket, config)` the recording hook has seen. Tests filter by
|
/// Every `(bucket, config)` the recording hook has seen. Tests filter by
|
||||||
/// their own bucket name; the hook is process-wide and set once.
|
/// 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());
|
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<RecordedOdmHookCall>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
fn install_recording_odm_hook() {
|
fn install_recording_odm_hook() {
|
||||||
ON_DEMAND_MIGRATION_CONFIG_HOOK.get_or_init(|| {
|
BUCKET_CONFIG_PUBLISH_HOOK.get_or_init(|| {
|
||||||
Box::new(|bucket, config| {
|
Box::new(|bucket, config_file, config| {
|
||||||
ODM_HOOK_CALLS.lock().unwrap().push((bucket.to_string(), config.cloned()));
|
assert_eq!(config_file, super::super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG);
|
||||||
|
ODM_HOOK_CALLS.lock().unwrap().push((
|
||||||
|
bucket.to_string(),
|
||||||
|
config.map(|(bytes, stamp, incarnation)| (bytes.to_vec(), stamp, incarnation)),
|
||||||
|
));
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn odm_hook_calls(bucket: &str) -> Vec<Option<OnDemandMigrationConfig>> {
|
fn odm_hook_calls(bucket: &str) -> Vec<RecordedOdmConfig> {
|
||||||
ODM_HOOK_CALLS
|
ODM_HOOK_CALLS
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -4143,54 +4612,6 @@ mod tests {
|
|||||||
.collect()
|
.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
|
/// rustfs/backlog#2148: the publish hook fires on every path that
|
||||||
/// installs bucket metadata into the cache (set, initial load, peer
|
/// installs bucket metadata into the cache (set, initial load, peer
|
||||||
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring
|
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring
|
||||||
@@ -4204,15 +4625,22 @@ mod tests {
|
|||||||
for dir in &dirs {
|
for dir in &dirs {
|
||||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
|
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
|
||||||
}
|
}
|
||||||
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
let expect_publish = |before: usize, label: &str| {
|
let expect_publish = |before: usize, label: &str| {
|
||||||
let calls = odm_hook_calls(bucket);
|
let calls = odm_hook_calls(bucket);
|
||||||
assert_eq!(calls.len(), before + 1, "{label} must publish exactly once");
|
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");
|
assert_eq!(
|
||||||
|
calls.last().unwrap().as_ref().map(|(bytes, _, _)| bytes.as_slice()),
|
||||||
|
Some(ODM_JSON),
|
||||||
|
"{label} must publish the stored bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(calls.last().unwrap().as_ref().map(|(_, _, id)| *id), Some(incarnation));
|
||||||
};
|
};
|
||||||
|
|
||||||
// set (via persist_new_and_set, which installs through `set`).
|
// set (via persist_new_and_set, which installs through `set`).
|
||||||
let mut bm = BucketMetadata::new(bucket);
|
let mut bm = BucketMetadata::new(bucket);
|
||||||
|
bm.bucket_incarnation_id = incarnation;
|
||||||
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let writer = BucketMetadataSys::new(ecstore.clone());
|
let writer = BucketMetadataSys::new(ecstore.clone());
|
||||||
@@ -4254,14 +4682,18 @@ mod tests {
|
|||||||
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
|
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
|
||||||
assert_eq!(calls.last().unwrap(), &None);
|
assert_eq!(calls.last().unwrap(), &None);
|
||||||
|
|
||||||
// A corrupt payload is withdrawn, never published as a config.
|
// Opaque bytes reach the application even if they are not valid JSON.
|
||||||
let mut corrupt = BucketMetadata::new(bucket);
|
let mut corrupt = BucketMetadata::new(bucket);
|
||||||
corrupt.on_demand_migration_config_json = b"not-json".to_vec();
|
corrupt.on_demand_migration_config_json = b"not-json".to_vec();
|
||||||
let before = odm_hook_calls(bucket).len();
|
let before = odm_hook_calls(bucket).len();
|
||||||
lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
|
lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
|
||||||
let calls = odm_hook_calls(bucket);
|
let calls = odm_hook_calls(bucket);
|
||||||
assert_eq!(calls.len(), before + 1);
|
assert_eq!(calls.len(), before + 1);
|
||||||
assert_eq!(calls.last().unwrap(), &None, "unreadable config must publish absence");
|
assert_eq!(
|
||||||
|
calls.last().unwrap().as_ref().map(|(bytes, _, _)| bytes.as_slice()),
|
||||||
|
Some(b"not-json".as_slice()),
|
||||||
|
"the application validates opaque config bytes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ mod metadata_test;
|
|||||||
pub mod migration;
|
pub mod migration;
|
||||||
mod msgp_decode;
|
mod msgp_decode;
|
||||||
pub mod object_lock;
|
pub mod object_lock;
|
||||||
pub mod on_demand_migration;
|
|
||||||
pub mod policy_sys;
|
pub mod policy_sys;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
pub mod remote_s3_client;
|
pub mod remote_s3_client;
|
||||||
|
|||||||
@@ -1,824 +0,0 @@
|
|||||||
// Copyright 2024 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
//! Optional `ListObjectsV2` list-through (`policy.list_through`,
|
|
||||||
//! rustfs/backlog#2164): the local listing and the source listing are merged
|
|
||||||
//! into one ordered page so clients see the whole namespace while a bucket is
|
|
||||||
//! migrating.
|
|
||||||
//!
|
|
||||||
//! Everything here is pure. The handler owns the I/O and the payloads; this
|
|
||||||
//! module owns the ordering, the page boundary, and the opaque continuation
|
|
||||||
//! token that carries both cursors.
|
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
/// The only continuation-token envelope version this build reads and writes.
|
|
||||||
pub const LIST_THROUGH_TOKEN_VERSION: u32 = 1;
|
|
||||||
|
|
||||||
/// Envelope marker. A bucket that is *not* merging hands out the local
|
|
||||||
/// listing's own marker, so the decoder needs a positive signal before it
|
|
||||||
/// treats an opaque token as a merged one.
|
|
||||||
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
|
|
||||||
|
|
||||||
/// Pages fetched per side per request: the first page, plus at most one refill
|
|
||||||
/// when the first one was mostly consumed by the previous page. Two pages of
|
|
||||||
/// `max_keys` always cover a full merged page, so this is a bound, not a
|
|
||||||
/// heuristic.
|
|
||||||
pub const MAX_LIST_FETCHES_PER_SIDE: usize = 2;
|
|
||||||
|
|
||||||
/// Per-bucket ceiling on source `ListObjectsV2` calls, in calls per second.
|
|
||||||
pub const SOURCE_LIST_RATE_PER_SEC: u32 = 10;
|
|
||||||
|
|
||||||
/// How long a listing may wait for a source rate-limit slot before it gives up
|
|
||||||
/// and answers from local state alone.
|
|
||||||
pub const SOURCE_LIST_MAX_RATE_WAIT: Duration = Duration::from_secs(1);
|
|
||||||
|
|
||||||
/// One listing entry as the merge orders it: an object key, or — under a
|
|
||||||
/// delimiter — a rolled-up common prefix. Both sort by `name` alone, which is
|
|
||||||
/// how S3 interleaves `Contents` and `CommonPrefixes` on the wire.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ListEntryKey {
|
|
||||||
pub name: String,
|
|
||||||
pub is_prefix: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListEntryKey {
|
|
||||||
pub fn object(name: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.into(),
|
|
||||||
is_prefix: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prefix(name: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.into(),
|
|
||||||
is_prefix: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum MergeSide {
|
|
||||||
Local,
|
|
||||||
Source,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One entry of the merged page: the side it came from and its index in that
|
|
||||||
/// side's buffer, in push order. The caller keeps the payloads.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub struct MergePick {
|
|
||||||
pub side: MergeSide,
|
|
||||||
pub index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The continuation-token envelope. Opaque to clients: it is serialized as
|
|
||||||
/// JSON and then base64-encoded by the same helper that encodes a plain local
|
|
||||||
/// marker, so the wire shape is `base64(json)`.
|
|
||||||
///
|
|
||||||
/// A `null` cursor with `done = false` means "list that side from the start";
|
|
||||||
/// `done = true` means the side is finished and must not be listed again.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct ListThroughToken {
|
|
||||||
/// Envelope marker, always [`LIST_THROUGH_TOKEN_TAG`].
|
|
||||||
pub t: String,
|
|
||||||
pub v: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub local: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub local_done: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub source: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub source_done: bool,
|
|
||||||
/// Last entry the previous page consumed. A side whose page was only
|
|
||||||
/// partially consumed is re-listed from the same cursor and everything at
|
|
||||||
/// or below this key is dropped, which is delimiter-safe: a rolled-up
|
|
||||||
/// common prefix compares as itself, never as its members.
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_key: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListThroughToken {
|
|
||||||
fn new(local: SideCursor, source: SideCursor, last_key: Option<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
t: LIST_THROUGH_TOKEN_TAG.to_string(),
|
|
||||||
v: LIST_THROUGH_TOKEN_VERSION,
|
|
||||||
local: local.token,
|
|
||||||
local_done: local.done,
|
|
||||||
source: source.token,
|
|
||||||
source_done: source.done,
|
|
||||||
last_key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode(&self) -> String {
|
|
||||||
// The envelope is built here from owned strings, so serialization
|
|
||||||
// cannot fail; the fallback keeps the signature infallible.
|
|
||||||
serde_json::to_string(self).unwrap_or_default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a decoded (base64-stripped) continuation token turned out to be.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum ListThroughCursor {
|
|
||||||
/// A plain local listing marker: the bucket was not merging when the token
|
|
||||||
/// was issued, or the client is paginating a non-merged listing.
|
|
||||||
Local(String),
|
|
||||||
Merged(Box<ListThroughToken>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
|
||||||
pub enum ListThroughTokenError {
|
|
||||||
#[error("continuation token version {0} is not supported")]
|
|
||||||
UnsupportedVersion(u32),
|
|
||||||
/// The message never echoes the token: it is client-controlled input.
|
|
||||||
#[error("continuation token is malformed")]
|
|
||||||
Malformed,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classifies an already base64-decoded continuation token.
|
|
||||||
///
|
|
||||||
/// Only a JSON object carrying the envelope marker is read as a merged token;
|
|
||||||
/// anything else is a local marker, so a bucket that turns `list_through` off
|
|
||||||
/// keeps paginating with the tokens it handed out. A token that *is* an
|
|
||||||
/// envelope but was tampered with (unknown version, unknown field, truncated
|
|
||||||
/// JSON) is an error, never a silent fallback.
|
|
||||||
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
|
|
||||||
if !decoded.starts_with('{') {
|
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
|
||||||
}
|
|
||||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(decoded) else {
|
|
||||||
// Not JSON at all: an object key may legitimately start with '{'.
|
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
|
||||||
};
|
|
||||||
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
|
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
|
||||||
}
|
|
||||||
match value.get("v").and_then(serde_json::Value::as_u64) {
|
|
||||||
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {}
|
|
||||||
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
|
|
||||||
None => return Err(ListThroughTokenError::Malformed),
|
|
||||||
}
|
|
||||||
serde_json::from_value::<ListThroughToken>(value)
|
|
||||||
.map(|token| ListThroughCursor::Merged(Box::new(token)))
|
|
||||||
.map_err(|_| ListThroughTokenError::Malformed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How the source must be listed for a request, given `filter.prefix`.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum SourceListPlan {
|
|
||||||
/// The request prefix and `filter.prefix` are disjoint: the source holds
|
|
||||||
/// nothing this listing could show.
|
|
||||||
Skip,
|
|
||||||
/// Ordinary paged listing under `prefix`, rolled up with the request's
|
|
||||||
/// delimiter — the source's own roll-up boundary matches the request's.
|
|
||||||
Page { prefix: String },
|
|
||||||
/// `filter.prefix` reaches past a delimiter, so every key the source could
|
|
||||||
/// contribute rolls into this one common prefix. One bounded probe listing
|
|
||||||
/// decides whether it exists; there is nothing to paginate.
|
|
||||||
Folded { probe_prefix: String, common_prefix: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Intersects the request prefix with `filter.prefix` and decides how (or
|
|
||||||
/// whether) the source is listed.
|
|
||||||
pub fn source_list_plan(request_prefix: &str, filter_prefix: Option<&str>, delimiter: Option<&str>) -> SourceListPlan {
|
|
||||||
let filter = filter_prefix.unwrap_or_default();
|
|
||||||
let source_prefix = if filter.starts_with(request_prefix) {
|
|
||||||
filter
|
|
||||||
} else if request_prefix.starts_with(filter) {
|
|
||||||
request_prefix
|
|
||||||
} else {
|
|
||||||
return SourceListPlan::Skip;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(delimiter) = delimiter.filter(|delimiter| !delimiter.is_empty()) else {
|
|
||||||
return SourceListPlan::Page {
|
|
||||||
prefix: source_prefix.to_string(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// `source_prefix` always starts with `request_prefix`, so this slice is on
|
|
||||||
// a character boundary.
|
|
||||||
let extra = &source_prefix[request_prefix.len()..];
|
|
||||||
match extra.find(delimiter) {
|
|
||||||
Some(at) => SourceListPlan::Folded {
|
|
||||||
probe_prefix: source_prefix.to_string(),
|
|
||||||
common_prefix: format!("{request_prefix}{}", &extra[..at + delimiter.len()]),
|
|
||||||
},
|
|
||||||
None => SourceListPlan::Page {
|
|
||||||
prefix: source_prefix.to_string(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where one side resumes.
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
||||||
pub struct SideCursor {
|
|
||||||
pub token: Option<String>,
|
|
||||||
pub done: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One page a side actually fetched this round.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
struct FetchedPage {
|
|
||||||
/// Token it was fetched with; `None` means from the start of the listing.
|
|
||||||
token: Option<String>,
|
|
||||||
/// Entries it contributed to the buffer, after the `last_key` filter.
|
|
||||||
count: usize,
|
|
||||||
/// Cursor for the page after it, `None` when it was the last one.
|
|
||||||
next_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where a side resumes after `consumed` of its buffered entries were taken.
|
|
||||||
///
|
|
||||||
/// A fully consumed page advances to its successor; a partially consumed one
|
|
||||||
/// is re-listed from the same cursor next time and re-filtered by `last_key`.
|
|
||||||
fn advance_cursor(pages: &[FetchedPage], consumed: usize) -> SideCursor {
|
|
||||||
let mut remaining = consumed;
|
|
||||||
let mut cursor = SideCursor { token: None, done: true };
|
|
||||||
for page in pages {
|
|
||||||
if remaining >= page.count {
|
|
||||||
remaining -= page.count;
|
|
||||||
cursor = match &page.next_token {
|
|
||||||
Some(next) => SideCursor {
|
|
||||||
token: Some(next.clone()),
|
|
||||||
done: false,
|
|
||||||
},
|
|
||||||
None => SideCursor { token: None, done: true },
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
cursor = SideCursor {
|
|
||||||
token: page.token.clone(),
|
|
||||||
done: false,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A page the merge driver still needs.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct FetchRequest {
|
|
||||||
pub side: MergeSide,
|
|
||||||
pub token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct SideState {
|
|
||||||
start: SideCursor,
|
|
||||||
pages: Vec<FetchedPage>,
|
|
||||||
entries: Vec<ListEntryKey>,
|
|
||||||
more: bool,
|
|
||||||
disabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SideState {
|
|
||||||
fn from_cursor(token: Option<String>, done: bool) -> Self {
|
|
||||||
Self {
|
|
||||||
start: SideCursor { token, done },
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn needs_page(&self, max_keys: usize) -> Option<Option<String>> {
|
|
||||||
if self.disabled || self.start.done {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match self.pages.last() {
|
|
||||||
None => Some(self.start.token.clone()),
|
|
||||||
Some(last) => {
|
|
||||||
let room = self.entries.len() < max_keys;
|
|
||||||
let capped = self.pages.len() >= MAX_LIST_FETCHES_PER_SIDE;
|
|
||||||
(self.more && room && !capped).then(|| last.next_token.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The merged page, once both sides have handed over everything they will.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct MergeOutcome {
|
|
||||||
/// Entries of the merged page, in wire order; indices point into each
|
|
||||||
/// side's buffer in push order.
|
|
||||||
pub picks: Vec<MergePick>,
|
|
||||||
pub is_truncated: bool,
|
|
||||||
/// `Some` exactly when `is_truncated`.
|
|
||||||
pub next_token: Option<ListThroughToken>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drives one merged page: the caller asks [`Self::next_fetch`] what to list,
|
|
||||||
/// hands the page back with [`Self::push_page`], and finishes with
|
|
||||||
/// [`Self::finish`]. Nothing here does I/O, so the same driver is exercised by
|
|
||||||
/// the property test and by the handler.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ListThroughMerger {
|
|
||||||
max_keys: usize,
|
|
||||||
last_key: Option<String>,
|
|
||||||
local: SideState,
|
|
||||||
source: SideState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListThroughMerger {
|
|
||||||
/// `token` is the envelope from the client's continuation token, absent on
|
|
||||||
/// the first page of a listing.
|
|
||||||
pub fn new(max_keys: usize, token: Option<&ListThroughToken>) -> Self {
|
|
||||||
let (local, source, last_key) = match token {
|
|
||||||
Some(token) => (
|
|
||||||
SideState::from_cursor(token.local.clone(), token.local_done),
|
|
||||||
SideState::from_cursor(token.source.clone(), token.source_done),
|
|
||||||
token.last_key.clone(),
|
|
||||||
),
|
|
||||||
None => (SideState::default(), SideState::default(), None),
|
|
||||||
};
|
|
||||||
Self {
|
|
||||||
max_keys,
|
|
||||||
last_key,
|
|
||||||
local,
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether an entry the listing returned still belongs to this page: a
|
|
||||||
/// re-listed page repeats what the previous page already consumed.
|
|
||||||
pub fn accepts(&self, name: &str) -> bool {
|
|
||||||
self.last_key.as_deref().is_none_or(|bound| name > bound)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The source contributes nothing to this page: it failed, is rate-limited,
|
|
||||||
/// or `filter.prefix` excludes it.
|
|
||||||
pub fn disable_source(&mut self) {
|
|
||||||
self.source.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_fetch(&self) -> Option<FetchRequest> {
|
|
||||||
for (side, state) in [(MergeSide::Local, &self.local), (MergeSide::Source, &self.source)] {
|
|
||||||
if let Some(token) = state.needs_page(self.max_keys) {
|
|
||||||
return Some(FetchRequest { side, token });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records one fetched page. `entries` must be sorted by `name` and already
|
|
||||||
/// filtered with [`Self::accepts`]; the caller keeps the matching payloads
|
|
||||||
/// in the same order.
|
|
||||||
pub fn push_page(&mut self, side: MergeSide, entries: Vec<ListEntryKey>, is_truncated: bool, next_token: Option<String>) {
|
|
||||||
let state = match side {
|
|
||||||
MergeSide::Local => &mut self.local,
|
|
||||||
MergeSide::Source => &mut self.source,
|
|
||||||
};
|
|
||||||
let token = match state.pages.last() {
|
|
||||||
Some(last) => last.next_token.clone(),
|
|
||||||
None => state.start.token.clone(),
|
|
||||||
};
|
|
||||||
// A truncated page without a cursor cannot be continued; treating the
|
|
||||||
// side as finished is the only alternative to looping on it forever.
|
|
||||||
state.more = is_truncated && next_token.is_some();
|
|
||||||
state.pages.push(FetchedPage {
|
|
||||||
token,
|
|
||||||
count: entries.len(),
|
|
||||||
next_token: is_truncated.then_some(next_token).flatten(),
|
|
||||||
});
|
|
||||||
state.entries.extend(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn finish(self) -> MergeOutcome {
|
|
||||||
let Self {
|
|
||||||
max_keys,
|
|
||||||
last_key,
|
|
||||||
local,
|
|
||||||
source,
|
|
||||||
} = self;
|
|
||||||
|
|
||||||
// A side with more pages behind it can only be trusted up to the last
|
|
||||||
// key it handed over: past that horizon the other side's entries could
|
|
||||||
// still be deduplicated by one we have not seen, which is what keeps
|
|
||||||
// "local wins on equal keys" true across page boundaries.
|
|
||||||
let horizon = [
|
|
||||||
local
|
|
||||||
.more
|
|
||||||
.then(|| local.entries.last().map_or("", |entry| entry.name.as_str())),
|
|
||||||
source
|
|
||||||
.more
|
|
||||||
.then(|| source.entries.last().map_or("", |entry| entry.name.as_str())),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.min();
|
|
||||||
|
|
||||||
let mut picks = Vec::with_capacity(max_keys.min(local.entries.len() + source.entries.len()));
|
|
||||||
let mut consumed_local = 0usize;
|
|
||||||
let mut consumed_source = 0usize;
|
|
||||||
let mut consumed_key: Option<String> = None;
|
|
||||||
|
|
||||||
while picks.len() < max_keys {
|
|
||||||
let next_local = local.entries.get(consumed_local).map(|entry| entry.name.as_str());
|
|
||||||
let next_source = source.entries.get(consumed_source).map(|entry| entry.name.as_str());
|
|
||||||
let name = match (next_local, next_source) {
|
|
||||||
(None, None) => break,
|
|
||||||
(Some(name), None) | (None, Some(name)) => name,
|
|
||||||
(Some(left), Some(right)) => left.min(right),
|
|
||||||
};
|
|
||||||
if horizon.is_some_and(|horizon| name > horizon) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let take_local = next_local == Some(name);
|
|
||||||
let take_source = next_source == Some(name);
|
|
||||||
consumed_key = Some(name.to_string());
|
|
||||||
if take_local {
|
|
||||||
picks.push(MergePick {
|
|
||||||
side: MergeSide::Local,
|
|
||||||
index: consumed_local,
|
|
||||||
});
|
|
||||||
consumed_local += 1;
|
|
||||||
} else {
|
|
||||||
picks.push(MergePick {
|
|
||||||
side: MergeSide::Source,
|
|
||||||
index: consumed_source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if take_source {
|
|
||||||
consumed_source += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let local_cursor = advance_cursor(&local.pages, consumed_local);
|
|
||||||
let source_cursor = if source.disabled {
|
|
||||||
// Keep the source where it was so a recovered source resumes there;
|
|
||||||
// this page is answered from local state alone.
|
|
||||||
source.start.clone()
|
|
||||||
} else {
|
|
||||||
advance_cursor(&source.pages, consumed_source)
|
|
||||||
};
|
|
||||||
let local_left = !local_cursor.done || consumed_local < local.entries.len();
|
|
||||||
let source_left = !source.disabled && (!source_cursor.done || consumed_source < source.entries.len());
|
|
||||||
let is_truncated = local_left || source_left;
|
|
||||||
|
|
||||||
let last_key = consumed_key.or(last_key);
|
|
||||||
MergeOutcome {
|
|
||||||
picks,
|
|
||||||
is_truncated,
|
|
||||||
next_token: is_truncated.then(|| ListThroughToken::new(local_cursor, source_cursor, last_key)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Token bucket capping source `ListObjectsV2` calls for one bucket.
|
|
||||||
///
|
|
||||||
/// A caller that cannot be served inside its budget is refused rather than
|
|
||||||
/// queued: a listing degrades to local state instead of holding the request
|
|
||||||
/// open behind other tenants' listings.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct SourceListRateLimiter {
|
|
||||||
rate_per_sec: f64,
|
|
||||||
burst: f64,
|
|
||||||
state: Mutex<RateLimiterState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct RateLimiterState {
|
|
||||||
tokens: f64,
|
|
||||||
updated_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SourceListRateLimiter {
|
|
||||||
pub fn new(rate_per_sec: u32) -> Self {
|
|
||||||
let rate_per_sec = f64::from(rate_per_sec.max(1));
|
|
||||||
Self {
|
|
||||||
rate_per_sec,
|
|
||||||
burst: rate_per_sec,
|
|
||||||
state: Mutex::new(RateLimiterState {
|
|
||||||
tokens: rate_per_sec,
|
|
||||||
updated_at: Instant::now(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reserves one call, returning how long the caller must wait before making
|
|
||||||
/// it, or `None` when that wait would exceed `max_wait` (nothing is
|
|
||||||
/// reserved then).
|
|
||||||
pub fn reserve(&self, max_wait: Duration) -> Option<Duration> {
|
|
||||||
self.reserve_at(Instant::now(), max_wait)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reserve_at(&self, now: Instant, max_wait: Duration) -> Option<Duration> {
|
|
||||||
let mut state = self.state.lock();
|
|
||||||
let elapsed = now.saturating_duration_since(state.updated_at).as_secs_f64();
|
|
||||||
state.tokens = (state.tokens + elapsed * self.rate_per_sec).min(self.burst);
|
|
||||||
state.updated_at = now;
|
|
||||||
if state.tokens >= 1.0 {
|
|
||||||
state.tokens -= 1.0;
|
|
||||||
return Some(Duration::ZERO);
|
|
||||||
}
|
|
||||||
let wait = Duration::from_secs_f64((1.0 - state.tokens) / self.rate_per_sec);
|
|
||||||
if wait > max_wait {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
state.tokens -= 1.0;
|
|
||||||
Some(wait)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SourceListRateLimiter {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new(SOURCE_LIST_RATE_PER_SEC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use proptest::prelude::*;
|
|
||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
/// One `ListObjectsV2` page over a sorted key set, with the S3 rules the
|
|
||||||
/// merge relies on: delimiter roll-up, `max_keys`, and a continuation
|
|
||||||
/// token that resumes after the last entry the page returned.
|
|
||||||
fn reference_page(
|
|
||||||
keys: &[String],
|
|
||||||
prefix: &str,
|
|
||||||
delimiter: Option<&str>,
|
|
||||||
after: Option<&str>,
|
|
||||||
max_keys: usize,
|
|
||||||
) -> (Vec<ListEntryKey>, bool, Option<String>) {
|
|
||||||
let mut entries: Vec<ListEntryKey> = Vec::new();
|
|
||||||
for key in keys.iter().filter(|key| key.starts_with(prefix)) {
|
|
||||||
let entry = match delimiter.and_then(|delimiter| key[prefix.len()..].find(delimiter).map(|at| (delimiter, at))) {
|
|
||||||
Some((delimiter, at)) => ListEntryKey::prefix(&key[..prefix.len() + at + delimiter.len()]),
|
|
||||||
None => ListEntryKey::object(key.clone()),
|
|
||||||
};
|
|
||||||
if entries.last().is_none_or(|last| last.name != entry.name) {
|
|
||||||
entries.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(after) = after {
|
|
||||||
entries.retain(|entry| entry.name.as_str() > after);
|
|
||||||
}
|
|
||||||
let truncated = entries.len() > max_keys;
|
|
||||||
entries.truncate(max_keys);
|
|
||||||
let next = truncated.then(|| entries.last().map(|entry| entry.name.clone())).flatten();
|
|
||||||
(entries, truncated && next.is_some(), next)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full pagination through the merger, returning every entry it emitted and
|
|
||||||
/// the page sizes it produced.
|
|
||||||
fn walk(
|
|
||||||
local: &[String],
|
|
||||||
source: &[String],
|
|
||||||
prefix: &str,
|
|
||||||
delimiter: Option<&str>,
|
|
||||||
max_keys: usize,
|
|
||||||
) -> (Vec<(ListEntryKey, MergeSide)>, Vec<usize>) {
|
|
||||||
let mut emitted = Vec::new();
|
|
||||||
let mut page_sizes = Vec::new();
|
|
||||||
let mut token: Option<ListThroughToken> = None;
|
|
||||||
for _ in 0..10_000 {
|
|
||||||
let mut merger = ListThroughMerger::new(max_keys, token.as_ref());
|
|
||||||
let mut buffers = [Vec::<ListEntryKey>::new(), Vec::<ListEntryKey>::new()];
|
|
||||||
while let Some(fetch) = merger.next_fetch() {
|
|
||||||
let keys = match fetch.side {
|
|
||||||
MergeSide::Local => local,
|
|
||||||
MergeSide::Source => source,
|
|
||||||
};
|
|
||||||
let (entries, truncated, next) = reference_page(keys, prefix, delimiter, fetch.token.as_deref(), max_keys);
|
|
||||||
let kept: Vec<ListEntryKey> = entries.into_iter().filter(|entry| merger.accepts(&entry.name)).collect();
|
|
||||||
buffers[usize::from(fetch.side == MergeSide::Source)].extend(kept.iter().cloned());
|
|
||||||
merger.push_page(fetch.side, kept, truncated, next);
|
|
||||||
}
|
|
||||||
let outcome = merger.finish();
|
|
||||||
page_sizes.push(outcome.picks.len());
|
|
||||||
for pick in &outcome.picks {
|
|
||||||
let entry = buffers[usize::from(pick.side == MergeSide::Source)][pick.index].clone();
|
|
||||||
emitted.push((entry, pick.side));
|
|
||||||
}
|
|
||||||
if !outcome.is_truncated {
|
|
||||||
return (emitted, page_sizes);
|
|
||||||
}
|
|
||||||
token = outcome.next_token;
|
|
||||||
}
|
|
||||||
panic!("merged pagination did not terminate");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expected(local: &[String], source: &[String], prefix: &str, delimiter: Option<&str>) -> Vec<ListEntryKey> {
|
|
||||||
let mut all: Vec<String> = local.iter().chain(source.iter()).cloned().collect();
|
|
||||||
all.sort();
|
|
||||||
all.dedup();
|
|
||||||
let (entries, _, _) = reference_page(&all, prefix, delimiter, None, usize::MAX);
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reference_page_rolls_up_and_paginates() {
|
|
||||||
let keys = vec!["a/1".to_string(), "a/2".to_string(), "b".to_string(), "c/1".to_string()];
|
|
||||||
let (entries, truncated, next) = reference_page(&keys, "", Some("/"), None, 2);
|
|
||||||
assert_eq!(entries, vec![ListEntryKey::prefix("a/"), ListEntryKey::object("b")]);
|
|
||||||
assert!(truncated);
|
|
||||||
assert_eq!(next.as_deref(), Some("b"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merged_pages_are_ordered_and_local_wins_on_equal_keys() {
|
|
||||||
let local = vec!["a".to_string(), "c".to_string()];
|
|
||||||
let source = vec!["b".to_string(), "c".to_string(), "d".to_string()];
|
|
||||||
let (emitted, sizes) = walk(&local, &source, "", None, 2);
|
|
||||||
let names: Vec<&str> = emitted.iter().map(|(entry, _)| entry.name.as_str()).collect();
|
|
||||||
assert_eq!(names, vec!["a", "b", "c", "d"]);
|
|
||||||
assert_eq!(emitted[2].1, MergeSide::Local, "the shared key must come from local");
|
|
||||||
assert!(sizes.iter().all(|size| *size <= 2), "{sizes:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn source_only_listing_paginates_without_a_local_side() {
|
|
||||||
let source: Vec<String> = (0..7).map(|index| format!("k{index}")).collect();
|
|
||||||
let (emitted, _) = walk(&[], &source, "", None, 3);
|
|
||||||
assert_eq!(emitted.len(), 7);
|
|
||||||
assert!(emitted.iter().all(|(_, side)| *side == MergeSide::Source));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_disabled_source_answers_from_local_alone() {
|
|
||||||
let mut merger = ListThroughMerger::new(10, None);
|
|
||||||
merger.disable_source();
|
|
||||||
assert_eq!(
|
|
||||||
merger.next_fetch(),
|
|
||||||
Some(FetchRequest {
|
|
||||||
side: MergeSide::Local,
|
|
||||||
token: None
|
|
||||||
})
|
|
||||||
);
|
|
||||||
merger.push_page(MergeSide::Local, vec![ListEntryKey::object("a")], false, None);
|
|
||||||
assert_eq!(merger.next_fetch(), None);
|
|
||||||
let outcome = merger.finish();
|
|
||||||
assert_eq!(outcome.picks.len(), 1);
|
|
||||||
assert!(!outcome.is_truncated);
|
|
||||||
assert!(outcome.next_token.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_degraded_page_keeps_the_source_cursor_for_the_next_one() {
|
|
||||||
let resume = ListThroughToken {
|
|
||||||
t: LIST_THROUGH_TOKEN_TAG.to_string(),
|
|
||||||
v: LIST_THROUGH_TOKEN_VERSION,
|
|
||||||
local: Some("local-1".to_string()),
|
|
||||||
local_done: false,
|
|
||||||
source: Some("source-1".to_string()),
|
|
||||||
source_done: false,
|
|
||||||
last_key: Some("a".to_string()),
|
|
||||||
};
|
|
||||||
let mut merger = ListThroughMerger::new(1, Some(&resume));
|
|
||||||
merger.disable_source();
|
|
||||||
merger.push_page(
|
|
||||||
MergeSide::Local,
|
|
||||||
vec![ListEntryKey::object("b"), ListEntryKey::object("c")],
|
|
||||||
true,
|
|
||||||
Some("local-2".to_string()),
|
|
||||||
);
|
|
||||||
let outcome = merger.finish();
|
|
||||||
assert!(outcome.is_truncated);
|
|
||||||
let token = outcome.next_token.expect("truncated page carries a token");
|
|
||||||
assert_eq!(token.source.as_deref(), Some("source-1"), "the source cursor must not move");
|
|
||||||
assert!(!token.source_done);
|
|
||||||
assert_eq!(token.last_key.as_deref(), Some("b"));
|
|
||||||
assert_eq!(token.local.as_deref(), Some("local-1"), "a partly read page is re-listed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_round_trips_and_rejects_tampering() {
|
|
||||||
let token = ListThroughToken::new(
|
|
||||||
SideCursor {
|
|
||||||
token: Some("l".to_string()),
|
|
||||||
done: false,
|
|
||||||
},
|
|
||||||
SideCursor { token: None, done: true },
|
|
||||||
Some("k".to_string()),
|
|
||||||
);
|
|
||||||
let encoded = token.encode();
|
|
||||||
assert_eq!(decode_continuation_token(&encoded), Ok(ListThroughCursor::Merged(Box::new(token))));
|
|
||||||
|
|
||||||
let bumped = encoded.replace("\"v\":1", "\"v\":2");
|
|
||||||
assert_eq!(decode_continuation_token(&bumped), Err(ListThroughTokenError::UnsupportedVersion(2)));
|
|
||||||
|
|
||||||
let extra = encoded.replace("{", "{\"x\":1,");
|
|
||||||
assert_eq!(decode_continuation_token(&extra), Err(ListThroughTokenError::Malformed));
|
|
||||||
|
|
||||||
let truncated = &encoded[..encoded.len() - 3];
|
|
||||||
assert_eq!(decode_continuation_token(truncated), Ok(ListThroughCursor::Local(truncated.to_string())));
|
|
||||||
|
|
||||||
let no_version = "{\"t\":\"odm-list\"}";
|
|
||||||
assert_eq!(decode_continuation_token(no_version), Err(ListThroughTokenError::Malformed));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_plain_local_marker_stays_local() {
|
|
||||||
assert_eq!(
|
|
||||||
decode_continuation_token("photos/2024/01.jpg"),
|
|
||||||
Ok(ListThroughCursor::Local("photos/2024/01.jpg".to_string()))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decode_continuation_token("{not json"),
|
|
||||||
Ok(ListThroughCursor::Local("{not json".to_string()))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decode_continuation_token("{\"t\":\"other\"}"),
|
|
||||||
Ok(ListThroughCursor::Local("{\"t\":\"other\"}".to_string()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn source_list_plan_intersects_the_filter_prefix() {
|
|
||||||
assert_eq!(source_list_plan("", None, None), SourceListPlan::Page { prefix: String::new() });
|
|
||||||
assert_eq!(
|
|
||||||
source_list_plan("photos/2024/", Some("photos/"), None),
|
|
||||||
SourceListPlan::Page {
|
|
||||||
prefix: "photos/2024/".to_string()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
source_list_plan("photos/", Some("photos/2024/"), None),
|
|
||||||
SourceListPlan::Page {
|
|
||||||
prefix: "photos/2024/".to_string()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(source_list_plan("videos/", Some("photos/"), None), SourceListPlan::Skip);
|
|
||||||
assert_eq!(
|
|
||||||
source_list_plan("", Some("photos/2024/"), Some("/")),
|
|
||||||
SourceListPlan::Folded {
|
|
||||||
probe_prefix: "photos/2024/".to_string(),
|
|
||||||
common_prefix: "photos/".to_string(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
source_list_plan("pho", Some("photos"), Some("/")),
|
|
||||||
SourceListPlan::Page {
|
|
||||||
prefix: "photos".to_string()
|
|
||||||
},
|
|
||||||
"a filter prefix that adds no delimiter keeps the source's own roll-up"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rate_limiter_spends_its_burst_then_paces_and_refuses() {
|
|
||||||
let limiter = SourceListRateLimiter::new(10);
|
|
||||||
let start = Instant::now();
|
|
||||||
for _ in 0..10 {
|
|
||||||
assert_eq!(limiter.reserve_at(start, Duration::from_secs(1)), Some(Duration::ZERO));
|
|
||||||
}
|
|
||||||
let paced = limiter.reserve_at(start, Duration::from_secs(1)).expect("within the budget");
|
|
||||||
assert!(paced > Duration::ZERO && paced <= Duration::from_millis(101), "{paced:?}");
|
|
||||||
assert_eq!(limiter.reserve_at(start, Duration::ZERO), None, "a zero budget refuses");
|
|
||||||
// A full second of refill restores the whole burst.
|
|
||||||
assert_eq!(limiter.reserve_at(start + Duration::from_secs(5), Duration::ZERO), Some(Duration::ZERO));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key_set() -> impl Strategy<Value = Vec<String>> {
|
|
||||||
proptest::collection::btree_set(
|
|
||||||
proptest::sample::select(vec!["a", "a/", "a/1", "a/2", "a/b/1", "b", "b/1", "c", "c/1", "c/2", "d", "d/e/f"])
|
|
||||||
.prop_map(str::to_string),
|
|
||||||
0..=12,
|
|
||||||
)
|
|
||||||
.prop_map(|set: BTreeSet<String>| set.into_iter().collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
proptest! {
|
|
||||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
|
||||||
|
|
||||||
/// Full pagination of a merged listing equals the sorted, deduplicated
|
|
||||||
/// union of both sides, with every shared key served by local, and no
|
|
||||||
/// page longer than `max_keys`.
|
|
||||||
#[test]
|
|
||||||
fn merged_pagination_equals_the_deduplicated_union(
|
|
||||||
local in key_set(),
|
|
||||||
source in key_set(),
|
|
||||||
max_keys in 1usize..=5,
|
|
||||||
with_delimiter in any::<bool>(),
|
|
||||||
prefix in proptest::sample::select(vec!["", "a", "a/", "c/"]),
|
|
||||||
) {
|
|
||||||
let delimiter = with_delimiter.then_some("/");
|
|
||||||
let (emitted, sizes) = walk(&local, &source, prefix, delimiter, max_keys);
|
|
||||||
let got: Vec<ListEntryKey> = emitted.iter().map(|(entry, _)| entry.clone()).collect();
|
|
||||||
prop_assert_eq!(got, expected(&local, &source, prefix, delimiter));
|
|
||||||
prop_assert!(sizes.iter().all(|size| *size <= max_keys), "{:?}", sizes);
|
|
||||||
for (entry, side) in &emitted {
|
|
||||||
if !entry.is_prefix && local.iter().any(|key| key == &entry.name) {
|
|
||||||
prop_assert_eq!(*side, MergeSide::Local, "local must win for {}", entry.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -180,6 +180,8 @@ impl RemoteS3EndpointSpec {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RemoteS3ClientError {
|
pub enum RemoteS3ClientError {
|
||||||
|
#[error("the {0} backend is not included in this build")]
|
||||||
|
BackendNotCompiled(&'static str),
|
||||||
#[error("remote endpoint requires credentials")]
|
#[error("remote endpoint requires credentials")]
|
||||||
MissingCredentials,
|
MissingCredentials,
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -281,9 +283,7 @@ impl Intercept for UserAgentSuffixInterceptor {
|
|||||||
|
|
||||||
/// Builds the SDK config for `spec` without finalizing it, so callers can add
|
/// Builds the SDK config for `spec` without finalizing it, so callers can add
|
||||||
/// interceptors or (in tests) swap the HTTP client before `build()`.
|
/// interceptors or (in tests) swap the HTTP client before `build()`.
|
||||||
pub(crate) async fn build_remote_s3_config(
|
pub async fn build_remote_s3_config(spec: &RemoteS3EndpointSpec) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
|
||||||
spec: &RemoteS3EndpointSpec,
|
|
||||||
) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
|
|
||||||
let Some(credentials) = &spec.credentials else {
|
let Some(credentials) = &spec.credentials else {
|
||||||
return Err(RemoteS3ClientError::MissingCredentials);
|
return Err(RemoteS3ClientError::MissingCredentials);
|
||||||
};
|
};
|
||||||
@@ -523,7 +523,7 @@ fn validate_ca_pem_bundle(ca_cert_pem: &[u8]) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
|
pub fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
|
||||||
validate_ca_pem_bundle(ca_cert_pem.as_bytes()).map_err(RemoteS3ClientError::InvalidCaPem)
|
validate_ca_pem_bundle(ca_cert_pem.as_bytes()).map_err(RemoteS3ClientError::InvalidCaPem)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,9 +652,10 @@ async fn build_aws_s3_http_client_from_tls_path() -> Option<SharedHttpClient> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use aws_smithy_async::time::TimeSource;
|
||||||
use aws_smithy_runtime_api::http::StatusCode as SmithyStatusCode;
|
use aws_smithy_runtime_api::http::StatusCode as SmithyStatusCode;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
|
|
||||||
fn spec(endpoint: &str, secure: bool) -> RemoteS3EndpointSpec {
|
fn spec(endpoint: &str, secure: bool) -> RemoteS3EndpointSpec {
|
||||||
RemoteS3EndpointSpec {
|
RemoteS3EndpointSpec {
|
||||||
@@ -824,6 +825,174 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ClockSkewTimeSource(Arc<AtomicU64>);
|
||||||
|
|
||||||
|
impl TimeSource for ClockSkewTimeSource {
|
||||||
|
fn now(&self) -> SystemTime {
|
||||||
|
SystemTime::UNIX_EPOCH + Duration::from_secs(self.0.load(Ordering::SeqCst))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ClockSkewConnector {
|
||||||
|
request_headers: RecordedHeaders,
|
||||||
|
error_code: &'static str,
|
||||||
|
skew_seconds: i64,
|
||||||
|
clock: ClockSkewTimeSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recorded_header<'a>(headers: &'a [(String, String)], name: &str) -> &'a str {
|
||||||
|
headers
|
||||||
|
.iter()
|
||||||
|
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
||||||
|
.map(|(_, value)| value.as_str())
|
||||||
|
.unwrap_or_else(|| panic!("signed request must contain {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signing_time(headers: &[(String, String)]) -> chrono::NaiveDateTime {
|
||||||
|
chrono::NaiveDateTime::parse_from_str(recorded_header(headers, "x-amz-date"), "%Y%m%dT%H%M%SZ")
|
||||||
|
.expect("SDK signing timestamp must use the SigV4 format")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmithyHttpConnector for ClockSkewConnector {
|
||||||
|
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
|
||||||
|
let mut headers = self.request_headers.lock().expect("clock skew request capture lock");
|
||||||
|
assert!(headers.len() < 3, "clock skew fixture must not exceed two GET attempts and one HEAD");
|
||||||
|
headers.push(
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let server_time = chrono::DateTime::<chrono::Utc>::from(self.clock.now()).naive_utc()
|
||||||
|
+ chrono::Duration::seconds(self.skew_seconds);
|
||||||
|
let (status, body) = if headers.len() == 1 {
|
||||||
|
(
|
||||||
|
403,
|
||||||
|
format!("<Error><Code>{}</Code><Message>Clock skew fixture</Message></Error>", self.error_code),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(200, String::new())
|
||||||
|
};
|
||||||
|
let response = http::Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("date", server_time.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
|
||||||
|
.header("content-type", "application/xml")
|
||||||
|
.header("content-length", body.len())
|
||||||
|
.body(SdkBody::from(body))
|
||||||
|
.expect("clock skew fixture response");
|
||||||
|
HttpConnectorFuture::ready(Ok(HttpResponse::try_from(response).expect("Smithy fixture response")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clock_skew_client(
|
||||||
|
error_code: &'static str,
|
||||||
|
skew_seconds: i64,
|
||||||
|
retry: RemoteS3RetryPolicy,
|
||||||
|
) -> (S3Client, RecordedHeaders, ClockSkewTimeSource) {
|
||||||
|
let headers: RecordedHeaders = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let clock = ClockSkewTimeSource(Arc::new(AtomicU64::new(1_700_000_000)));
|
||||||
|
let connector = SharedHttpConnector::new(ClockSkewConnector {
|
||||||
|
request_headers: Arc::clone(&headers),
|
||||||
|
error_code,
|
||||||
|
skew_seconds,
|
||||||
|
clock: clock.clone(),
|
||||||
|
});
|
||||||
|
let mut spec = spec("s3.example.com", true);
|
||||||
|
spec.retry = retry;
|
||||||
|
let config = build_remote_s3_config(&spec)
|
||||||
|
.await
|
||||||
|
.expect("clock skew fixture uses the production outbound configuration")
|
||||||
|
.http_client(http_client_fn(move |_settings, _components| connector.clone()))
|
||||||
|
.time_source(clock.clone())
|
||||||
|
.build();
|
||||||
|
(S3Client::from_conf(config), headers, clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn remote_s3_clock_skew_retries_resign_and_seed_next_operation() {
|
||||||
|
for error_code in ["RequestTimeTooSkewed", "SignatureDoesNotMatch"] {
|
||||||
|
for skew_seconds in [-600, 600] {
|
||||||
|
let (client, headers, clock) = clock_skew_client(error_code, skew_seconds, REPLICATION_TARGET_RETRY_POLICY).await;
|
||||||
|
let initial = chrono::DateTime::<chrono::Utc>::from(clock.now()).naive_utc();
|
||||||
|
client
|
||||||
|
.get_object()
|
||||||
|
.bucket("bucket")
|
||||||
|
.key("object")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("clock skew GET must retry successfully");
|
||||||
|
assert_eq!(
|
||||||
|
headers.lock().expect("captured requests").len(),
|
||||||
|
2,
|
||||||
|
"{error_code}: GET needs exactly one retry"
|
||||||
|
);
|
||||||
|
clock.0.fetch_add(17, Ordering::SeqCst);
|
||||||
|
// SDK signing time is independent of Tokio's retry/scheduler clock.
|
||||||
|
tokio::time::advance(Duration::from_secs(61)).await;
|
||||||
|
client
|
||||||
|
.head_bucket()
|
||||||
|
.bucket("bucket")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("subsequent HEAD must use the client's cached skew");
|
||||||
|
let headers = headers.lock().expect("captured signed requests");
|
||||||
|
assert_eq!(headers.len(), 3, "subsequent operation must succeed on its first attempt");
|
||||||
|
assert_eq!(signing_time(&headers[0]), initial, "the first attempt must use the injected clock");
|
||||||
|
assert_eq!(
|
||||||
|
signing_time(&headers[1]),
|
||||||
|
initial + chrono::Duration::seconds(skew_seconds),
|
||||||
|
"{error_code}: retry must apply the measured offset exactly"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
signing_time(&headers[2]),
|
||||||
|
initial + chrono::Duration::seconds(skew_seconds + 17),
|
||||||
|
"{error_code}: the next operation must apply cached skew to the advanced signing clock"
|
||||||
|
);
|
||||||
|
let signature = |index: usize| {
|
||||||
|
recorded_header(&headers[index], "authorization")
|
||||||
|
.rsplit_once("Signature=")
|
||||||
|
.expect("SigV4 authorization contains a signature")
|
||||||
|
.1
|
||||||
|
};
|
||||||
|
assert_ne!(
|
||||||
|
signature(0),
|
||||||
|
signature(1),
|
||||||
|
"{error_code}: retry must be signed again after adjusting its date"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn remote_s3_clock_skew_respects_one_attempt_policy() {
|
||||||
|
use aws_smithy_types::error::metadata::ProvideErrorMetadata;
|
||||||
|
|
||||||
|
for error_code in ["RequestTimeTooSkewed", "SignatureDoesNotMatch"] {
|
||||||
|
for retry in [
|
||||||
|
RemoteS3RetryPolicy::Disabled,
|
||||||
|
RemoteS3RetryPolicy::Standard { max_attempts: 1 },
|
||||||
|
] {
|
||||||
|
let (client, headers, _clock) = clock_skew_client(error_code, 600, retry).await;
|
||||||
|
let error = client
|
||||||
|
.get_object()
|
||||||
|
.bucket("bucket")
|
||||||
|
.key("object")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("clock skew must not override the caller's one-attempt budget");
|
||||||
|
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some(error_code));
|
||||||
|
assert_eq!(
|
||||||
|
headers.lock().expect("captured requests").len(),
|
||||||
|
1,
|
||||||
|
"{error_code}: {retry:?} must send exactly one request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn path_style_auto_and_path_force_path_style() {
|
fn path_style_auto_and_path_force_path_style() {
|
||||||
assert!(PathStyle::Auto.force_path_style());
|
assert!(PathStyle::Auto.force_path_style());
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub(crate) use replication_lifecycle_bridge::ReplicationLifecycleBridge;
|
|||||||
pub(crate) use replication_migration_bridge::ReplicationMigrationBridge;
|
pub(crate) use replication_migration_bridge::ReplicationMigrationBridge;
|
||||||
pub use replication_object_bridge::ReplicationObjectBridge;
|
pub use replication_object_bridge::ReplicationObjectBridge;
|
||||||
pub use replication_object_config::{DeleteReplicationConfigSnapshot, ReplicationConfig};
|
pub use replication_object_config::{DeleteReplicationConfigSnapshot, ReplicationConfig};
|
||||||
|
pub(crate) use replication_object_decision_boundary::replication_etags_match;
|
||||||
pub use replication_object_decision_boundary::{
|
pub use replication_object_decision_boundary::{
|
||||||
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config,
|
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config,
|
||||||
delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
||||||
@@ -88,5 +89,6 @@ pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
|
|||||||
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
|
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
|
||||||
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
|
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
|
||||||
pub use replication_target_boundary::SsecPassthroughCapability;
|
pub use replication_target_boundary::SsecPassthroughCapability;
|
||||||
|
pub use replication_target_boundary::VersionIdentityCapability;
|
||||||
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
|
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
|
||||||
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use rustfs_filemeta::ObjectPartInfo;
|
||||||
pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
|
pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
|
||||||
pub(crate) use rustfs_replication::{
|
pub(crate) use rustfs_replication::{
|
||||||
REPLICATE_EXISTING, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction,
|
REPLICATE_EXISTING, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use rustfs_replication::ReplicationMultipartPlanError;
|
||||||
pub use rustfs_replication::{
|
pub use rustfs_replication::{
|
||||||
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config,
|
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config,
|
||||||
delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
||||||
@@ -20,9 +22,9 @@ pub use rustfs_replication::{
|
|||||||
pub(crate) use rustfs_replication::{
|
pub(crate) use rustfs_replication::{
|
||||||
ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject, delete_marker_purge_mrf_entry,
|
ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject, delete_marker_purge_mrf_entry,
|
||||||
delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision,
|
delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision,
|
||||||
delete_replication_object_opts, heal_uses_delete_replication_path, is_object_lock_denied_delete,
|
delete_replication_object_opts, delete_replication_target_version_id, heal_uses_delete_replication_path,
|
||||||
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match,
|
is_object_lock_denied_delete, is_retryable_delete_replication_head_error, is_version_delete_replication,
|
||||||
replication_multipart_complete_actual_size, replication_multipart_part_plan, replication_single_put_size_error,
|
replicate_delete_outcome, replication_etags_match, replication_multipart_complete_actual_size,
|
||||||
resync_existing_delete_replication_info, resync_target_for_object, should_retry_delete_marker_purge,
|
replication_multipart_part_plan, replication_single_put_size_error, resync_existing_delete_replication_info,
|
||||||
single_part_replica_etag_mismatch, target_delete_version_id,
|
resync_target_for_object, should_retry_delete_marker_purge, single_part_replica_etag_mismatch,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ use super::replication_storage_boundary::{
|
|||||||
HTTPPreconditions, ObjectInfo, ObjectOptions, ObjectToDelete, ReplicationDeletedObject, ReplicationObjectIO,
|
HTTPPreconditions, ObjectInfo, ObjectOptions, ObjectToDelete, ReplicationDeletedObject, ReplicationObjectIO,
|
||||||
ReplicationStorage,
|
ReplicationStorage,
|
||||||
};
|
};
|
||||||
use super::replication_target_boundary::{ReplicationTargetStore, replication_object_is_ssec_encrypted};
|
use super::replication_target_boundary::{BucketTargetError, ReplicationTargetStore, replication_object_is_ssec_encrypted};
|
||||||
use super::replication_versioning_boundary::ReplicationVersioningStore;
|
use super::replication_versioning_boundary::ReplicationVersioningStore;
|
||||||
use super::runtime_boundary as runtime_sources;
|
use super::runtime_boundary as runtime_sources;
|
||||||
use futures_util::stream::{self, StreamExt};
|
use futures_util::stream::{self, StreamExt};
|
||||||
@@ -882,6 +882,20 @@ fn reconstructed_heal_delete_info(
|
|||||||
) -> DeletedObjectReplicationInfo {
|
) -> DeletedObjectReplicationInfo {
|
||||||
let mut rstate = oi.replication_state();
|
let mut rstate = oi.replication_state();
|
||||||
rstate.replicate_decision_str = dsc.to_string();
|
rstate.replicate_decision_str = dsc.to_string();
|
||||||
|
// The caller hands us a blank ObjectInfo (the source marker may already be
|
||||||
|
// gone), so the state above carries no target-assigned marker version ids.
|
||||||
|
// Restore them from the journal: `delete_marker_purge_version_id` must hit
|
||||||
|
// the id the target reported, not fall back to the source marker id, which
|
||||||
|
// a target that mints its own ids answers with an idempotent 204 that would
|
||||||
|
// acknowledge the intent while the real marker stays behind (backlog#2290).
|
||||||
|
// The corrupt flag rides along so a refusal stays a refusal after restart.
|
||||||
|
for (arn, version_id) in &entry.target_delete_marker_version_ids {
|
||||||
|
rstate
|
||||||
|
.target_delete_marker_version_ids
|
||||||
|
.entry(arn.clone())
|
||||||
|
.or_insert_with(|| version_id.clone());
|
||||||
|
}
|
||||||
|
rstate.target_delete_marker_version_ids_corrupt |= entry.target_delete_marker_version_ids_corrupt;
|
||||||
|
|
||||||
let delete_marker_mtime = entry
|
let delete_marker_mtime = entry
|
||||||
.delete_marker_mtime
|
.delete_marker_mtime
|
||||||
@@ -3065,7 +3079,11 @@ pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rcfg = match ReplicationMetadataStore::optional_replication_config(bucket).await {
|
let rcfg = match ReplicationMetadataStore::optional_replication_config(bucket).await {
|
||||||
Ok(Some(config)) => config,
|
Ok(Some(config)) => Some(config),
|
||||||
|
// A bucket without a configuration still owes its pending purges an
|
||||||
|
// answer: the delete worker finishes them locally as abandoned, which
|
||||||
|
// is what makes the bucket deletable again (rustfs/backlog#2340).
|
||||||
|
Ok(None) if !oi.version_purge_status.is_empty() => None,
|
||||||
Ok(None) => return ReplicationQueueAdmission::Skipped,
|
Ok(None) => return ReplicationQueueAdmission::Skipped,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -3084,6 +3102,23 @@ pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u
|
|||||||
|
|
||||||
let tgts = match ReplicationTargetStore::list_bucket_targets(bucket).await {
|
let tgts = match ReplicationTargetStore::list_bucket_targets(bucket).await {
|
||||||
Ok(targets) => Some(targets),
|
Ok(targets) => Some(targets),
|
||||||
|
// A bucket whose persisted target configuration cannot be decoded has
|
||||||
|
// an unknown target set, not an empty one: scheduling against `None`
|
||||||
|
// here would drop every heal for it without a trace
|
||||||
|
// (rustfs/backlog#2282). Report it missed so the object is retried
|
||||||
|
// once the configuration is readable again.
|
||||||
|
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. }) => {
|
||||||
|
warn!(
|
||||||
|
event = EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_REPLICATION,
|
||||||
|
bucket,
|
||||||
|
reason = "target_config_unreadable",
|
||||||
|
"Bucket replication targets are unreadable; replication heal queue fails closed"
|
||||||
|
);
|
||||||
|
|
||||||
|
return ReplicationQueueAdmission::Missed;
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!(
|
debug!(
|
||||||
event = EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED,
|
event = EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED,
|
||||||
@@ -3098,7 +3133,7 @@ pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let rcfg_wrapper = ReplicationConfig::new(Some(rcfg), tgts);
|
let rcfg_wrapper = ReplicationConfig::new(rcfg, tgts);
|
||||||
queue_replication_heal_internal(bucket, oi, rcfg_wrapper, retry_count)
|
queue_replication_heal_internal(bucket, oi, rcfg_wrapper, retry_count)
|
||||||
.await
|
.await
|
||||||
.admission
|
.admission
|
||||||
@@ -3144,7 +3179,11 @@ pub(crate) async fn queue_replication_heal_internal(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if rcfg.config.is_none() || rcfg.remotes.is_none() {
|
// Without a configuration or targets there is nothing to replicate —
|
||||||
|
// except a version purge the bucket still owes: its stored decision names
|
||||||
|
// the targets, and the delete worker settles the ones no longer
|
||||||
|
// configured as abandoned (rustfs/backlog#2340).
|
||||||
|
if (rcfg.config.is_none() || rcfg.remotes.is_none()) && oi.version_purge_status.is_empty() {
|
||||||
return ReplicationHealQueueResult {
|
return ReplicationHealQueueResult {
|
||||||
object_info: roi,
|
object_info: roi,
|
||||||
admission: ReplicationQueueAdmission::Skipped,
|
admission: ReplicationQueueAdmission::Skipped,
|
||||||
@@ -3189,12 +3228,15 @@ pub(crate) async fn queue_replication_heal_internal(
|
|||||||
}
|
}
|
||||||
ReplicationHealQueueAction::QueueDelete(dv) => {
|
ReplicationHealQueueAction::QueueDelete(dv) => {
|
||||||
// A purge the peer denied under object lock cannot succeed until
|
// A purge the peer denied under object lock cannot succeed until
|
||||||
// the lock lapses (#6850); requeuing it every heal cycle only
|
// the lock lapses (#6850), and one whose replica cannot be told
|
||||||
|
// apart on a target that mints its own version ids cannot
|
||||||
|
// succeed until the ledger or an operator resolves it
|
||||||
|
// (rustfs/backlog#2340); requeuing either every heal cycle only
|
||||||
// burns bandwidth and failure counters. The backoff expires on
|
// burns bandwidth and failure counters. The backoff expires on
|
||||||
// its own, so the purge is probed again — and converges — once
|
// its own, so the purge is probed again — and converges — once
|
||||||
// the retention window has a chance of being over.
|
// the condition has a chance of being over.
|
||||||
if super::replication_object_decision_boundary::is_version_delete_replication(&dv.delete_object)
|
if super::replication_object_decision_boundary::is_version_delete_replication(&dv.delete_object)
|
||||||
&& super::replication_resyncer::object_lock_denied_purge_backoff_active(&dv)
|
&& super::replication_resyncer::purge_backoff_active(&dv)
|
||||||
{
|
{
|
||||||
return ReplicationHealQueueResult {
|
return ReplicationHealQueueResult {
|
||||||
object_info: roi,
|
object_info: roi,
|
||||||
@@ -6584,4 +6626,87 @@ mod tests {
|
|||||||
replacement_data
|
replacement_data
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// backlog#2290: a delete-marker purge intent that survives a restart
|
||||||
|
/// through the MRF journal addresses the marker version the TARGET
|
||||||
|
/// assigned, exactly as the live watcher does (see the
|
||||||
|
/// `requires_delayed_purge` spawn). The journal carries the per-ARN ids
|
||||||
|
/// (`targetDeleteMarkerVersionIDs`) and replay restores them into the
|
||||||
|
/// reconstructed replication state; without that the replay would fall
|
||||||
|
/// back to the source marker id, which a target that mints its own ids
|
||||||
|
/// answers with an idempotent 204 — the entry would be acknowledged while
|
||||||
|
/// the real marker stayed behind.
|
||||||
|
#[test]
|
||||||
|
fn mrf_delete_marker_purge_replay_preserves_target_assigned_marker_version() {
|
||||||
|
use super::super::replication_object_decision_boundary::{delete_marker_purge_mrf_entry, delete_marker_purge_version_id};
|
||||||
|
|
||||||
|
let arn = "arn:minio:replication::generic-target:photos".to_string();
|
||||||
|
let source_marker = uuid::Uuid::new_v4();
|
||||||
|
let remote_marker = "remote-assigned-marker-version".to_string();
|
||||||
|
|
||||||
|
let live_oi = ObjectInfo {
|
||||||
|
bucket: "photos".to_string(),
|
||||||
|
name: "obj".to_string(),
|
||||||
|
version_id: Some(source_marker),
|
||||||
|
delete_marker: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut live_state = live_oi.replication_state();
|
||||||
|
live_state.replicate_decision_str = replicate_decision_for_admitted_targets(std::slice::from_ref(&arn)).to_string();
|
||||||
|
live_state
|
||||||
|
.target_delete_marker_version_ids
|
||||||
|
.insert(arn.clone(), remote_marker.clone());
|
||||||
|
let live = DeletedObjectReplicationInfo {
|
||||||
|
delete_object: ReplicationDeletedObject {
|
||||||
|
object_name: "obj".to_string(),
|
||||||
|
delete_marker: true,
|
||||||
|
delete_marker_version_id: Some(source_marker),
|
||||||
|
replication_state: Some(live_state),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
bucket: "photos".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
delete_marker_purge_version_id(live.delete_object.replication_state.as_ref(), &arn, source_marker),
|
||||||
|
Some(Some(remote_marker.clone())),
|
||||||
|
"the live purge addresses the recorded target version"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watch window exhausted: persist the intent, restart, replay it.
|
||||||
|
let entry = delete_marker_purge_mrf_entry(&live, vec![arn.clone()]);
|
||||||
|
let replay_oi = ObjectInfo {
|
||||||
|
bucket: entry.bucket.clone(),
|
||||||
|
name: entry.object.clone(),
|
||||||
|
version_id: entry.version_id,
|
||||||
|
delete_marker: entry.delete_marker,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let dsc = replicate_decision_for_admitted_targets(&entry.target_arns);
|
||||||
|
let replayed = reconstructed_heal_delete_info(&entry, &replay_oi, &dsc);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
delete_marker_purge_version_id(replayed.delete_object.replication_state.as_ref(), &arn, source_marker),
|
||||||
|
Some(Some(remote_marker)),
|
||||||
|
"the MRF replay must address the target-assigned marker version, not source marker {source_marker}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A refusal (inconsistent recorded ids) must stay a refusal across the
|
||||||
|
// journal round trip instead of degrading into the source-id fallback.
|
||||||
|
let mut refused = live;
|
||||||
|
refused
|
||||||
|
.delete_object
|
||||||
|
.replication_state
|
||||||
|
.as_mut()
|
||||||
|
.expect("state was set above")
|
||||||
|
.target_delete_marker_version_ids_corrupt = true;
|
||||||
|
let entry = delete_marker_purge_mrf_entry(&refused, vec![arn.clone()]);
|
||||||
|
assert!(entry.target_delete_marker_version_ids_corrupt);
|
||||||
|
let replayed = reconstructed_heal_delete_info(&entry, &replay_oi, &dsc);
|
||||||
|
assert_eq!(
|
||||||
|
delete_marker_purge_version_id(replayed.delete_object.replication_state.as_ref(), &arn, source_marker),
|
||||||
|
None,
|
||||||
|
"the MRF replay must keep refusing to guess when the recorded ids were inconsistent"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::bucket::bucket_target_sys::{BucketTargetError, BucketTargetSys};
|
pub(crate) use crate::bucket::bucket_target_sys::BucketTargetError;
|
||||||
|
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
||||||
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
|
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
|
||||||
use aws_sdk_s3::types::{ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
use aws_sdk_s3::types::{ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
@@ -37,7 +38,7 @@ use time::format_description::well_known::Rfc3339;
|
|||||||
|
|
||||||
pub(crate) use crate::bucket::bucket_target_sys::{
|
pub(crate) use crate::bucket::bucket_target_sys::{
|
||||||
AdvancedPutOptions, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions, RemotePutObjectResponse, RemoveObjectOptions,
|
AdvancedPutOptions, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions, RemotePutObjectResponse, RemoveObjectOptions,
|
||||||
S3ClientError, TargetClient, resolve_read_api_version_id,
|
ReplicaLocation, S3ClientError, TargetClient, resolve_read_api_version_id,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use crate::bucket::target::BucketTarget;
|
pub(crate) use crate::bucket::target::BucketTarget;
|
||||||
@@ -47,6 +48,7 @@ pub use rustfs_replication::{ObjectLockIntegrity, object_lock_put_integrity};
|
|||||||
pub(crate) use rustfs_replication::{
|
pub(crate) use rustfs_replication::{
|
||||||
SsecPassthroughGate, is_replication_target_offline_error, ssec_passthrough_gate, version_identity_drifted,
|
SsecPassthroughGate, is_replication_target_offline_error, ssec_passthrough_gate, version_identity_drifted,
|
||||||
};
|
};
|
||||||
|
pub use rustfs_replication::{VersionIdentityCapability, version_identity_capability_from_put};
|
||||||
|
|
||||||
use super::replication_config_store::ReplicationConfigStore;
|
use super::replication_config_store::ReplicationConfigStore;
|
||||||
use super::replication_error_boundary::{Error, Result};
|
use super::replication_error_boundary::{Error, Result};
|
||||||
@@ -191,6 +193,14 @@ impl ReplicationTargetStore {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn version_identity_capability(arn: &str) -> VersionIdentityCapability {
|
||||||
|
BucketTargetSys::get().version_identity_capability(arn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_version_identity_capability(arn: &str, capability: VersionIdentityCapability) {
|
||||||
|
BucketTargetSys::get().record_version_identity_capability(arn, capability)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) async fn register_test_target(target_client: &Arc<TargetClient>) {
|
pub(crate) async fn register_test_target(target_client: &Arc<TargetClient>) {
|
||||||
BucketTargetSys::get().arn_remotes_map.write().await.insert(
|
BucketTargetSys::get().arn_remotes_map.write().await.insert(
|
||||||
@@ -247,7 +257,16 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
|
|||||||
meta.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string());
|
meta.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut is_multipart = object_info.is_multipart();
|
// Older transformed objects can have physical parts without logical part
|
||||||
|
// lengths. Keep their existing whole-object transport: physical sizes are
|
||||||
|
// not plaintext boundaries for a multipart replication read.
|
||||||
|
let legacy_single_put = object_info.etag.as_deref().is_none_or(|etag| etag.len() == 32);
|
||||||
|
let base_is_multipart = object_info.is_multipart()
|
||||||
|
&& !(legacy_single_put
|
||||||
|
&& object_info.parts.len() > 1
|
||||||
|
&& (object_info.is_compressed() || object_info.is_encrypted())
|
||||||
|
&& object_info.parts.iter().any(|part| part.actual_size <= 0));
|
||||||
|
let mut is_multipart = base_is_multipart;
|
||||||
|
|
||||||
if let Some(checksum_data) = &object_info.checksum
|
if let Some(checksum_data) = &object_info.checksum
|
||||||
&& !checksum_data.is_empty()
|
&& !checksum_data.is_empty()
|
||||||
@@ -258,8 +277,8 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
|
|||||||
} else if object_info.is_encrypted() {
|
} else if object_info.is_encrypted() {
|
||||||
// Encrypted checksums cannot be exposed as plaintext headers, and
|
// Encrypted checksums cannot be exposed as plaintext headers, and
|
||||||
// decrypt_checksums reports is_multipart=false for them (a value
|
// decrypt_checksums reports is_multipart=false for them (a value
|
||||||
// the response path relies on). Keep the object's own multipart
|
// the response path relies on). Keep the transport selected from
|
||||||
// flag so encrypted objects stay on the multipart route.
|
// the object's layout and readable part boundaries.
|
||||||
} else {
|
} else {
|
||||||
let (checksum_meta, checksum_record_is_multipart) = object_info.decrypt_checksums(0, &HeaderMap::new())?;
|
let (checksum_meta, checksum_record_is_multipart) = object_info.decrypt_checksums(0, &HeaderMap::new())?;
|
||||||
// The checksum record describes how the *checksum* is composed,
|
// The checksum record describes how the *checksum* is composed,
|
||||||
@@ -267,23 +286,37 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
|
|||||||
// MULTIPART flag even on a multipart upload, so trusting it here
|
// MULTIPART flag even on a multipart upload, so trusting it here
|
||||||
// routed a 768-part object through a single PutObject and the
|
// routed a 768-part object through a single PutObject and the
|
||||||
// target rejected the 6 GiB body with EntityTooLarge
|
// target rejected the 6 GiB body with EntityTooLarge
|
||||||
// (rustfs#6825). The object's own shape is the authority: the
|
// (rustfs#6825). The usable part layout is the authority: the
|
||||||
// record may only add multipart-ness, never take it away.
|
// record may only add multipart-ness, never take it away.
|
||||||
is_multipart = object_info.is_multipart() || checksum_record_is_multipart;
|
is_multipart = base_is_multipart || checksum_record_is_multipart;
|
||||||
|
|
||||||
for (key, value) in checksum_meta.iter() {
|
if !base_is_multipart
|
||||||
if key != AMZ_CHECKSUM_TYPE {
|
|
||||||
meta.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !object_info.is_multipart()
|
|
||||||
&& checksum_meta
|
&& checksum_meta
|
||||||
.get(AMZ_CHECKSUM_TYPE)
|
.get(AMZ_CHECKSUM_TYPE)
|
||||||
.is_some_and(|value| value == AMZ_CHECKSUM_TYPE_FULL_OBJECT)
|
.is_some_and(|value| value == AMZ_CHECKSUM_TYPE_FULL_OBJECT)
|
||||||
{
|
{
|
||||||
is_multipart = false;
|
is_multipart = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The record keys each checksum by algorithm name ("CRC32"); the
|
||||||
|
// target only reads `x-amz-checksum-<algorithm>`. Inserting the bare
|
||||||
|
// name here made `PutObjectOptions::header()` send it as user
|
||||||
|
// metadata (`x-amz-meta-crc32`), so no replica ever carried the
|
||||||
|
// source checksum (rustfs/backlog#2340). The object-level record
|
||||||
|
// describes one PUT body: a multipart replica is rebuilt part by
|
||||||
|
// part, and its CreateMultipartUpload must not announce a checksum
|
||||||
|
// the parts do not carry, so the record is forwarded on the
|
||||||
|
// single-PUT route only (MinIO `getCRCMeta` parity).
|
||||||
|
if !is_multipart {
|
||||||
|
for (key, value) in checksum_meta.iter() {
|
||||||
|
if key == AMZ_CHECKSUM_TYPE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(header) = rustfs_rio::ChecksumType::from_string(key).key() {
|
||||||
|
meta.insert(header.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +548,7 @@ fn is_standard_header(key: &str) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::super::replication_filemeta_boundary::ObjectPartInfo;
|
||||||
use super::*;
|
use super::*;
|
||||||
use aws_smithy_types::DateTime;
|
use aws_smithy_types::DateTime;
|
||||||
use rustfs_replication::content_matches_by_etag;
|
use rustfs_replication::content_matches_by_etag;
|
||||||
@@ -549,6 +583,109 @@ mod tests {
|
|||||||
checksum.to_bytes(&combined)
|
checksum.to_bytes(&combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replication_route_metadata() -> [(&'static str, Arc<HashMap<String, String>>); 4] {
|
||||||
|
let mut compressed = HashMap::new();
|
||||||
|
rustfs_utils::http::insert_str(&mut compressed, rustfs_utils::http::SUFFIX_COMPRESSION, "zstd".to_string());
|
||||||
|
[
|
||||||
|
("plain", Arc::new(HashMap::new())),
|
||||||
|
("compressed", Arc::new(compressed)),
|
||||||
|
(
|
||||||
|
"encrypted",
|
||||||
|
Arc::new(HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string())])),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssec",
|
||||||
|
Arc::new(HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())])),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replication_route_object(
|
||||||
|
etag: Option<&str>,
|
||||||
|
actual_sizes: [i64; 3],
|
||||||
|
metadata: Arc<HashMap<String, String>>,
|
||||||
|
) -> ObjectInfo {
|
||||||
|
ObjectInfo {
|
||||||
|
etag: etag.map(str::to_string),
|
||||||
|
size: 48,
|
||||||
|
actual_size: 12,
|
||||||
|
user_defined: metadata,
|
||||||
|
parts: Arc::new(
|
||||||
|
actual_sizes
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, actual_size)| ObjectPartInfo {
|
||||||
|
number: index + 1,
|
||||||
|
size: 16,
|
||||||
|
actual_size,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_transformed_single_put_parts_keep_the_previous_replication_route() {
|
||||||
|
let [_, (_, compressed), (_, encrypted), (_, ssec)] = replication_route_metadata();
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
"compressed middle zero",
|
||||||
|
compressed.clone(),
|
||||||
|
Some("0123456789abcdef0123456789abcdef"),
|
||||||
|
[4, 0, 4],
|
||||||
|
),
|
||||||
|
("compressed tail unknown", compressed, None, [4, 4, -1]),
|
||||||
|
(
|
||||||
|
"encrypted middle unknown",
|
||||||
|
encrypted,
|
||||||
|
Some("gggggggggggggggggggggggggggggggg"),
|
||||||
|
[4, -1, 4],
|
||||||
|
),
|
||||||
|
("ssec tail zero", ssec.clone(), None, [4, 4, 0]),
|
||||||
|
("ssec middle unknown", ssec, Some("gggggggggggggggggggggggggggggggg"), [4, -1, 4]),
|
||||||
|
];
|
||||||
|
for (name, metadata, etag, actual_sizes) in cases {
|
||||||
|
for checksum in [None, Some(full_object_multipart_checksum_record())] {
|
||||||
|
let mut object_info = replication_route_object(etag, actual_sizes, metadata.clone());
|
||||||
|
object_info.checksum = checksum;
|
||||||
|
assert!(object_info.is_multipart(), "{name}: physical parts remain visible to metadata APIs");
|
||||||
|
assert!(object_info.is_compressed() || object_info.is_encrypted());
|
||||||
|
|
||||||
|
let (options, is_multipart) =
|
||||||
|
replication_put_object_options("STANDARD", &object_info).expect("legacy transformed put options");
|
||||||
|
assert!(
|
||||||
|
!is_multipart,
|
||||||
|
"{name}: unknown logical part sizes must preserve the old whole-object route"
|
||||||
|
);
|
||||||
|
assert_eq!(options.internal.source_etag, etag.unwrap_or_default());
|
||||||
|
if metadata.contains_key(SSEC_ALGORITHM_HEADER) {
|
||||||
|
assert_eq!(
|
||||||
|
get_header_map(&options.user_metadata, SUFFIX_REPLICATION_SSEC_CRC).is_some(),
|
||||||
|
object_info.checksum.is_some(),
|
||||||
|
"SSE-C checksums retain their raw passthrough transport"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positive_part_sizes_and_legacy_multipart_etags_keep_the_replication_route() {
|
||||||
|
for (name, metadata) in replication_route_metadata() {
|
||||||
|
for (etag, actual_sizes) in [
|
||||||
|
("0123456789abcdef0123456789abcdef", [4, 4, 4]),
|
||||||
|
("0123456789abcdef0123456789abcdef-3", [4, 0, -1]),
|
||||||
|
] {
|
||||||
|
let mut object_info = replication_route_object(Some(etag), actual_sizes, metadata.clone());
|
||||||
|
object_info.checksum = Some(full_object_multipart_checksum_record());
|
||||||
|
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("multipart put options");
|
||||||
|
assert!(is_multipart, "{name}/{etag}: usable sizes and old multipart ETags must retain MPU");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multipart_object_with_full_object_checksum_keeps_the_multipart_route() {
|
fn multipart_object_with_full_object_checksum_keeps_the_multipart_route() {
|
||||||
// rustfs#6825: a 768-part upload was replicated with a single
|
// rustfs#6825: a 768-part upload was replicated with a single
|
||||||
@@ -581,6 +718,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_multipart_parts_keep_the_replication_route_without_a_multipart_etag() {
|
||||||
|
for etag in [Some("0123456789abcdef0123456789abcdef"), None] {
|
||||||
|
for checksum in [None, Some(full_object_multipart_checksum_record())] {
|
||||||
|
let object_info = ObjectInfo {
|
||||||
|
etag: etag.map(str::to_string),
|
||||||
|
checksum,
|
||||||
|
parts: Arc::new(
|
||||||
|
(1..=2)
|
||||||
|
.map(|number| ObjectPartInfo {
|
||||||
|
number,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (options, is_multipart) =
|
||||||
|
replication_put_object_options("STANDARD", &object_info).expect("build put options");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
is_multipart,
|
||||||
|
"stored parts must retain multipart routing: etag={etag:?}, checksum={:?}",
|
||||||
|
object_info.checksum
|
||||||
|
);
|
||||||
|
assert_eq!(options.internal.source_etag, etag.unwrap_or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn checksum_record_never_changes_the_transport_a_single_part_object_needs() {
|
fn checksum_record_never_changes_the_transport_a_single_part_object_needs() {
|
||||||
// The mirror of the rustfs#6825 guard: an object stored as one PUT
|
// The mirror of the rustfs#6825 guard: an object stored as one PUT
|
||||||
@@ -591,6 +758,10 @@ mod tests {
|
|||||||
let object_info = ObjectInfo {
|
let object_info = ObjectInfo {
|
||||||
etag: Some("0123456789abcdef0123456789abcdef".to_string()),
|
etag: Some("0123456789abcdef0123456789abcdef".to_string()),
|
||||||
checksum: Some(checksum.to_bytes(&[])),
|
checksum: Some(checksum.to_bytes(&[])),
|
||||||
|
parts: Arc::new(vec![ObjectPartInfo {
|
||||||
|
number: 1,
|
||||||
|
..Default::default()
|
||||||
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -627,6 +798,19 @@ mod tests {
|
|||||||
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options");
|
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options");
|
||||||
|
|
||||||
assert!(is_multipart, "a composite-checksum multipart object must stay on the multipart transport");
|
assert!(is_multipart, "a composite-checksum multipart object must stay on the multipart transport");
|
||||||
|
|
||||||
|
for (name, metadata) in replication_route_metadata() {
|
||||||
|
let mut legacy = replication_route_object(Some("0123456789abcdef0123456789abcdef"), [4, 0, 4], metadata);
|
||||||
|
legacy.checksum = Some(checksum.to_bytes(&combined));
|
||||||
|
let (_, record_is_multipart) = legacy.decrypt_checksums(0, &HeaderMap::new()).expect("decode checksum");
|
||||||
|
let (_, is_multipart) = replication_put_object_options("STANDARD", &legacy).expect("legacy checksum put options");
|
||||||
|
if legacy.is_encrypted() {
|
||||||
|
assert!(!is_multipart, "{name}: encrypted checksum records must not change the old transport");
|
||||||
|
} else {
|
||||||
|
assert!(record_is_multipart, "the composite checksum must carry its own multipart signal");
|
||||||
|
assert!(is_multipart, "{name}: a composite record can still promote the legacy route to MPU");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1307,12 +1491,63 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let (opts, _is_multipart) = replication_put_object_options("", &object_info).expect("build replication put options");
|
let (opts, is_multipart) = replication_put_object_options("", &object_info).expect("build replication put options");
|
||||||
|
|
||||||
|
assert!(!is_multipart, "{name}: a single-part checksum record must keep the single-PUT route");
|
||||||
|
let header = ty.key().expect("every forwarded algorithm has an x-amz-checksum header");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
opts.user_metadata.get(name),
|
opts.user_metadata.get(header),
|
||||||
Some(&checksum.encoded),
|
Some(&checksum.encoded),
|
||||||
"replication must forward the {name} checksum into user_metadata identically to the classic algorithms"
|
"replication must forward the {name} checksum as the {header} header"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!opts.user_metadata.contains_key(name),
|
||||||
|
"{name}: the bare algorithm name would leave as x-amz-meta user metadata"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The object-level record of a multipart upload (composite or full-object)
|
||||||
|
/// must not become a PutObject checksum header: the replica is rebuilt
|
||||||
|
/// through CreateMultipartUpload/UploadPart, and a checksum announced there
|
||||||
|
/// that the parts do not carry would be rejected by the target.
|
||||||
|
#[test]
|
||||||
|
fn replication_put_object_options_keeps_multipart_checksum_records_off_the_wire() {
|
||||||
|
let mut composite_type = rustfs_rio::ChecksumType::from_string("crc32");
|
||||||
|
composite_type
|
||||||
|
.merge(rustfs_rio::ChecksumType::MULTIPART)
|
||||||
|
.merge(rustfs_rio::ChecksumType::INCLUDES_MULTIPART);
|
||||||
|
let mut combined = Vec::new();
|
||||||
|
for part in [b"part-one".as_slice(), b"part-two".as_slice()] {
|
||||||
|
let part_checksum =
|
||||||
|
rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::from_string("crc32"), part).expect("part checksum");
|
||||||
|
combined.extend_from_slice(part_checksum.raw.as_slice());
|
||||||
|
}
|
||||||
|
let composite = rustfs_rio::Checksum::new_from_data(composite_type, &combined)
|
||||||
|
.expect("composite checksum")
|
||||||
|
.to_bytes(&combined);
|
||||||
|
|
||||||
|
for (label, checksum, etag) in [
|
||||||
|
("composite", composite, "0123456789abcdef0123456789abcdef-2"),
|
||||||
|
(
|
||||||
|
"full-object",
|
||||||
|
full_object_multipart_checksum_record(),
|
||||||
|
"0123456789abcdef0123456789abcdef-3",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let object_info = ObjectInfo {
|
||||||
|
etag: Some(etag.to_string()),
|
||||||
|
checksum: Some(checksum),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (opts, is_multipart) = replication_put_object_options("", &object_info).expect("build replication put options");
|
||||||
|
assert!(is_multipart, "{label}: a multipart object must keep the multipart route");
|
||||||
|
assert!(
|
||||||
|
opts.user_metadata
|
||||||
|
.keys()
|
||||||
|
.all(|key| !key.starts_with("x-amz-checksum-") && key != "CRC32"),
|
||||||
|
"{label}: no object-level checksum may reach the target's CreateMultipartUpload: {:?}",
|
||||||
|
opts.user_metadata
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ mod tests {
|
|||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
fn encode_context(context: &HashMap<String, String>) -> String {
|
fn encode_context(context: &BTreeMap<String, String>) -> String {
|
||||||
let ordered = context.iter().collect::<BTreeMap<_, _>>();
|
let ordered = context.iter().collect::<BTreeMap<_, _>>();
|
||||||
serde_json::to_string(&ordered).expect("context serializes")
|
serde_json::to_string(&ordered).expect("context serializes")
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user