mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 03:59:14 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||||
|
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=a881fd7d3f5cb94654221ca85b8b30cce1b95e608824a55a15339cbc294e6d34
|
sha256-darwin=a881fd7d3f5cb94654221ca85b8b30cce1b95e608824a55a15339cbc294e6d34
|
||||||
sha256-linux=e9a8d64e73f627c4d26c236dbbba690c9ee03a9e26d42a4244515b4439365535
|
sha256-linux=a2933d83dfe74ffa03410a0959333a1c48288b8469ca9f17273d449d7510c24b
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+15
-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,11 @@ 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
|
||||||
|
|
||||||
- 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:
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# 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. `sm-standard-4` is an ARC pod
|
||||||
|
# (`scripts/ci/check_runner_ephemerality.sh`) and usually has no
|
||||||
|
# `/dev/loop-control`, so `mount -o loop` fails with ENOENT ("mount failed:
|
||||||
|
# No such file or directory"). The prepare step therefore mounts four 1 GiB
|
||||||
|
# tmpfs instances and exports them as `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
|
||||||
|
runs-on: sm-standard-4
|
||||||
|
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
|
||||||
|
cache-shared-key: ci-e2e-distributed
|
||||||
|
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}"
|
||||||
|
# sm-standard-4 is an ARC pod without usable loop devices, so
|
||||||
|
# `mount -o loop` fails with ENOENT. Sized tmpfs still reports a
|
||||||
|
# distinct st_dev and independent 1G statfs capacity.
|
||||||
|
sudo mount -t tmpfs -o size=1G,nosuid,nodev,mode=1777 tmpfs "${mountpoint}"
|
||||||
|
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
+356
-111
File diff suppressed because it is too large
Load Diff
+17
-12
@@ -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" }
|
||||||
@@ -199,10 +199,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 +234,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" }
|
||||||
@@ -339,7 +343,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 +371,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" }
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,21 @@ 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`.
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
@@ -812,6 +834,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) |
|
||||||
@@ -191,6 +193,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(())
|
||||||
|
}
|
||||||
@@ -16,15 +16,18 @@
|
|||||||
|
|
||||||
#[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;
|
||||||
@@ -34,6 +37,76 @@ mod tests {
|
|||||||
|
|
||||||
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) -> Result<Option<(PathBuf, RestartEvidenceRun)>, Box<dyn Error + Send + Sync>> {
|
||||||
|
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
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("background-target-restart.json").exists() {
|
||||||
|
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
||||||
|
}
|
||||||
|
Ok(Some((directory, run)))
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -195,8 +268,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 +317,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 {
|
||||||
@@ -808,6 +884,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)?
|
||||||
|
} 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 +938,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 +1079,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 +1357,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 +1456,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 +1464,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 +1535,31 @@ 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((directory, run)) = 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)?, run.binary.sha256, "server build changed during restart");
|
||||||
|
let evidence = serde_json::json!({
|
||||||
|
"schema": 1, "case": "background-target-restart", "evidence": "process-restart",
|
||||||
|
"run_id": run.run_id, "source_revision": run.source_revision,
|
||||||
|
"test_build": compiled_test_identity(),
|
||||||
|
"binary_sha256": run.binary.sha256, "test_binary_sha256": 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(directory.join("background-target-restart.json"))?;
|
||||||
|
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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6743,6 +6743,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
|
||||||
|
|||||||
@@ -12,21 +12,34 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
use crate::common::{
|
||||||
|
RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging, replication_fast_env, rustfs_binary_path,
|
||||||
|
};
|
||||||
|
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target};
|
||||||
|
use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject};
|
||||||
|
use crate::replication_extension_test::{
|
||||||
|
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
|
||||||
|
};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ServerSideEncryption, VersioningConfiguration,
|
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, DefaultRetention,
|
||||||
|
ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, ObjectLockConfiguration, ObjectLockEnabled,
|
||||||
|
ObjectLockRetentionMode, ObjectLockRule, PublicAccessBlockConfiguration, ServerSideEncryption, ServerSideEncryptionByDefault,
|
||||||
|
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Tag, Tagging, VersioningConfiguration,
|
||||||
};
|
};
|
||||||
|
use http::{Method, StatusCode};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tokio::time::{Instant, sleep};
|
use tokio::time::{Instant, sleep};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||||
|
|
||||||
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||||
|
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
|
||||||
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||||
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||||
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
||||||
@@ -40,6 +53,32 @@ const MULTIPART_UPLOADS_PER_WORKER: usize = 16;
|
|||||||
// comfortably covers that window plus CI scheduling jitter.
|
// comfortably covers that window plus CI scheduling jitter.
|
||||||
const LISTING_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
const LISTING_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
// Bucket-configuration upgrade/rollback scenarios (rustfs#7172, #7183, #7089).
|
||||||
|
const CONFIG_PLAIN_BUCKET: &str = "upgrade-config-plain";
|
||||||
|
const CONFIG_ENCRYPTED_BUCKET: &str = "upgrade-config-encrypted";
|
||||||
|
const CONFIG_REPLICATED_BUCKET: &str = "upgrade-config-replicated";
|
||||||
|
const CONFIG_LOCKED_BUCKET: &str = "upgrade-config-locked";
|
||||||
|
const CONFIG_REPLICA_BUCKET: &str = "upgrade-config-replica";
|
||||||
|
const ROLLBACK_BUCKET: &str = "rollback-config-data";
|
||||||
|
const ROLLBACK_REPLICA_BUCKET: &str = "rollback-config-replica";
|
||||||
|
const BUCKET_QUOTA_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
const LIFECYCLE_RULE_ID: &str = "upgrade-expire-logs";
|
||||||
|
const LIFECYCLE_PREFIX: &str = "logs/";
|
||||||
|
const LIFECYCLE_DAYS: i32 = 30;
|
||||||
|
const BUCKET_TAG_KEY: &str = "owner";
|
||||||
|
const BUCKET_TAG_VALUE: &str = "upgrade-compatibility";
|
||||||
|
const OBJECT_LOCK_DAYS: i32 = 1;
|
||||||
|
// `set-bucket-quota` answers 503 until the scanner has made the bucket's usage
|
||||||
|
// authoritative; the quota test uses the same 30s budget.
|
||||||
|
const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
// Quota admission fails closed while a freshly started server has neither
|
||||||
|
// authoritative usage nor a persisted degraded baseline for the bucket
|
||||||
|
// (rustfs#5716), so a write to a quota-enabled bucket is retryable-503 for that
|
||||||
|
// window. It is a restart property, not an upgrade property — the same window
|
||||||
|
// opens on the very first start — so the write assertions ride it out instead
|
||||||
|
// of treating it as an upgrade failure.
|
||||||
|
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
|
||||||
|
|
||||||
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
@@ -240,6 +279,93 @@ async fn exercise_mixed_cluster(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pins the published old writer's limitation and the supported recovery
|
||||||
|
/// procedure. This is not a promise that mixed-version ODM is supported.
|
||||||
|
/// Replace the loss assertion when ODM gains independent persistence;
|
||||||
|
/// preserving configuration across rc.5 writes is then an improvement.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires the pinned 1.0.0-rc.5 release binary"]
|
||||||
|
async fn rc5_rollback_requires_restoring_odm_configuration() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous_binary = source_binary()?;
|
||||||
|
let version = tokio::process::Command::new(&previous_binary)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
assert!(version.status.success(), "previous binary must report its version");
|
||||||
|
assert!(
|
||||||
|
String::from_utf8(version.stdout)?.contains(RC5_COMMIT),
|
||||||
|
"this compatibility scenario requires the published rc.5 writer"
|
||||||
|
);
|
||||||
|
let mut env = OdmTestEnv::start().await?;
|
||||||
|
let bucket = "odm-rc5-rollback";
|
||||||
|
let source_bucket = "odm-rc5-source";
|
||||||
|
env.source.create_bucket_with_mode(source_bucket, BucketMode::Unversioned);
|
||||||
|
env.seed_source(
|
||||||
|
source_bucket,
|
||||||
|
&[SeedObject::new(
|
||||||
|
"source-only",
|
||||||
|
bytes::Bytes::from_static(b"source read after recovery"),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
env.rustfs.create_test_bucket(bucket).await?;
|
||||||
|
let saved_config = env.fake_source_spec(source_bucket);
|
||||||
|
assert_eq!(env.configure_source(bucket, &saved_config).await?.status, 200);
|
||||||
|
let before = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(before.status, 200);
|
||||||
|
let expected_config = before
|
||||||
|
.json()?
|
||||||
|
.get("config")
|
||||||
|
.cloned()
|
||||||
|
.ok_or("configuration response omitted config")?;
|
||||||
|
env.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("local")
|
||||||
|
.body(ByteStream::from_static(b"local data survives rollback"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||||
|
let restarted = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(restarted.status, 200, "a current writer preserves ODM across restart");
|
||||||
|
assert_eq!(restarted.json()?.get("config"), Some(&expected_config));
|
||||||
|
|
||||||
|
restart_from_binary(&mut env.rustfs, &previous_binary, &[]).await?;
|
||||||
|
env.client
|
||||||
|
.put_bucket_tagging()
|
||||||
|
.bucket(bucket)
|
||||||
|
.tagging(
|
||||||
|
Tagging::builder()
|
||||||
|
.tag_set(Tag::builder().key("writer").value("rc5").build()?)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||||
|
let missing = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(missing.status, 404, "rc.5 rewrites metadata without ODM keys");
|
||||||
|
assert!(missing.body.contains("NoSuchConfiguration"));
|
||||||
|
assert_eq!(read_object(&env.client, bucket, "local", None).await?.1, b"local data survives rollback");
|
||||||
|
let tags = env.client.get_bucket_tagging().bucket(bucket).send().await?;
|
||||||
|
assert!(tags.tag_set().iter().any(|tag| tag.key() == "writer" && tag.value() == "rc5"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
env.configure_source(bucket, &saved_config).await?.status,
|
||||||
|
200,
|
||||||
|
"restore from saved full configuration"
|
||||||
|
);
|
||||||
|
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||||
|
let restored = env.get_config(bucket).await?;
|
||||||
|
assert_eq!(restored.status, 200, "restored ODM configuration persists");
|
||||||
|
assert_eq!(restored.json()?.get("config"), Some(&expected_config));
|
||||||
|
env.wait_until_source_consulted(bucket).await?;
|
||||||
|
assert_eq!(
|
||||||
|
read_object(&env.client, bucket, "source-only", None).await?.1,
|
||||||
|
b"source read after recovery"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires a pinned previous RustFS release binary"]
|
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||||
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
||||||
@@ -429,3 +555,653 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Child-process environment shared by both bucket-configuration scenarios.
|
||||||
|
///
|
||||||
|
/// The replication target is an in-process fake bound to `127.0.0.1`, which
|
||||||
|
/// `set-remote-target` rejects as an SSRF risk without the loopback opt-in, and
|
||||||
|
/// the proxy bypass keeps a developer's `HTTP_PROXY` from intercepting the
|
||||||
|
/// server's outbound health check.
|
||||||
|
fn bucket_config_server_env() -> Vec<(&'static str, &'static str)> {
|
||||||
|
let mut env = vec![
|
||||||
|
(SSE_MASTER_KEY_ENV, SSE_MASTER_KEY),
|
||||||
|
("NO_PROXY", "127.0.0.1,localhost"),
|
||||||
|
("HTTP_PROXY", ""),
|
||||||
|
("HTTPS_PROXY", ""),
|
||||||
|
// Shorten the scanner cycle so the bucket's usage becomes authoritative
|
||||||
|
// in seconds; both `set-bucket-quota` and quota admission block on it.
|
||||||
|
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||||
|
("RUSTFS_SCANNER_START_DELAY_SECS", "0"),
|
||||||
|
];
|
||||||
|
env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||||
|
env.extend(replication_fast_env());
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart `env` in place on the same data directory using an explicit binary.
|
||||||
|
///
|
||||||
|
/// [`RustFSTestEnvironment::restart_server_preserving_data`] always relaunches
|
||||||
|
/// the workspace build, which is the upgrade direction only. The rollback
|
||||||
|
/// scenario needs the reverse: stop the current build and bring the pinned
|
||||||
|
/// previous release up on the metadata that build just wrote.
|
||||||
|
async fn restart_from_binary(env: &mut RustFSTestEnvironment, binary: &Path, server_env: &[(&str, &str)]) -> TestResult {
|
||||||
|
env.stop_server();
|
||||||
|
env.start_rustfs_server_from_binary(binary, vec![], server_env).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_bucket_quota(env: &RustFSTestEnvironment, bucket: &str, quota_bytes: u64) -> TestResult {
|
||||||
|
let path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||||
|
let body = serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" }).to_string();
|
||||||
|
let deadline = Instant::now() + QUOTA_READINESS_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let (status, response) =
|
||||||
|
admin_request(&env.url, Method::PUT, &path, Some(body.clone()), &env.access_key, &env.secret_key).await?;
|
||||||
|
if status.is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if status != StatusCode::SERVICE_UNAVAILABLE || Instant::now() >= deadline {
|
||||||
|
return Err(format!("setting the quota of {bucket} failed: {status} {response}").into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT into a quota-enabled bucket, riding out the post-start quota-admission
|
||||||
|
/// warm-up described on [`QUOTA_ADMISSION_WARMUP_TIMEOUT`].
|
||||||
|
///
|
||||||
|
/// Only `ServiceUnavailable` is retried: any other failure, and a warm-up that
|
||||||
|
/// never ends, is a genuine regression and surfaces as an error.
|
||||||
|
async fn put_object_through_quota_warmup(client: &Client, bucket: &str, key: &str, body: &'static [u8]) -> TestResult {
|
||||||
|
let deadline = Instant::now() + QUOTA_ADMISSION_WARMUP_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let result = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from_static(body))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
let error = match result {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
let retryable = error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("ServiceUnavailable");
|
||||||
|
if !retryable || Instant::now() >= deadline {
|
||||||
|
return Err(format!("PUT {bucket}/{key} failed after the quota warm-up window: {error}").into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_bucket_quota(env: &RustFSTestEnvironment, bucket: &str) -> Result<Option<u64>, BoxError> {
|
||||||
|
let path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||||
|
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||||
|
if status != StatusCode::OK {
|
||||||
|
return Err(format!("reading the quota of {bucket} failed: {status} {response}").into());
|
||||||
|
}
|
||||||
|
let quota: serde_json::Value = serde_json::from_str(&response)?;
|
||||||
|
Ok(quota.get("quota").and_then(serde_json::Value::as_u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /rustfs/admin/v3/list-remote-targets?bucket=...`.
|
||||||
|
///
|
||||||
|
/// Returns an error for any non-200, because rustfs#7172 made this endpoint
|
||||||
|
/// fail closed on a `bucket-targets.json` blob the running build cannot parse.
|
||||||
|
/// An upgrade that misreads a blob written by the previous release therefore
|
||||||
|
/// shows up here as an error, and a silently dropped target shows up as an
|
||||||
|
/// empty list — the caller must distinguish the two.
|
||||||
|
async fn list_remote_targets(env: &RustFSTestEnvironment, bucket: &str) -> Result<Vec<serde_json::Value>, BoxError> {
|
||||||
|
let path = format!("/rustfs/admin/v3/list-remote-targets?bucket={}", urlencoding::encode(bucket));
|
||||||
|
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||||
|
if status != StatusCode::OK {
|
||||||
|
return Err(format!("list-remote-targets for {bucket} failed: {status} {response}").into());
|
||||||
|
}
|
||||||
|
Ok(serde_json::from_str(&response)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that `bucket` still carries exactly the replication target `arn`.
|
||||||
|
async fn assert_remote_target_preserved(env: &RustFSTestEnvironment, bucket: &str, arn: &str, context: &str) -> TestResult {
|
||||||
|
let targets = list_remote_targets(env, bucket).await?;
|
||||||
|
assert_eq!(
|
||||||
|
targets.len(),
|
||||||
|
1,
|
||||||
|
"{context}: list-remote-targets must still report the single configured target, got {targets:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
targets[0].get("arn").and_then(serde_json::Value::as_str),
|
||||||
|
Some(arn),
|
||||||
|
"{context}: the target ARN changed across the restart: {targets:?}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure a replication target on `bucket` pointing at the in-process fake,
|
||||||
|
/// then attach an enabled replication rule for it. Returns the target ARN.
|
||||||
|
async fn configure_replication(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
bucket: &str,
|
||||||
|
target: &FakeS3Target,
|
||||||
|
target_bucket: &str,
|
||||||
|
) -> Result<String, BoxError> {
|
||||||
|
let arn = set_replication_target_with_options(
|
||||||
|
env,
|
||||||
|
bucket,
|
||||||
|
ReplicationTargetOptions {
|
||||||
|
endpoint: &target.address(),
|
||||||
|
access_key: FAKE_ACCESS_KEY,
|
||||||
|
secret_key: FAKE_SECRET_KEY,
|
||||||
|
target_bucket,
|
||||||
|
secure: false,
|
||||||
|
skip_tls_verify: false,
|
||||||
|
ca_cert_pem: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
put_bucket_replication(env, bucket, &arn).await?;
|
||||||
|
Ok(arn)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_default_sse_s3_encryption(client: &Client, bucket: &str) -> TestResult {
|
||||||
|
let configuration = ServerSideEncryptionConfiguration::builder()
|
||||||
|
.rules(
|
||||||
|
ServerSideEncryptionRule::builder()
|
||||||
|
.apply_server_side_encryption_by_default(
|
||||||
|
ServerSideEncryptionByDefault::builder()
|
||||||
|
.sse_algorithm(ServerSideEncryption::Aes256)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()?;
|
||||||
|
client
|
||||||
|
.put_bucket_encryption()
|
||||||
|
.bucket(bucket)
|
||||||
|
.server_side_encryption_configuration(configuration)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_default_sse_s3_encryption(client: &Client, bucket: &str, context: &str) -> TestResult {
|
||||||
|
let response = client.get_bucket_encryption().bucket(bucket).send().await?;
|
||||||
|
let rules = response
|
||||||
|
.server_side_encryption_configuration()
|
||||||
|
.ok_or("GetBucketEncryption omitted the configuration")?
|
||||||
|
.rules();
|
||||||
|
assert_eq!(rules.len(), 1, "{context}: expected exactly one encryption rule, got {rules:?}");
|
||||||
|
assert_eq!(
|
||||||
|
rules[0]
|
||||||
|
.apply_server_side_encryption_by_default()
|
||||||
|
.map(ServerSideEncryptionByDefault::sse_algorithm),
|
||||||
|
Some(&ServerSideEncryption::Aes256),
|
||||||
|
"{context}: the default encryption algorithm changed"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_bucket_tag(client: &Client, bucket: &str) -> TestResult {
|
||||||
|
let tagging = Tagging::builder()
|
||||||
|
.tag_set(Tag::builder().key(BUCKET_TAG_KEY).value(BUCKET_TAG_VALUE).build()?)
|
||||||
|
.build()?;
|
||||||
|
client.put_bucket_tagging().bucket(bucket).tagging(tagging).send().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_bucket_tag(client: &Client, bucket: &str, context: &str) -> TestResult {
|
||||||
|
let tags = client.get_bucket_tagging().bucket(bucket).send().await?;
|
||||||
|
let tag_set = tags.tag_set();
|
||||||
|
assert_eq!(tag_set.len(), 1, "{context}: expected exactly one bucket tag, got {tag_set:?}");
|
||||||
|
assert_eq!(tag_set[0].key(), BUCKET_TAG_KEY, "{context}: bucket tag key changed");
|
||||||
|
assert_eq!(tag_set[0].value(), BUCKET_TAG_VALUE, "{context}: bucket tag value changed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_versioning_enabled(client: &Client, bucket: &str, context: &str) -> TestResult {
|
||||||
|
let versioning = client.get_bucket_versioning().bucket(bucket).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
versioning.status(),
|
||||||
|
Some(&BucketVersioningStatus::Enabled),
|
||||||
|
"{context}: versioning is no longer Enabled on {bucket}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bucket_policy_document(bucket: &str) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Sid": "UpgradePublicRead",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": { "AWS": ["*"] },
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": [format!("arn:aws:s3:::{bucket}/public/*")]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET .../on-demand-migration/{bucket}/status`.
|
||||||
|
///
|
||||||
|
/// The migration module defaults on from rustfs#7089, so a bucket that never
|
||||||
|
/// configured a source must still answer `configured: false` rather than
|
||||||
|
/// engaging the migration path.
|
||||||
|
async fn assert_migration_not_configured(env: &RustFSTestEnvironment, bucket: &str) -> TestResult {
|
||||||
|
let path = format!("/rustfs/admin/v3/on-demand-migration/{bucket}/status");
|
||||||
|
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::OK,
|
||||||
|
"the migration status endpoint must answer for an unconfigured bucket: {status} {response}"
|
||||||
|
);
|
||||||
|
let body: serde_json::Value = serde_json::from_str(&response)?;
|
||||||
|
assert_eq!(
|
||||||
|
body.get("configured"),
|
||||||
|
Some(&serde_json::Value::Bool(false)),
|
||||||
|
"a bucket upgraded from the previous release must not look migration-configured: {body}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GET for a key that was never written must be a plain `NoSuchKey`.
|
||||||
|
///
|
||||||
|
/// With the migration module on by default this is the cheap proof that an
|
||||||
|
/// unconfigured bucket never consults a source: any migration engagement would
|
||||||
|
/// surface as a different status or error code here.
|
||||||
|
async fn assert_missing_key_is_no_such_key(client: &Client, bucket: &str, key: &str) -> TestResult {
|
||||||
|
let error = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a key that was never written must not be readable");
|
||||||
|
assert_eq!(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"a missing key must stay a 404 on a bucket with no migration configuration"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey"),
|
||||||
|
"a missing key must stay NoSuchKey on a bucket with no migration configuration"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bucket configuration written by the pinned previous release must survive an
|
||||||
|
/// upgrade to the current build unchanged, and must keep working.
|
||||||
|
///
|
||||||
|
/// This pins the three on-disk surfaces the on-demand-migration series moved:
|
||||||
|
///
|
||||||
|
/// * `BucketMetadata` grew two msgpack keys (encoded map length 44 -> 46), so
|
||||||
|
/// every configuration read below decodes a 44-key blob on 46-key code.
|
||||||
|
/// * rustfs#7172 made an unreadable `bucket-targets.json` / encryption /
|
||||||
|
/// public-access-block / quota blob "present but unreadable" instead of
|
||||||
|
/// silently defaulting, and made `list-remote-targets` fail closed on it. A
|
||||||
|
/// replication target configured by the old release must therefore still be
|
||||||
|
/// *listed*, not dropped and not an error.
|
||||||
|
/// * rustfs#7183 made the object write path refuse a PUT when the bucket's
|
||||||
|
/// encryption configuration cannot be read, so a misparsed SSE config would
|
||||||
|
/// turn every PUT to that bucket into a 500.
|
||||||
|
///
|
||||||
|
/// Not covered on purpose: on-demand-migration configuration itself, which the
|
||||||
|
/// previous release has no public API for — the reverse direction is asserted
|
||||||
|
/// instead (an upgraded bucket reports `configured: false`).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||||
|
async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous_binary = source_binary()?;
|
||||||
|
|
||||||
|
// In-process: the fake target outlives both server processes, so the
|
||||||
|
// replication target stays reachable across the upgrade.
|
||||||
|
let replication_target = FakeS3Target::start().await?;
|
||||||
|
replication_target.create_bucket(CONFIG_REPLICA_BUCKET);
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
let server_env = bucket_config_server_env();
|
||||||
|
env.start_rustfs_server_from_binary(&previous_binary, vec![], &server_env)
|
||||||
|
.await?;
|
||||||
|
let old_client = env.create_s3_client();
|
||||||
|
|
||||||
|
env.create_test_bucket(CONFIG_PLAIN_BUCKET).await?;
|
||||||
|
env.create_test_bucket(CONFIG_ENCRYPTED_BUCKET).await?;
|
||||||
|
env.create_test_bucket(CONFIG_REPLICATED_BUCKET).await?;
|
||||||
|
old_client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(CONFIG_LOCKED_BUCKET)
|
||||||
|
.object_lock_enabled_for_bucket(true)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Plain bucket: policy, tags, lifecycle, quota.
|
||||||
|
let policy = bucket_policy_document(CONFIG_PLAIN_BUCKET);
|
||||||
|
old_client
|
||||||
|
.put_bucket_policy()
|
||||||
|
.bucket(CONFIG_PLAIN_BUCKET)
|
||||||
|
.policy(policy.to_string())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
put_bucket_tag(&old_client, CONFIG_PLAIN_BUCKET).await?;
|
||||||
|
old_client
|
||||||
|
.put_bucket_lifecycle_configuration()
|
||||||
|
.bucket(CONFIG_PLAIN_BUCKET)
|
||||||
|
.lifecycle_configuration(
|
||||||
|
BucketLifecycleConfiguration::builder()
|
||||||
|
.rules(
|
||||||
|
LifecycleRule::builder()
|
||||||
|
.id(LIFECYCLE_RULE_ID)
|
||||||
|
.status(ExpirationStatus::Enabled)
|
||||||
|
.filter(LifecycleRuleFilter::builder().prefix(LIFECYCLE_PREFIX).build())
|
||||||
|
.expiration(LifecycleExpiration::builder().days(LIFECYCLE_DAYS).build())
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
set_bucket_quota(&env, CONFIG_PLAIN_BUCKET, BUCKET_QUOTA_BYTES).await?;
|
||||||
|
|
||||||
|
// Encrypted bucket: SSE-S3 default encryption plus a fully restrictive
|
||||||
|
// public access block, both of which rustfs#7172 now fails closed on.
|
||||||
|
put_default_sse_s3_encryption(&old_client, CONFIG_ENCRYPTED_BUCKET).await?;
|
||||||
|
old_client
|
||||||
|
.put_public_access_block()
|
||||||
|
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||||
|
.public_access_block_configuration(
|
||||||
|
PublicAccessBlockConfiguration::builder()
|
||||||
|
.block_public_acls(true)
|
||||||
|
.ignore_public_acls(true)
|
||||||
|
.block_public_policy(true)
|
||||||
|
.restrict_public_buckets(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Replicated bucket: versioning, a validated remote target, a rule.
|
||||||
|
enable_versioning(&old_client, CONFIG_REPLICATED_BUCKET).await?;
|
||||||
|
let target_arn = configure_replication(&env, CONFIG_REPLICATED_BUCKET, &replication_target, CONFIG_REPLICA_BUCKET).await?;
|
||||||
|
assert_remote_target_preserved(&env, CONFIG_REPLICATED_BUCKET, &target_arn, "before the upgrade").await?;
|
||||||
|
|
||||||
|
// Object-lock bucket: a default GOVERNANCE retention on a fresh bucket.
|
||||||
|
old_client
|
||||||
|
.put_object_lock_configuration()
|
||||||
|
.bucket(CONFIG_LOCKED_BUCKET)
|
||||||
|
.object_lock_configuration(
|
||||||
|
ObjectLockConfiguration::builder()
|
||||||
|
.object_lock_enabled(ObjectLockEnabled::Enabled)
|
||||||
|
.rule(
|
||||||
|
ObjectLockRule::builder()
|
||||||
|
.default_retention(
|
||||||
|
DefaultRetention::builder()
|
||||||
|
.mode(ObjectLockRetentionMode::Governance)
|
||||||
|
.days(OBJECT_LOCK_DAYS)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let plain_key = "plain/written-by-previous";
|
||||||
|
let plain_bytes = b"plain object written by the previous RustFS release";
|
||||||
|
put_object_through_quota_warmup(&old_client, CONFIG_PLAIN_BUCKET, plain_key, plain_bytes).await?;
|
||||||
|
|
||||||
|
let encrypted_key = "encrypted/written-by-previous";
|
||||||
|
let encrypted_bytes = b"default-encrypted object written by the previous RustFS release";
|
||||||
|
old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||||
|
.key(encrypted_key)
|
||||||
|
.body(ByteStream::from_static(encrypted_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
read_object(&old_client, CONFIG_ENCRYPTED_BUCKET, encrypted_key, None)
|
||||||
|
.await?
|
||||||
|
.0,
|
||||||
|
Some(ServerSideEncryption::Aes256),
|
||||||
|
"the previous release must apply the bucket default encryption it just accepted"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The multipart object lives in the default-encrypted bucket so the
|
||||||
|
// upgraded build has to reassemble parts *and* re-derive the object key.
|
||||||
|
let multipart_key = "encrypted/multipart-written-by-previous";
|
||||||
|
let multipart_parts = vec![vec![b'm'; 5 * 1024 * 1024], b"final multipart bytes".to_vec()];
|
||||||
|
let multipart_bytes = multipart_parts.concat();
|
||||||
|
write_multipart(&old_client, CONFIG_ENCRYPTED_BUCKET, multipart_key, &multipart_parts).await?;
|
||||||
|
|
||||||
|
let versioned_key = "versioned/written-by-previous";
|
||||||
|
let versioned_bytes = b"versioned object written by the previous RustFS release";
|
||||||
|
let versioned_id = old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(CONFIG_REPLICATED_BUCKET)
|
||||||
|
.key(versioned_key)
|
||||||
|
.body(ByteStream::from_static(versioned_bytes))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||||
|
let new_client = env.create_s3_client();
|
||||||
|
|
||||||
|
// Every configuration must read back unchanged on the upgraded build.
|
||||||
|
let upgraded_policy = new_client.get_bucket_policy().bucket(CONFIG_PLAIN_BUCKET).send().await?;
|
||||||
|
let upgraded_policy: serde_json::Value =
|
||||||
|
serde_json::from_str(upgraded_policy.policy().ok_or("GetBucketPolicy omitted the document")?)?;
|
||||||
|
assert_eq!(upgraded_policy, policy, "the bucket policy changed across the upgrade");
|
||||||
|
assert_bucket_tag(&new_client, CONFIG_PLAIN_BUCKET, "after the upgrade").await?;
|
||||||
|
|
||||||
|
let lifecycle = new_client
|
||||||
|
.get_bucket_lifecycle_configuration()
|
||||||
|
.bucket(CONFIG_PLAIN_BUCKET)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let rules = lifecycle.rules();
|
||||||
|
assert_eq!(rules.len(), 1, "the lifecycle rule count changed across the upgrade: {rules:?}");
|
||||||
|
assert_eq!(rules[0].id(), Some(LIFECYCLE_RULE_ID));
|
||||||
|
assert_eq!(rules[0].status(), &ExpirationStatus::Enabled);
|
||||||
|
assert_eq!(
|
||||||
|
rules[0].expiration().and_then(LifecycleExpiration::days),
|
||||||
|
Some(LIFECYCLE_DAYS),
|
||||||
|
"the lifecycle expiration changed across the upgrade"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_bucket_quota(&env, CONFIG_PLAIN_BUCKET).await?,
|
||||||
|
Some(BUCKET_QUOTA_BYTES),
|
||||||
|
"the bucket quota changed across the upgrade"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_default_sse_s3_encryption(&new_client, CONFIG_ENCRYPTED_BUCKET, "after the upgrade").await?;
|
||||||
|
let public_access_block = new_client
|
||||||
|
.get_public_access_block()
|
||||||
|
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let public_access_block = public_access_block
|
||||||
|
.public_access_block_configuration()
|
||||||
|
.ok_or("GetPublicAccessBlock omitted the configuration")?;
|
||||||
|
assert_eq!(public_access_block.block_public_acls(), Some(true));
|
||||||
|
assert_eq!(public_access_block.ignore_public_acls(), Some(true));
|
||||||
|
assert_eq!(public_access_block.block_public_policy(), Some(true));
|
||||||
|
assert_eq!(public_access_block.restrict_public_buckets(), Some(true));
|
||||||
|
|
||||||
|
assert_versioning_enabled(&new_client, CONFIG_REPLICATED_BUCKET, "after the upgrade").await?;
|
||||||
|
// rustfs#7172: neither an empty list nor an error is acceptable here.
|
||||||
|
assert_remote_target_preserved(&env, CONFIG_REPLICATED_BUCKET, &target_arn, "after the upgrade").await?;
|
||||||
|
let replication = new_client
|
||||||
|
.get_bucket_replication()
|
||||||
|
.bucket(CONFIG_REPLICATED_BUCKET)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let replication_rules = replication
|
||||||
|
.replication_configuration()
|
||||||
|
.ok_or("GetBucketReplication omitted the configuration")?
|
||||||
|
.rules();
|
||||||
|
assert_eq!(
|
||||||
|
replication_rules.len(),
|
||||||
|
1,
|
||||||
|
"the replication rule count changed across the upgrade: {replication_rules:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
replication_rules[0].destination().map(|destination| destination.bucket()),
|
||||||
|
Some(target_arn.as_str()),
|
||||||
|
"the replication rule no longer points at the configured target"
|
||||||
|
);
|
||||||
|
|
||||||
|
let object_lock = new_client
|
||||||
|
.get_object_lock_configuration()
|
||||||
|
.bucket(CONFIG_LOCKED_BUCKET)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let object_lock = object_lock
|
||||||
|
.object_lock_configuration()
|
||||||
|
.ok_or("GetObjectLockConfiguration omitted the configuration")?;
|
||||||
|
assert_eq!(object_lock.object_lock_enabled(), Some(&ObjectLockEnabled::Enabled));
|
||||||
|
let retention = object_lock
|
||||||
|
.rule()
|
||||||
|
.and_then(ObjectLockRule::default_retention)
|
||||||
|
.ok_or("the object lock configuration lost its default retention")?;
|
||||||
|
assert_eq!(retention.mode(), Some(&ObjectLockRetentionMode::Governance));
|
||||||
|
assert_eq!(retention.days(), Some(OBJECT_LOCK_DAYS));
|
||||||
|
|
||||||
|
// rustfs#7183: a PUT into the default-encrypted bucket must still succeed
|
||||||
|
// and still come back encrypted.
|
||||||
|
let post_upgrade_encrypted_key = "encrypted/written-after-upgrade";
|
||||||
|
let post_upgrade_encrypted_bytes = b"default-encrypted object written by the current RustFS build";
|
||||||
|
new_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(CONFIG_ENCRYPTED_BUCKET)
|
||||||
|
.key(post_upgrade_encrypted_key)
|
||||||
|
.body(ByteStream::from_static(post_upgrade_encrypted_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, post_upgrade_encrypted_key, None).await?;
|
||||||
|
assert_eq!(
|
||||||
|
encryption,
|
||||||
|
Some(ServerSideEncryption::Aes256),
|
||||||
|
"a PUT after the upgrade lost the bucket default encryption"
|
||||||
|
);
|
||||||
|
assert_eq!(body, post_upgrade_encrypted_bytes);
|
||||||
|
|
||||||
|
let post_upgrade_plain_key = "plain/written-after-upgrade";
|
||||||
|
let post_upgrade_plain_bytes = b"plain object written by the current RustFS build";
|
||||||
|
put_object_through_quota_warmup(&new_client, CONFIG_PLAIN_BUCKET, post_upgrade_plain_key, post_upgrade_plain_bytes).await?;
|
||||||
|
let (encryption, body) = read_object(&new_client, CONFIG_PLAIN_BUCKET, post_upgrade_plain_key, None).await?;
|
||||||
|
assert_eq!(encryption, None, "a bucket without default encryption must not encrypt a PUT");
|
||||||
|
assert_eq!(body, post_upgrade_plain_bytes);
|
||||||
|
|
||||||
|
// Every object written by the previous release reads back byte-identical.
|
||||||
|
assert_eq!(read_object(&new_client, CONFIG_PLAIN_BUCKET, plain_key, None).await?.1, plain_bytes);
|
||||||
|
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, encrypted_key, None).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(body, encrypted_bytes);
|
||||||
|
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, multipart_key, None).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(body, multipart_bytes, "the multipart object did not survive the upgrade");
|
||||||
|
assert_eq!(
|
||||||
|
read_object(&new_client, CONFIG_REPLICATED_BUCKET, versioned_key, Some(&versioned_id))
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
|
versioned_bytes
|
||||||
|
);
|
||||||
|
|
||||||
|
// rustfs#7089: the migration module is on by default, but a bucket that
|
||||||
|
// never configured a source behaves exactly as before.
|
||||||
|
assert_migration_not_configured(&env, CONFIG_PLAIN_BUCKET).await?;
|
||||||
|
assert_missing_key_is_no_such_key(&new_client, CONFIG_PLAIN_BUCKET, "plain/never-written").await?;
|
||||||
|
|
||||||
|
replication_target.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling back to the pinned previous release must still read the bucket
|
||||||
|
/// metadata the current build wrote.
|
||||||
|
///
|
||||||
|
/// This is the other half of the `BucketMetadata` 44 -> 46 key change: the
|
||||||
|
/// current build writes a 46-key msgpack map with `OnDemandMigrationConfigJSON`
|
||||||
|
/// and `OnDemandMigrationConfigUpdatedAt`, and the previous release's decoder
|
||||||
|
/// has to skip those two unknown keys instead of failing the whole blob. If it
|
||||||
|
/// did not, every configuration read below would come back empty or error and
|
||||||
|
/// the rollback would silently discard the bucket's configuration.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||||
|
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let previous_binary = source_binary()?;
|
||||||
|
|
||||||
|
let replication_target = FakeS3Target::start().await?;
|
||||||
|
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
let server_env = bucket_config_server_env();
|
||||||
|
env.start_rustfs_server_with_env(vec![], &server_env).await?;
|
||||||
|
let new_client = env.create_s3_client();
|
||||||
|
|
||||||
|
env.create_test_bucket(ROLLBACK_BUCKET).await?;
|
||||||
|
enable_versioning(&new_client, ROLLBACK_BUCKET).await?;
|
||||||
|
put_default_sse_s3_encryption(&new_client, ROLLBACK_BUCKET).await?;
|
||||||
|
put_bucket_tag(&new_client, ROLLBACK_BUCKET).await?;
|
||||||
|
let target_arn = configure_replication(&env, ROLLBACK_BUCKET, &replication_target, ROLLBACK_REPLICA_BUCKET).await?;
|
||||||
|
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "before the rollback").await?;
|
||||||
|
|
||||||
|
let single_key = "rollback/single";
|
||||||
|
let single_bytes = b"single-part object written by the current RustFS build";
|
||||||
|
let single_version = new_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(ROLLBACK_BUCKET)
|
||||||
|
.key(single_key)
|
||||||
|
.body(ByteStream::from_static(single_bytes))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.version_id()
|
||||||
|
.ok_or("versioned PUT omitted version ID")?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let multipart_key = "rollback/multipart";
|
||||||
|
let multipart_parts = vec![vec![b'r'; 5 * 1024 * 1024], b"final rollback bytes".to_vec()];
|
||||||
|
let multipart_bytes = multipart_parts.concat();
|
||||||
|
write_multipart(&new_client, ROLLBACK_BUCKET, multipart_key, &multipart_parts).await?;
|
||||||
|
|
||||||
|
restart_from_binary(&mut env, &previous_binary, &server_env).await?;
|
||||||
|
let old_client = env.create_s3_client();
|
||||||
|
|
||||||
|
assert_versioning_enabled(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
|
||||||
|
assert_default_sse_s3_encryption(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
|
||||||
|
assert_bucket_tag(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
|
||||||
|
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after the rollback").await?;
|
||||||
|
|
||||||
|
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, single_key, Some(&single_version)).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(body, single_bytes);
|
||||||
|
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, multipart_key, None).await?;
|
||||||
|
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(body, multipart_bytes, "the multipart object did not survive the rollback");
|
||||||
|
|
||||||
|
// A PUT on the rolled-back release must still honour the encryption
|
||||||
|
// configuration it decoded out of the current build's metadata blob.
|
||||||
|
let post_rollback_key = "rollback/written-after-rollback";
|
||||||
|
let post_rollback_bytes = b"object written by the previous RustFS release after the rollback";
|
||||||
|
old_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(ROLLBACK_BUCKET)
|
||||||
|
.key(post_rollback_key)
|
||||||
|
.body(ByteStream::from_static(post_rollback_bytes))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, post_rollback_key, None).await?;
|
||||||
|
assert_eq!(
|
||||||
|
encryption,
|
||||||
|
Some(ServerSideEncryption::Aes256),
|
||||||
|
"the rolled-back release lost the bucket default encryption"
|
||||||
|
);
|
||||||
|
assert_eq!(body, post_rollback_bytes);
|
||||||
|
|
||||||
|
replication_target.shutdown().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"] }
|
||||||
@@ -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, UnreadableTargetsPolicy, append_version_id_query,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +69,13 @@ 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 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,
|
||||||
@@ -89,8 +96,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 +154,23 @@ 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_under_transaction_lock, get,
|
||||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
get_accelerate_config, get_bucket_policy, get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk,
|
||||||
get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config, get_public_access_block_config,
|
get_cors_config, get_durability_config, get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config,
|
||||||
get_quota_config, get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config,
|
get_notification_config, get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
|
||||||
get_versioning_config, get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata,
|
get_on_demand_migration_config_in, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||||
remove_bucket_metadata, set_bucket_metadata, update, update_bucket_targets_under_transaction_lock,
|
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||||
update_config_with, update_if_incarnation, update_quota_if_incarnation, update_under_transaction_lock,
|
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
||||||
|
update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||||
|
update_quota_if_incarnation, update_under_transaction_lock,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub use crate::bucket::metadata_sys::{ConfigWriteLockProbe, test_support};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod migration {
|
pub mod migration {
|
||||||
@@ -248,7 +213,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,9 +444,11 @@ 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, LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr,
|
||||||
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
NotificationSys, ScannerPublicationLeaseGrant, acquire_cross_pool_fence_fleet_proof,
|
||||||
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
acquire_legacy_transition_state_reconcile_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_matches, new_global_notification_sys,
|
||||||
|
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,9 +459,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,
|
||||||
@@ -558,6 +525,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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,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 _;
|
||||||
@@ -369,6 +369,26 @@ struct SsecPassthroughRecord {
|
|||||||
recorded_at: Instant,
|
recorded_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a target write does when the bucket's persisted target set exists but
|
||||||
|
/// cannot be decoded.
|
||||||
|
///
|
||||||
|
/// `docs/architecture/remote-credential-sealing-adr.md` forbids rewriting a
|
||||||
|
/// configuration that could not be fully read, because re-serializing a
|
||||||
|
/// partial in-memory view is the one mechanism by which a configured target
|
||||||
|
/// really disappears. That rule guards against an *unintentional* overwrite,
|
||||||
|
/// so an operator who names the hazard keeps a repair path
|
||||||
|
/// (rustfs/backlog#2309); everything that does not name it stays refused.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum UnreadableTargetsPolicy {
|
||||||
|
/// Refuse the write with [`BucketTargetError::BucketRemoteTargetsUnreadable`].
|
||||||
|
#[default]
|
||||||
|
FailClosed,
|
||||||
|
/// Discard the unreadable set; the target being written becomes the whole
|
||||||
|
/// configuration. Reachable only from an admin request that asked for it
|
||||||
|
/// explicitly, and audited by the caller.
|
||||||
|
Replace,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct BucketTargetSys {
|
pub struct BucketTargetSys {
|
||||||
pub arn_remotes_map: Arc<RwLock<HashMap<String, ArnTarget>>>,
|
pub arn_remotes_map: Arc<RwLock<HashMap<String, ArnTarget>>>,
|
||||||
@@ -376,6 +396,11 @@ pub struct BucketTargetSys {
|
|||||||
/// [`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>>>,
|
||||||
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>,
|
||||||
@@ -419,6 +444,7 @@ impl BucketTargetSys {
|
|||||||
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())),
|
||||||
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 +654,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 +684,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 +710,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 +732,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;
|
||||||
@@ -752,20 +811,45 @@ impl BucketTargetSys {
|
|||||||
bucket: &str,
|
bucket: &str,
|
||||||
target: &BucketTarget,
|
target: &BucketTarget,
|
||||||
update: bool,
|
update: bool,
|
||||||
|
unreadable_policy: UnreadableTargetsPolicy,
|
||||||
) -> 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, unreadable_policy).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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The persisted target set a write merges into.
|
||||||
|
///
|
||||||
|
/// An absent configuration starts from the empty set. An unreadable one is
|
||||||
|
/// refused, because re-serializing a partial view of a set this node could
|
||||||
|
/// not decode is how a configured target disappears for good — unless the
|
||||||
|
/// caller carries the operator's explicit
|
||||||
|
/// [`UnreadableTargetsPolicy::Replace`] opt-in, which discards it
|
||||||
|
/// deliberately (rustfs/backlog#2309).
|
||||||
|
async fn targets_base_for_write(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
unreadable_policy: UnreadableTargetsPolicy,
|
||||||
|
) -> Result<BucketTargets, BucketTargetError> {
|
||||||
|
match self.list_bucket_targets(bucket).await {
|
||||||
|
Ok(targets) => Ok(targets),
|
||||||
|
Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => Ok(BucketTargets::default()),
|
||||||
|
// The opt-in discards only a set this node genuinely cannot read.
|
||||||
|
// A readable set still merges through the arm above, so the policy
|
||||||
|
// can never drop a target that was visible here.
|
||||||
|
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. })
|
||||||
|
if unreadable_policy == UnreadableTargetsPolicy::Replace =>
|
||||||
|
{
|
||||||
|
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 {
|
||||||
@@ -1093,6 +1177,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,9 +1189,9 @@ 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;
|
||||||
@@ -1161,6 +1250,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;
|
||||||
};
|
};
|
||||||
@@ -2276,6 +2370,13 @@ 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 +2410,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}")
|
||||||
}
|
}
|
||||||
@@ -3256,7 +3360,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 +4358,75 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rustfs/backlog#2309: after rustfs/rustfs#7172 an undecodable
|
||||||
|
/// `bucket-targets.json` left the bucket with no API repair path at all.
|
||||||
|
/// The refusal is the default and stays the default; the operator's
|
||||||
|
/// explicit opt-in is the only thing that discards the set, and it starts
|
||||||
|
/// the replacement from empty rather than from a partial view of bytes
|
||||||
|
/// this node never decoded.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unreadable_target_set_is_replaced_only_with_the_explicit_opt_in() {
|
||||||
|
let sys = BucketTargetSys::default();
|
||||||
|
let bucket = "targets-repair-opt-in";
|
||||||
|
sys.mark_targets_unreadable(bucket).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
sys.targets_base_for_write(bucket, UnreadableTargetsPolicy::FailClosed).await,
|
||||||
|
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. })
|
||||||
|
),
|
||||||
|
"without the opt-in an unreadable target set must still refuse the write"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
UnreadableTargetsPolicy::default(),
|
||||||
|
UnreadableTargetsPolicy::FailClosed,
|
||||||
|
"a caller that says nothing must get the refusal"
|
||||||
|
);
|
||||||
|
|
||||||
|
let base = sys
|
||||||
|
.targets_base_for_write(bucket, UnreadableTargetsPolicy::Replace)
|
||||||
|
.await
|
||||||
|
.expect("the explicit opt-in must let an operator replace an unreadable set");
|
||||||
|
assert!(
|
||||||
|
base.is_empty(),
|
||||||
|
"the replacement must start from an empty set, never from a partial decode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opt-in is not a wipe switch. On a set this node can read, both
|
||||||
|
/// policies take the same merge path, so a stray `replace-unreadable=true`
|
||||||
|
/// cannot drop a visible target — which is what makes the flag safe to
|
||||||
|
/// repeat in an operator's repair script.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_opt_in_never_discards_a_readable_target_set() {
|
||||||
|
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()]);
|
||||||
|
|
||||||
|
for policy in [UnreadableTargetsPolicy::FailClosed, UnreadableTargetsPolicy::Replace] {
|
||||||
|
let base = sys
|
||||||
|
.targets_base_for_write(bucket, policy)
|
||||||
|
.await
|
||||||
|
.expect("a readable target set must be readable under either policy");
|
||||||
|
assert_eq!(base.targets.len(), 1, "{policy:?} must keep the persisted target");
|
||||||
|
assert_eq!(base.targets[0].arn, existing.arn, "{policy:?} must not rewrite the persisted target");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ use super::{
|
|||||||
bucket_lifecycle_ops::{
|
bucket_lifecycle_ops::{
|
||||||
ManualTransitionQueueSnapshot, ManualTransitionRunReport, decode_manual_transition_continuation_token,
|
ManualTransitionQueueSnapshot, ManualTransitionRunReport, decode_manual_transition_continuation_token,
|
||||||
},
|
},
|
||||||
manual_transition_job, tier_delete_journal, transition_transaction,
|
manual_transition_job, recovery_control, tier_delete_journal, transition_transaction,
|
||||||
};
|
};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::services::tier::tier_probe_intent;
|
||||||
|
|
||||||
pub(crate) const ILM_META_PREFIX: &str = "ilm";
|
pub(crate) const ILM_META_PREFIX: &str = "ilm";
|
||||||
const ILM_META_OBJECT_PREFIX: &str = "ilm/";
|
const ILM_META_OBJECT_PREFIX: &str = "ilm/";
|
||||||
@@ -35,10 +36,12 @@ pub(crate) enum DurableIlmRecordKind {
|
|||||||
TierDeleteJournal,
|
TierDeleteJournal,
|
||||||
TierDeleteDispatchManifest,
|
TierDeleteDispatchManifest,
|
||||||
TransitionTransaction,
|
TransitionTransaction,
|
||||||
|
TierProbeIntent,
|
||||||
ManualTransitionJob,
|
ManualTransitionJob,
|
||||||
ManualTransitionScope,
|
ManualTransitionScope,
|
||||||
ManualTransitionTask,
|
ManualTransitionTask,
|
||||||
ManualTransitionWorkerResult,
|
ManualTransitionWorkerResult,
|
||||||
|
RecoveryControl,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -73,6 +76,12 @@ pub(crate) const TRANSITION_TRANSACTION_NAMESPACE: DurableIlmNamespace = Durable
|
|||||||
max_record_size: transition_transaction::MAX_TRANSITION_TRANSACTION_SIZE,
|
max_record_size: transition_transaction::MAX_TRANSITION_TRANSACTION_SIZE,
|
||||||
kind: DurableIlmRecordKind::TransitionTransaction,
|
kind: DurableIlmRecordKind::TransitionTransaction,
|
||||||
};
|
};
|
||||||
|
pub(crate) const TIER_PROBE_INTENT_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
|
||||||
|
name: "tier-probe-intent",
|
||||||
|
prefix: tier_probe_intent::TIER_PROBE_INTENT_RECORD_PREFIX,
|
||||||
|
max_record_size: tier_probe_intent::MAX_TIER_PROBE_INTENT_SIZE,
|
||||||
|
kind: DurableIlmRecordKind::TierProbeIntent,
|
||||||
|
};
|
||||||
pub(crate) const MANUAL_TRANSITION_JOB_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
|
pub(crate) const MANUAL_TRANSITION_JOB_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
|
||||||
name: "manual-transition-job",
|
name: "manual-transition-job",
|
||||||
prefix: "ilm/manual-transition/jobs",
|
prefix: "ilm/manual-transition/jobs",
|
||||||
@@ -97,16 +106,24 @@ pub(crate) const MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE: DurableIlmNamespace
|
|||||||
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE,
|
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE,
|
||||||
kind: DurableIlmRecordKind::ManualTransitionWorkerResult,
|
kind: DurableIlmRecordKind::ManualTransitionWorkerResult,
|
||||||
};
|
};
|
||||||
|
pub(crate) const RECOVERY_CONTROL_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
|
||||||
|
name: "recovery-control",
|
||||||
|
prefix: recovery_control::ILM_RECOVERY_CONTROL_PREFIX,
|
||||||
|
max_record_size: recovery_control::MAX_ILM_RECOVERY_CONTROL_SIZE,
|
||||||
|
kind: DurableIlmRecordKind::RecoveryControl,
|
||||||
|
};
|
||||||
|
|
||||||
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 8] = [
|
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 10] = [
|
||||||
TIER_DELETE_JOURNAL_NAMESPACE,
|
TIER_DELETE_JOURNAL_NAMESPACE,
|
||||||
TIER_DELETE_JOURNAL_V6_NAMESPACE,
|
TIER_DELETE_JOURNAL_V6_NAMESPACE,
|
||||||
TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE,
|
TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE,
|
||||||
TRANSITION_TRANSACTION_NAMESPACE,
|
TRANSITION_TRANSACTION_NAMESPACE,
|
||||||
|
TIER_PROBE_INTENT_NAMESPACE,
|
||||||
MANUAL_TRANSITION_JOB_NAMESPACE,
|
MANUAL_TRANSITION_JOB_NAMESPACE,
|
||||||
MANUAL_TRANSITION_SCOPE_NAMESPACE,
|
MANUAL_TRANSITION_SCOPE_NAMESPACE,
|
||||||
MANUAL_TRANSITION_TASK_NAMESPACE,
|
MANUAL_TRANSITION_TASK_NAMESPACE,
|
||||||
MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE,
|
MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE,
|
||||||
|
RECOVERY_CONTROL_NAMESPACE,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -200,6 +217,15 @@ pub(crate) enum DurableIlmRecordCheckpoint {
|
|||||||
revision: u64,
|
revision: u64,
|
||||||
state: transition_transaction::TransitionTransactionState,
|
state: transition_transaction::TransitionTransactionState,
|
||||||
},
|
},
|
||||||
|
TierProbeIntent {
|
||||||
|
content_sha256: String,
|
||||||
|
identity_sha256: String,
|
||||||
|
remote_version_sha256: String,
|
||||||
|
remote_version_known: bool,
|
||||||
|
owner_fence_sha256: String,
|
||||||
|
revision: u64,
|
||||||
|
state: tier_probe_intent::TierProbeIntentState,
|
||||||
|
},
|
||||||
ManualTransitionJob {
|
ManualTransitionJob {
|
||||||
content_sha256: String,
|
content_sha256: String,
|
||||||
identity_sha256: String,
|
identity_sha256: String,
|
||||||
@@ -223,6 +249,18 @@ pub(crate) enum DurableIlmRecordCheckpoint {
|
|||||||
ManualTransitionWorkerResult {
|
ManualTransitionWorkerResult {
|
||||||
content_sha256: String,
|
content_sha256: String,
|
||||||
},
|
},
|
||||||
|
RecoveryControl {
|
||||||
|
content_sha256: String,
|
||||||
|
identity_sha256: String,
|
||||||
|
source_generation_sha256: String,
|
||||||
|
first_seen_at_unix_nanos: i64,
|
||||||
|
revision: u64,
|
||||||
|
classification: recovery_control::IlmRecoveryClassification,
|
||||||
|
attempt_count: u64,
|
||||||
|
consecutive_failure_count: u32,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
owner_fence_sha256: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DurableIlmRecordCheckpoint {
|
impl DurableIlmRecordCheckpoint {
|
||||||
@@ -232,10 +270,12 @@ impl DurableIlmRecordCheckpoint {
|
|||||||
| Self::TierDeleteDispatchManifest { content_sha256, .. }
|
| Self::TierDeleteDispatchManifest { content_sha256, .. }
|
||||||
| Self::TierDeleteDispatchParent { content_sha256, .. }
|
| Self::TierDeleteDispatchParent { content_sha256, .. }
|
||||||
| Self::TransitionTransaction { content_sha256, .. }
|
| Self::TransitionTransaction { content_sha256, .. }
|
||||||
|
| Self::TierProbeIntent { content_sha256, .. }
|
||||||
| Self::ManualTransitionJob { content_sha256, .. }
|
| Self::ManualTransitionJob { content_sha256, .. }
|
||||||
| Self::ManualTransitionScope { content_sha256, .. }
|
| Self::ManualTransitionScope { content_sha256, .. }
|
||||||
| Self::ManualTransitionTask { content_sha256 }
|
| Self::ManualTransitionTask { content_sha256 }
|
||||||
| Self::ManualTransitionWorkerResult { content_sha256 } => content_sha256,
|
| Self::ManualTransitionWorkerResult { content_sha256 }
|
||||||
|
| Self::RecoveryControl { content_sha256, .. } => content_sha256,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +461,32 @@ impl DurableIlmRecordCheckpoint {
|
|||||||
.is_some_and(|expected_revision| *next_revision == expected_revision)
|
.is_some_and(|expected_revision| *next_revision == expected_revision)
|
||||||
&& (!previous_remote_version_known || previous_remote_version == next_remote_version)
|
&& (!previous_remote_version_known || previous_remote_version == next_remote_version)
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
Self::TierProbeIntent {
|
||||||
|
identity_sha256: previous_identity,
|
||||||
|
remote_version_sha256: previous_remote_version,
|
||||||
|
remote_version_known: previous_remote_version_known,
|
||||||
|
owner_fence_sha256: previous_owner_fence,
|
||||||
|
revision: previous_revision,
|
||||||
|
state: previous_state,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
Self::TierProbeIntent {
|
||||||
|
identity_sha256: next_identity,
|
||||||
|
remote_version_sha256: next_remote_version,
|
||||||
|
owner_fence_sha256: next_owner_fence,
|
||||||
|
revision: next_revision,
|
||||||
|
state: next_state,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
previous_identity == next_identity
|
||||||
|
&& previous_owner_fence == next_owner_fence
|
||||||
|
&& next_revision
|
||||||
|
.checked_sub(*previous_revision)
|
||||||
|
.is_some_and(|distance| distance == 1 && tier_probe_state_reaches(*previous_state, *next_state, distance))
|
||||||
|
&& (!previous_remote_version_known || previous_remote_version == next_remote_version)
|
||||||
|
}
|
||||||
(
|
(
|
||||||
Self::ManualTransitionJob {
|
Self::ManualTransitionJob {
|
||||||
content_sha256: previous_content,
|
content_sha256: previous_content,
|
||||||
@@ -483,6 +549,51 @@ impl DurableIlmRecordCheckpoint {
|
|||||||
..
|
..
|
||||||
},
|
},
|
||||||
) => previous_identity == next_identity && next_updated_at > previous_updated_at,
|
) => previous_identity == next_identity && next_updated_at > previous_updated_at,
|
||||||
|
(
|
||||||
|
Self::RecoveryControl {
|
||||||
|
identity_sha256: previous_identity,
|
||||||
|
source_generation_sha256: previous_generation,
|
||||||
|
first_seen_at_unix_nanos: previous_first_seen,
|
||||||
|
revision: previous_revision,
|
||||||
|
classification: previous_classification,
|
||||||
|
attempt_count: previous_attempts,
|
||||||
|
consecutive_failure_count: previous_failures,
|
||||||
|
owner_fence_sha256: previous_owner,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
Self::RecoveryControl {
|
||||||
|
identity_sha256: next_identity,
|
||||||
|
source_generation_sha256: next_generation,
|
||||||
|
first_seen_at_unix_nanos: next_first_seen,
|
||||||
|
revision: next_revision,
|
||||||
|
classification: next_classification,
|
||||||
|
attempt_count: next_attempts,
|
||||||
|
consecutive_failure_count: next_failures,
|
||||||
|
owner_fence_sha256: next_owner,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
let adjacent = previous_identity == next_identity
|
||||||
|
&& previous_first_seen == next_first_seen
|
||||||
|
&& previous_revision.checked_add(1) == Some(*next_revision);
|
||||||
|
let claim = next_owner.is_some()
|
||||||
|
&& *previous_classification == recovery_control::IlmRecoveryClassification::Retrying
|
||||||
|
&& *next_classification == recovery_control::IlmRecoveryClassification::Retrying
|
||||||
|
&& previous_attempts.checked_add(1) == Some(*next_attempts)
|
||||||
|
&& previous_failures == next_failures;
|
||||||
|
let source_refresh = previous_owner.is_some()
|
||||||
|
&& previous_owner == next_owner
|
||||||
|
&& *previous_classification == recovery_control::IlmRecoveryClassification::Retrying
|
||||||
|
&& *next_classification == recovery_control::IlmRecoveryClassification::Retrying
|
||||||
|
&& previous_attempts == next_attempts
|
||||||
|
&& previous_failures == next_failures
|
||||||
|
&& previous_generation != next_generation;
|
||||||
|
let completion = previous_owner.is_some()
|
||||||
|
&& next_owner.is_none()
|
||||||
|
&& previous_generation == next_generation
|
||||||
|
&& previous_attempts == next_attempts;
|
||||||
|
adjacent && (claim || source_refresh || completion)
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -500,6 +611,22 @@ impl DurableIlmRecordCheckpoint {
|
|||||||
/// after the exact terminal ETag and terminal receipt were committed, to
|
/// after the exact terminal ETag and terminal receipt were committed, to
|
||||||
/// purge older object versions exposed by that deletion.
|
/// purge older object versions exposed by that deletion.
|
||||||
pub(crate) fn is_predecessor_of_terminal(&self, terminal: &Self) -> bool {
|
pub(crate) fn is_predecessor_of_terminal(&self, terminal: &Self) -> bool {
|
||||||
|
if let Self::TierProbeIntent { state, .. } = terminal
|
||||||
|
&& !matches!(
|
||||||
|
state,
|
||||||
|
tier_probe_intent::TierProbeIntentState::AbortedNoRemote | tier_probe_intent::TierProbeIntentState::Completed
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Self::RecoveryControl { classification, .. } = terminal
|
||||||
|
&& !matches!(
|
||||||
|
classification,
|
||||||
|
recovery_control::IlmRecoveryClassification::Terminal | recovery_control::IlmRecoveryClassification::Abandoned
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if self == terminal || self.validate_successor(terminal).is_ok() {
|
if self == terminal || self.validate_successor(terminal).is_ok() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -568,6 +695,63 @@ impl DurableIlmRecordCheckpoint {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
Self::TierProbeIntent {
|
||||||
|
identity_sha256: previous_identity,
|
||||||
|
remote_version_sha256: previous_remote_version,
|
||||||
|
remote_version_known: previous_remote_version_known,
|
||||||
|
owner_fence_sha256: previous_owner_fence,
|
||||||
|
revision: previous_revision,
|
||||||
|
state: previous_state,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
Self::TierProbeIntent {
|
||||||
|
identity_sha256: terminal_identity,
|
||||||
|
remote_version_sha256: terminal_remote_version,
|
||||||
|
owner_fence_sha256: terminal_owner_fence,
|
||||||
|
revision: terminal_revision,
|
||||||
|
state: terminal_state,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
previous_identity == terminal_identity
|
||||||
|
&& previous_owner_fence == terminal_owner_fence
|
||||||
|
&& matches!(
|
||||||
|
terminal_state,
|
||||||
|
tier_probe_intent::TierProbeIntentState::AbortedNoRemote
|
||||||
|
| tier_probe_intent::TierProbeIntentState::Completed
|
||||||
|
)
|
||||||
|
&& terminal_revision
|
||||||
|
.checked_sub(*previous_revision)
|
||||||
|
.is_some_and(|distance| tier_probe_state_reaches(*previous_state, *terminal_state, distance))
|
||||||
|
&& (!previous_remote_version_known || previous_remote_version == terminal_remote_version)
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Self::RecoveryControl {
|
||||||
|
identity_sha256: previous_identity,
|
||||||
|
source_generation_sha256: previous_generation,
|
||||||
|
first_seen_at_unix_nanos: previous_first_seen,
|
||||||
|
revision: previous_revision,
|
||||||
|
attempt_count: previous_attempts,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
Self::RecoveryControl {
|
||||||
|
identity_sha256: terminal_identity,
|
||||||
|
source_generation_sha256: terminal_generation,
|
||||||
|
first_seen_at_unix_nanos: terminal_first_seen,
|
||||||
|
revision: terminal_revision,
|
||||||
|
attempt_count: terminal_attempts,
|
||||||
|
classification:
|
||||||
|
recovery_control::IlmRecoveryClassification::Terminal | recovery_control::IlmRecoveryClassification::Abandoned,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
previous_identity == terminal_identity
|
||||||
|
&& (previous_generation == terminal_generation || terminal_attempts > previous_attempts)
|
||||||
|
&& previous_first_seen == terminal_first_seen
|
||||||
|
&& terminal_revision > previous_revision
|
||||||
|
&& terminal_attempts >= previous_attempts
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,6 +790,23 @@ fn transition_state_distance(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tier_probe_state_reaches(
|
||||||
|
from: tier_probe_intent::TierProbeIntentState,
|
||||||
|
to: tier_probe_intent::TierProbeIntentState,
|
||||||
|
revision_distance: u64,
|
||||||
|
) -> bool {
|
||||||
|
use tier_probe_intent::TierProbeIntentState::{AbortedNoRemote, CleanupPending, Completed, UploadOutcomeUnknown, Uploaded};
|
||||||
|
|
||||||
|
match (from, to) {
|
||||||
|
(UploadOutcomeUnknown, Uploaded | CleanupPending | AbortedNoRemote) => revision_distance == 1,
|
||||||
|
(UploadOutcomeUnknown, Completed) => matches!(revision_distance, 2 | 3),
|
||||||
|
(Uploaded, CleanupPending) => revision_distance == 1,
|
||||||
|
(Uploaded, Completed) => revision_distance == 2,
|
||||||
|
(CleanupPending, Completed) => revision_distance == 1,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn manual_job_state_reaches(
|
fn manual_job_state_reaches(
|
||||||
from: manual_transition_job::ManualTransitionJobState,
|
from: manual_transition_job::ManualTransitionJobState,
|
||||||
to: manual_transition_job::ManualTransitionJobState,
|
to: manual_transition_job::ManualTransitionJobState,
|
||||||
@@ -1082,6 +1283,71 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DurableIlmRecordKind::TierProbeIntent => {
|
||||||
|
let probe_id = tier_probe_intent::tier_probe_intent_id_from_record_object_name(path)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
let intent =
|
||||||
|
tier_probe_intent::TierProbeIntent::decode(probe_id, data).map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
let canonical =
|
||||||
|
tier_probe_intent::tier_probe_intent_record_object_name(probe_id).map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
if canonical != path {
|
||||||
|
return Err(Error::other("tier probe intent path is not canonical"));
|
||||||
|
}
|
||||||
|
let identity_sha256 = checkpoint_hash(&(
|
||||||
|
intent.probe_id,
|
||||||
|
&intent.operation,
|
||||||
|
&intent.tier_name,
|
||||||
|
intent.destination_id,
|
||||||
|
&intent.probe_object,
|
||||||
|
&intent.creator_id,
|
||||||
|
intent.creator_epoch,
|
||||||
|
intent.created_at_unix_nanos,
|
||||||
|
))?;
|
||||||
|
let remote_version_sha256 = checkpoint_hash(&intent.remote_version)?;
|
||||||
|
let owner_fence_sha256 = checkpoint_hash(&intent.owner)?;
|
||||||
|
(
|
||||||
|
"probe_id",
|
||||||
|
probe_id.to_string(),
|
||||||
|
DurableIlmRecordCheckpoint::TierProbeIntent {
|
||||||
|
content_sha256,
|
||||||
|
identity_sha256,
|
||||||
|
remote_version_sha256,
|
||||||
|
remote_version_known: !intent.remote_version.is_unknown(),
|
||||||
|
owner_fence_sha256,
|
||||||
|
revision: intent.revision,
|
||||||
|
state: intent.state,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DurableIlmRecordKind::RecoveryControl => {
|
||||||
|
let (protocol, control_id) = recovery_control::recovery_control_id_from_record_object_name(path)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
let control =
|
||||||
|
recovery_control::IlmRecoveryControl::decode(&control_id, data).map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
let canonical = recovery_control::recovery_control_record_object_name(protocol, &control_id)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
if canonical != path || control.identity.protocol != protocol {
|
||||||
|
return Err(Error::other("ILM recovery control path is not canonical"));
|
||||||
|
}
|
||||||
|
let identity_sha256 = checkpoint_hash(&control.identity)?;
|
||||||
|
let source_generation_sha256 = checkpoint_hash(&control.observed_source_generation)?;
|
||||||
|
let owner_fence_sha256 = control.owner.as_ref().map(checkpoint_hash).transpose()?;
|
||||||
|
(
|
||||||
|
"control_id",
|
||||||
|
control_id,
|
||||||
|
DurableIlmRecordCheckpoint::RecoveryControl {
|
||||||
|
content_sha256,
|
||||||
|
identity_sha256,
|
||||||
|
source_generation_sha256,
|
||||||
|
first_seen_at_unix_nanos: control.first_seen_at_unix_nanos,
|
||||||
|
revision: control.revision,
|
||||||
|
classification: control.classification,
|
||||||
|
attempt_count: control.attempt_count,
|
||||||
|
consecutive_failure_count: control.consecutive_failure_count,
|
||||||
|
owner_fence_sha256,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
DurableIlmRecordKind::ManualTransitionJob => {
|
DurableIlmRecordKind::ManualTransitionJob => {
|
||||||
let job_id = manual_transition_job::manual_transition_job_id_from_record_object_name(path)
|
let job_id = manual_transition_job::manual_transition_job_id_from_record_object_name(path)
|
||||||
.map_err(|err| Error::other(err.to_string()))?;
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
@@ -1237,6 +1503,190 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tier_probe_intent_fixture() -> tier_probe_intent::TierProbeIntent {
|
||||||
|
let probe_id = Uuid::parse_str("36e2220e-9ad2-495b-b3bc-c4d2caf70a31").expect("fixture uuid should parse");
|
||||||
|
tier_probe_intent::TierProbeIntent {
|
||||||
|
probe_id,
|
||||||
|
revision: 1,
|
||||||
|
state: tier_probe_intent::TierProbeIntentState::UploadOutcomeUnknown,
|
||||||
|
operation: tier_probe_intent::TierProbeOperationIdentity::Verify {
|
||||||
|
config_etag: "config-etag".to_string(),
|
||||||
|
backend_identity: [1; 32],
|
||||||
|
},
|
||||||
|
tier_name: "COLD-A".to_string(),
|
||||||
|
destination_id: [1; 32],
|
||||||
|
probe_object: tier_probe_intent::tier_probe_object_name(probe_id),
|
||||||
|
creator_id: "node-a".to_string(),
|
||||||
|
creator_epoch: Uuid::parse_str("76746062-c05a-40b7-9e38-d2722d7e0332").expect("fixture creator epoch should parse"),
|
||||||
|
created_at_unix_nanos: 1_780_000_000_000_000_000,
|
||||||
|
owner: tier_probe_intent::TierProbeOwnerFence {
|
||||||
|
owner_id: "node-a".to_string(),
|
||||||
|
owner_epoch: Uuid::parse_str("76746062-c05a-40b7-9e38-d2722d7e0332").expect("fixture owner epoch should parse"),
|
||||||
|
not_after_unix_nanos: 1_780_000_900_000_000_000,
|
||||||
|
},
|
||||||
|
remote_version: tier_probe_intent::TierProbeRemoteVersion::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tier_probe_checkpoint(intent: &tier_probe_intent::TierProbeIntent) -> DurableIlmRecordCheckpoint {
|
||||||
|
let path =
|
||||||
|
tier_probe_intent::tier_probe_intent_record_object_name(intent.probe_id).expect("tier probe path should build");
|
||||||
|
let encoded = intent.encode().expect("tier probe intent should encode");
|
||||||
|
let namespace = classify_durable_ilm_record(&path)
|
||||||
|
.expect("tier probe namespace should classify")
|
||||||
|
.expect("tier probe intent should be durable");
|
||||||
|
assert_eq!(namespace, &TIER_PROBE_INTENT_NAMESPACE);
|
||||||
|
validate_durable_ilm_record(&path, &encoded)
|
||||||
|
.expect("tier probe intent should validate")
|
||||||
|
.checkpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recovery_control_fixture() -> recovery_control::IlmRecoveryControl {
|
||||||
|
let source_path = "ilm/transition-transactions/records/12/34/1234567890abcdef1234567890abcdef.json";
|
||||||
|
let generation = recovery_control::IlmRecoverySourceGeneration::new(
|
||||||
|
transition_transaction::TRANSITION_TRANSACTION_SCHEMA,
|
||||||
|
"source-etag",
|
||||||
|
"a".repeat(64),
|
||||||
|
vec![recovery_control::IlmRecoverySourceCopy {
|
||||||
|
authority: "pool-0/set-0".to_string(),
|
||||||
|
canonical_path: source_path.to_string(),
|
||||||
|
etag: "source-etag".to_string(),
|
||||||
|
encoded_len: 128,
|
||||||
|
content_sha256: "a".repeat(64),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("source generation should build");
|
||||||
|
recovery_control::IlmRecoveryControl::new(
|
||||||
|
recovery_control::IlmRecoveryControlIdentity {
|
||||||
|
protocol: recovery_control::IlmRecoveryProtocol::TransitionTransaction,
|
||||||
|
canonical_source_path: source_path.to_string(),
|
||||||
|
stable_operation_identity: "12345678-90ab-cdef-1234-567890abcdef".to_string(),
|
||||||
|
record_class: "transition_transaction_v1".to_string(),
|
||||||
|
},
|
||||||
|
generation,
|
||||||
|
recovery_control::IlmRecoveryClassification::Retrying,
|
||||||
|
1_000_000_000,
|
||||||
|
recovery_control::IlmRecoveryErrorCode::None,
|
||||||
|
)
|
||||||
|
.expect("recovery control should build")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recovery_control_checkpoint(control: &recovery_control::IlmRecoveryControl) -> DurableIlmRecordCheckpoint {
|
||||||
|
let control_id = control.identity.source_operation_digest().expect("control id should derive");
|
||||||
|
let path = recovery_control::recovery_control_record_object_name(control.identity.protocol, &control_id)
|
||||||
|
.expect("control path should build");
|
||||||
|
let encoded = control.encode().expect("control should encode");
|
||||||
|
let namespace = classify_durable_ilm_record(&path)
|
||||||
|
.expect("recovery control namespace should classify")
|
||||||
|
.expect("recovery control should be durable");
|
||||||
|
assert_eq!(namespace, &RECOVERY_CONTROL_NAMESPACE);
|
||||||
|
validate_durable_ilm_record(&path, &encoded)
|
||||||
|
.expect("recovery control should validate")
|
||||||
|
.checkpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_control_checkpoint_tracks_claim_retry_and_terminal_generations() {
|
||||||
|
let initial_control = recovery_control_fixture();
|
||||||
|
let initial = recovery_control_checkpoint(&initial_control);
|
||||||
|
|
||||||
|
let mut claimed_control = initial_control;
|
||||||
|
let mut advanced_generation = claimed_control.observed_source_generation.clone();
|
||||||
|
advanced_generation.source_schema = "rustfs-transition-transaction-v2".to_string();
|
||||||
|
claimed_control
|
||||||
|
.claim_for_source_generation("node-a", Uuid::new_v4(), 2_000_000_000, 300_000_000_000, advanced_generation)
|
||||||
|
.expect("control should claim");
|
||||||
|
let claimed = recovery_control_checkpoint(&claimed_control);
|
||||||
|
initial.validate_successor(&claimed).expect("claim should advance receipt");
|
||||||
|
|
||||||
|
let mut retry_control = claimed_control;
|
||||||
|
retry_control
|
||||||
|
.record_retryable_failure(3_000_000_000, recovery_control::IlmRecoveryErrorCode::BackendTimeout)
|
||||||
|
.expect("retry should persist");
|
||||||
|
let retry = recovery_control_checkpoint(&retry_control);
|
||||||
|
claimed.validate_successor(&retry).expect("retry should advance receipt");
|
||||||
|
|
||||||
|
let ready_at = retry_control
|
||||||
|
.next_attempt_at_unix_nanos
|
||||||
|
.expect("retry deadline should persist");
|
||||||
|
let mut terminal_control = retry_control;
|
||||||
|
terminal_control
|
||||||
|
.claim("node-b", Uuid::new_v4(), ready_at, 300_000_000_000)
|
||||||
|
.expect("retry should claim");
|
||||||
|
let reclaimed = recovery_control_checkpoint(&terminal_control);
|
||||||
|
retry.validate_successor(&reclaimed).expect("reclaim should advance receipt");
|
||||||
|
terminal_control
|
||||||
|
.finish_attempt(
|
||||||
|
recovery_control::IlmRecoveryClassification::Terminal,
|
||||||
|
recovery_control::IlmRecoveryErrorCode::None,
|
||||||
|
)
|
||||||
|
.expect("control should terminate");
|
||||||
|
let terminal = recovery_control_checkpoint(&terminal_control);
|
||||||
|
reclaimed
|
||||||
|
.validate_successor(&terminal)
|
||||||
|
.expect("terminal state should advance receipt");
|
||||||
|
assert!(initial.is_predecessor_of_terminal(&terminal));
|
||||||
|
assert!(!initial.is_predecessor_of_terminal(&retry));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_probe_intent_checkpoint_tracks_exact_monotonic_generations() {
|
||||||
|
let initial_intent = tier_probe_intent_fixture();
|
||||||
|
let initial = tier_probe_checkpoint(&initial_intent);
|
||||||
|
|
||||||
|
let mut uploaded_intent = initial_intent;
|
||||||
|
uploaded_intent
|
||||||
|
.advance(
|
||||||
|
tier_probe_intent::TierProbeIntentState::Uploaded,
|
||||||
|
tier_probe_intent::TierProbeRemoteVersion::versioned("opaque-v1"),
|
||||||
|
)
|
||||||
|
.expect("uploaded state should advance");
|
||||||
|
let uploaded = tier_probe_checkpoint(&uploaded_intent);
|
||||||
|
initial
|
||||||
|
.validate_successor(&uploaded)
|
||||||
|
.expect("durable receipt may adopt the exact uploaded generation");
|
||||||
|
|
||||||
|
let mut cleanup_intent = uploaded_intent.clone();
|
||||||
|
cleanup_intent
|
||||||
|
.advance(
|
||||||
|
tier_probe_intent::TierProbeIntentState::CleanupPending,
|
||||||
|
uploaded_intent.remote_version.clone(),
|
||||||
|
)
|
||||||
|
.expect("cleanup state should advance");
|
||||||
|
let cleanup = tier_probe_checkpoint(&cleanup_intent);
|
||||||
|
uploaded
|
||||||
|
.validate_successor(&cleanup)
|
||||||
|
.expect("durable receipt may adopt the exact cleanup generation");
|
||||||
|
|
||||||
|
let mut completed_intent = cleanup_intent.clone();
|
||||||
|
completed_intent
|
||||||
|
.advance(tier_probe_intent::TierProbeIntentState::Completed, cleanup_intent.remote_version.clone())
|
||||||
|
.expect("completed state should advance");
|
||||||
|
let completed = tier_probe_checkpoint(&completed_intent);
|
||||||
|
cleanup
|
||||||
|
.validate_successor(&completed)
|
||||||
|
.expect("durable receipt may adopt the exact terminal generation");
|
||||||
|
assert!(
|
||||||
|
initial.is_predecessor_of_terminal(&completed),
|
||||||
|
"terminal cleanup must recognize the full acknowledged-PUT path"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
initial.validate_successor(&completed).is_err(),
|
||||||
|
"ordinary receipt advancement must not skip intermediate generations"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!initial.is_predecessor_of_terminal(&uploaded),
|
||||||
|
"a nonterminal generation must not be accepted as terminal proof"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut rebound = uploaded_intent;
|
||||||
|
rebound.owner.owner_epoch = Uuid::new_v4();
|
||||||
|
assert!(
|
||||||
|
rebound.encode().is_err(),
|
||||||
|
"dormant v1 must reject owner takeover before producing a checkpoint"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tier_delete_dispatch_manifest_namespace_validates_monotonic_branches() {
|
fn tier_delete_dispatch_manifest_namespace_validates_monotonic_branches() {
|
||||||
use tier_delete_journal::TierDeleteDispatchManifestState::{Aborted, Aborting, Completed, DispatchAuthorized, Preparing};
|
use tier_delete_journal::TierDeleteDispatchManifestState::{Aborted, Aborting, Completed, DispatchAuthorized, Preparing};
|
||||||
|
|||||||
@@ -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,7 @@ 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;
|
||||||
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
@@ -1733,6 +1733,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 +1833,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 +1962,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 +3783,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 +3873,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 +3898,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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ use uuid::Uuid;
|
|||||||
use crate::bucket::lifecycle::config_boundary;
|
use crate::bucket::lifecycle::config_boundary;
|
||||||
use crate::bucket::lifecycle::durable_namespace::TRANSITION_TRANSACTION_NAMESPACE;
|
use crate::bucket::lifecycle::durable_namespace::TRANSITION_TRANSACTION_NAMESPACE;
|
||||||
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
||||||
|
use crate::bucket::lifecycle::recovery_control::{
|
||||||
|
IlmRecoveryClassification, IlmRecoveryControl, IlmRecoveryControlIdentity, IlmRecoveryErrorCode, IlmRecoveryProtocol,
|
||||||
|
ObservedIlmRecoveryControl, load_recovery_control, observe_recovery_source, recovery_control_record_object_name,
|
||||||
|
save_recovery_control_if_absent, save_recovery_control_if_current,
|
||||||
|
};
|
||||||
use crate::bucket::lifecycle::tier_sweeper::{
|
use crate::bucket::lifecycle::tier_sweeper::{
|
||||||
delete_confirmed_transition_candidate_exact_with_lease_idempotent,
|
delete_confirmed_transition_candidate_exact_with_lease_idempotent,
|
||||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||||
@@ -44,6 +49,7 @@ const EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY: &str = "lifecycle_transit
|
|||||||
pub const DEFAULT_TRANSITION_TRANSACTION_RECOVERY_LIMIT: usize = 1_000;
|
pub const DEFAULT_TRANSITION_TRANSACTION_RECOVERY_LIMIT: usize = 1_000;
|
||||||
const TRANSITION_TRANSACTION_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
|
const TRANSITION_TRANSACTION_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
const TRANSITION_TRANSACTION_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
|
const TRANSITION_TRANSACTION_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
const TRANSITION_RECOVERY_CONTROL_LEASE_NANOS: i64 = 15 * 60 * 1_000_000_000;
|
||||||
pub const TRANSITION_TRANSACTION_SCHEMA: &str = "rustfs-transition-transaction-v1";
|
pub const TRANSITION_TRANSACTION_SCHEMA: &str = "rustfs-transition-transaction-v1";
|
||||||
pub const TRANSITION_TRANSACTION_PREFIX: &str = "ilm/transition-transactions";
|
pub const TRANSITION_TRANSACTION_PREFIX: &str = "ilm/transition-transactions";
|
||||||
pub const TRANSITION_TRANSACTION_RECORD_PREFIX: &str = TRANSITION_TRANSACTION_NAMESPACE.prefix;
|
pub const TRANSITION_TRANSACTION_RECORD_PREFIX: &str = TRANSITION_TRANSACTION_NAMESPACE.prefix;
|
||||||
@@ -612,6 +618,7 @@ pub(crate) async fn save_transition_transaction_record(
|
|||||||
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()
|
||||||
@@ -658,6 +665,7 @@ pub(crate) async fn save_transition_transaction_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(etag),
|
if_match: Some(etag),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -735,9 +743,11 @@ pub enum TransitionTransactionRecoveryOutcome {
|
|||||||
RemoteCandidateDeleted,
|
RemoteCandidateDeleted,
|
||||||
RecordDeleted,
|
RecordDeleted,
|
||||||
Retained,
|
Retained,
|
||||||
|
RetainedAmbiguous(IlmRecoveryErrorCode),
|
||||||
|
OperatorRequired(IlmRecoveryErrorCode),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct TransitionRecoveryClaimBarrierState {
|
struct TransitionRecoveryClaimBarrierState {
|
||||||
transaction_id: Uuid,
|
transaction_id: Uuid,
|
||||||
@@ -745,17 +755,17 @@ struct TransitionRecoveryClaimBarrierState {
|
|||||||
release: tokio::sync::Notify,
|
release: tokio::sync::Notify,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
pub(crate) struct TransitionRecoveryClaimBarrier {
|
pub(crate) struct TransitionRecoveryClaimBarrier {
|
||||||
state: Arc<TransitionRecoveryClaimBarrierState>,
|
state: Arc<TransitionRecoveryClaimBarrierState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
static TRANSITION_RECOVERY_CLAIM_BARRIER: std::sync::OnceLock<
|
static TRANSITION_RECOVERY_CLAIM_BARRIER: std::sync::OnceLock<
|
||||||
std::sync::Mutex<Option<Arc<TransitionRecoveryClaimBarrierState>>>,
|
std::sync::Mutex<Option<Arc<TransitionRecoveryClaimBarrierState>>>,
|
||||||
> = std::sync::OnceLock::new();
|
> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
impl TransitionRecoveryClaimBarrier {
|
impl TransitionRecoveryClaimBarrier {
|
||||||
pub(crate) fn install(transaction_id: Uuid) -> Self {
|
pub(crate) fn install(transaction_id: Uuid) -> Self {
|
||||||
let state = Arc::new(TransitionRecoveryClaimBarrierState {
|
let state = Arc::new(TransitionRecoveryClaimBarrierState {
|
||||||
@@ -786,7 +796,7 @@ impl TransitionRecoveryClaimBarrier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
impl Drop for TransitionRecoveryClaimBarrier {
|
impl Drop for TransitionRecoveryClaimBarrier {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.state.release.notify_one();
|
self.state.release.notify_one();
|
||||||
@@ -800,7 +810,7 @@ impl Drop for TransitionRecoveryClaimBarrier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
async fn pause_before_transition_recovery_claim(transaction_id: Uuid) {
|
async fn pause_before_transition_recovery_claim(transaction_id: Uuid) {
|
||||||
let barrier = TRANSITION_RECOVERY_CLAIM_BARRIER
|
let barrier = TRANSITION_RECOVERY_CLAIM_BARRIER
|
||||||
.get_or_init(|| std::sync::Mutex::new(None))
|
.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
@@ -815,6 +825,80 @@ async fn pause_before_transition_recovery_claim(transaction_id: Uuid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TransitionRecoveryTerminalBarrierState {
|
||||||
|
transaction_id: Uuid,
|
||||||
|
arrived: tokio::sync::Notify,
|
||||||
|
release: tokio::sync::Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
pub(crate) struct TransitionRecoveryTerminalBarrier {
|
||||||
|
state: Arc<TransitionRecoveryTerminalBarrierState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
static TRANSITION_RECOVERY_TERMINAL_BARRIER: std::sync::OnceLock<
|
||||||
|
std::sync::Mutex<Option<Arc<TransitionRecoveryTerminalBarrierState>>>,
|
||||||
|
> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
impl TransitionRecoveryTerminalBarrier {
|
||||||
|
pub(crate) fn install(transaction_id: Uuid) -> Self {
|
||||||
|
let state = Arc::new(TransitionRecoveryTerminalBarrierState {
|
||||||
|
transaction_id,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut slot = TRANSITION_RECOVERY_TERMINAL_BARRIER
|
||||||
|
.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
|
.lock()
|
||||||
|
.expect("transition recovery terminal barrier mutex should not poison");
|
||||||
|
assert!(
|
||||||
|
slot.is_none(),
|
||||||
|
"transition recovery terminal barrier must be installed by one test at a time"
|
||||||
|
);
|
||||||
|
*slot = Some(Arc::clone(&state));
|
||||||
|
drop(slot);
|
||||||
|
Self { state }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn wait_until_paused(&self) {
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||||
|
.await
|
||||||
|
.expect("transition recovery should persist terminal control before source cleanup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
impl Drop for TransitionRecoveryTerminalBarrier {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.state.release.notify_one();
|
||||||
|
let mut slot = TRANSITION_RECOVERY_TERMINAL_BARRIER
|
||||||
|
.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
|
.lock()
|
||||||
|
.expect("transition recovery terminal barrier mutex should not poison");
|
||||||
|
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||||
|
*slot = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
async fn pause_after_transition_recovery_terminal(transaction_id: Uuid) {
|
||||||
|
let barrier = TRANSITION_RECOVERY_TERMINAL_BARRIER
|
||||||
|
.get_or_init(|| std::sync::Mutex::new(None))
|
||||||
|
.lock()
|
||||||
|
.expect("transition recovery terminal barrier mutex should not poison")
|
||||||
|
.as_ref()
|
||||||
|
.filter(|barrier| barrier.transaction_id == transaction_id)
|
||||||
|
.cloned();
|
||||||
|
if let Some(barrier) = barrier {
|
||||||
|
barrier.arrived.notify_one();
|
||||||
|
barrier.release.notified().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TransitionOperatorProbe {
|
pub enum TransitionOperatorProbe {
|
||||||
@@ -1018,17 +1102,35 @@ fn transition_transaction_id_from_record_object_name(object: &str) -> Result<Uui
|
|||||||
let suffix = object
|
let suffix = object
|
||||||
.strip_prefix(&prefix)
|
.strip_prefix(&prefix)
|
||||||
.ok_or(TransitionTransactionError::Corrupt("transaction record path has wrong prefix"))?;
|
.ok_or(TransitionTransactionError::Corrupt("transaction record path has wrong prefix"))?;
|
||||||
let file_name = suffix
|
let mut parts = suffix.split('/');
|
||||||
.rsplit('/')
|
let shard_a = parts
|
||||||
.next()
|
.next()
|
||||||
.ok_or(TransitionTransactionError::Corrupt("transaction record path is incomplete"))?;
|
.ok_or(TransitionTransactionError::Corrupt("transaction record path is incomplete"))?;
|
||||||
|
let shard_b = parts
|
||||||
|
.next()
|
||||||
|
.ok_or(TransitionTransactionError::Corrupt("transaction record path is incomplete"))?;
|
||||||
|
let file_name = parts
|
||||||
|
.next()
|
||||||
|
.ok_or(TransitionTransactionError::Corrupt("transaction record path is incomplete"))?;
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Err(TransitionTransactionError::Corrupt("transaction record path is not canonical"));
|
||||||
|
}
|
||||||
let transaction_key = file_name
|
let transaction_key = file_name
|
||||||
.strip_suffix(".json")
|
.strip_suffix(".json")
|
||||||
.ok_or(TransitionTransactionError::Corrupt("transaction record path has wrong suffix"))?;
|
.ok_or(TransitionTransactionError::Corrupt("transaction record path has wrong suffix"))?;
|
||||||
if transaction_key.len() != 32 || !transaction_key.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
if transaction_key.len() != 32
|
||||||
|
|| !transaction_key
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||||
|
|| shard_a != &transaction_key[..2]
|
||||||
|
|| shard_b != &transaction_key[2..4]
|
||||||
|
{
|
||||||
return Err(TransitionTransactionError::Corrupt("transaction record path has invalid transaction id"));
|
return Err(TransitionTransactionError::Corrupt("transaction record path has invalid transaction id"));
|
||||||
}
|
}
|
||||||
Uuid::parse_str(transaction_key).map_err(|_| TransitionTransactionError::Corrupt("transaction record path has invalid uuid"))
|
Uuid::parse_str(transaction_key)
|
||||||
|
.ok()
|
||||||
|
.filter(|transaction_id| !transaction_id.is_nil())
|
||||||
|
.ok_or(TransitionTransactionError::Corrupt("transaction record path has invalid uuid"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn process_transition_transaction_record(
|
pub async fn process_transition_transaction_record(
|
||||||
@@ -1053,6 +1155,27 @@ async fn process_transition_transaction_record_at(
|
|||||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||||
let record_name =
|
let record_name =
|
||||||
transition_transaction_record_object_name(observed.transaction_id).map_err(transition_transaction_store_error)?;
|
transition_transaction_record_object_name(observed.transaction_id).map_err(transition_transaction_store_error)?;
|
||||||
|
let now_unix_nanos =
|
||||||
|
i64::try_from(now_unix_nanos).map_err(|_| Error::other("transition transaction recovery timestamp does not fit i64"))?;
|
||||||
|
let recovery_control_identity = transition_recovery_control_identity(observed, &record_name);
|
||||||
|
let recovery_control_id = recovery_control_identity
|
||||||
|
.source_operation_digest()
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
let control_record_name =
|
||||||
|
recovery_control_record_object_name(IlmRecoveryProtocol::TransitionTransaction, &recovery_control_id)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
let control_lock = if transition_state_needs_recovery_control(observed, now_unix_nanos) {
|
||||||
|
Some(
|
||||||
|
api.new_ns_lock(RUSTFS_META_BUCKET, &format!("{control_record_name}.recovery-lock"))
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let _control_guard = match &control_lock {
|
||||||
|
Some(lock) => Some(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
// The synthetic key avoids nesting the recovery lock with the config
|
// The synthetic key avoids nesting the recovery lock with the config
|
||||||
// object's own I/O lock. Holding it across the bounded source proof and
|
// object's own I/O lock. Holding it across the bounded source proof and
|
||||||
// remote DELETE elects one destructive recovery worker across nodes.
|
// remote DELETE elects one destructive recovery worker across nodes.
|
||||||
@@ -1071,11 +1194,40 @@ async fn process_transition_transaction_record_at(
|
|||||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||||
}
|
}
|
||||||
|
|
||||||
match current.state {
|
let mut recovery_control = if transition_state_needs_recovery_control(¤t, now_unix_nanos) {
|
||||||
TransitionTransactionState::Uploaded => {
|
if cleanup_terminal_transition_recovery_control(
|
||||||
if transition_transaction_ownership_is_active(¤t, now_unix_nanos) {
|
api.clone(),
|
||||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
¤t,
|
||||||
|
&record_name,
|
||||||
|
&recovery_control_identity,
|
||||||
|
&recovery_control_id,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(TransitionTransactionRecoveryOutcome::RecordDeleted);
|
||||||
}
|
}
|
||||||
|
match claim_transition_recovery_control(
|
||||||
|
api.clone(),
|
||||||
|
¤t,
|
||||||
|
&record_name,
|
||||||
|
recovery_control_identity,
|
||||||
|
&recovery_control_id,
|
||||||
|
now_unix_nanos,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(control) => Some(control),
|
||||||
|
None => return Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let recovery = match current.state {
|
||||||
|
TransitionTransactionState::Uploaded => {
|
||||||
|
if transition_transaction_ownership_is_active(¤t, i128::from(now_unix_nanos)) {
|
||||||
|
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||||
|
} else {
|
||||||
let mut cleanup = current.clone();
|
let mut cleanup = current.clone();
|
||||||
cleanup
|
cleanup
|
||||||
.mark_cleanup_pending(
|
.mark_cleanup_pending(
|
||||||
@@ -1090,36 +1242,352 @@ async fn process_transition_transaction_record_at(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(transition_transaction_store_error)?;
|
.map_err(transition_transaction_store_error)?;
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
pause_before_transition_recovery_claim(current.transaction_id).await;
|
pause_before_transition_recovery_claim(current.transaction_id).await;
|
||||||
match save_transition_transaction_record_if_current(api.clone(), ¤t, &cleanup).await {
|
match save_transition_transaction_record_if_current(api.clone(), ¤t, &cleanup).await {
|
||||||
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
|
Ok(()) => recover_cleanup_pending(api.clone(), &cleanup).await,
|
||||||
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => {
|
||||||
|
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||||
|
}
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TransitionTransactionState::CleanupPending => recover_cleanup_pending(api, ¤t).await,
|
|
||||||
TransitionTransactionState::LocalCommitStarted => match local_commit_matches_transaction(api.clone(), ¤t).await {
|
|
||||||
Ok(true) => {
|
|
||||||
delete_transition_transaction_record(api, ¤t).await?;
|
|
||||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
|
||||||
}
|
}
|
||||||
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
TransitionTransactionState::CleanupPending => recover_cleanup_pending(api.clone(), ¤t).await,
|
||||||
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
TransitionTransactionState::LocalCommitStarted => match local_commit_matches_transaction(api.clone(), ¤t).await {
|
||||||
|
Ok(true) => Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
||||||
|
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::OperatorRequired(
|
||||||
|
IlmRecoveryErrorCode::LocalCommitAmbiguous,
|
||||||
|
)),
|
||||||
|
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::OperatorRequired(
|
||||||
|
IlmRecoveryErrorCode::LocalCommitAmbiguous,
|
||||||
|
)),
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
},
|
},
|
||||||
TransitionTransactionState::AbortedNoRemote | TransitionTransactionState::Committed => {
|
TransitionTransactionState::AbortedNoRemote | TransitionTransactionState::Committed => {
|
||||||
delete_transition_transaction_record(api, ¤t).await?;
|
|
||||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||||
}
|
}
|
||||||
TransitionTransactionState::UploadOutcomeUnknown => {
|
TransitionTransactionState::UploadOutcomeUnknown => {
|
||||||
if transition_transaction_ownership_is_active(¤t, now_unix_nanos) {
|
if transition_transaction_ownership_is_active(¤t, i128::from(now_unix_nanos)) {
|
||||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||||
} else {
|
} else {
|
||||||
recover_unknown_upload_outcome(api, ¤t).await
|
recover_unknown_upload_outcome(api.clone(), ¤t).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
TransitionTransactionState::UploadStarted => {
|
||||||
|
if transition_transaction_ownership_is_active(¤t, i128::from(now_unix_nanos)) {
|
||||||
|
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||||
|
} else {
|
||||||
|
Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
|
||||||
|
IlmRecoveryErrorCode::RemoteVersionUnknown,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(mut control) = recovery_control.take() {
|
||||||
|
let source_to_delete = if matches!(
|
||||||
|
recovery,
|
||||||
|
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted
|
||||||
|
| TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||||
|
) {
|
||||||
|
let refreshed =
|
||||||
|
refresh_transition_recovery_control_source(api.clone(), control, &record_name, current.transaction_id).await?;
|
||||||
|
control = refreshed.0;
|
||||||
|
refreshed.1
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
persist_transition_recovery_result(api.clone(), control, &recovery, now_unix_nanos).await?;
|
||||||
|
if let Some(source) = source_to_delete {
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
pause_after_transition_recovery_terminal(source.transaction_id).await;
|
||||||
|
delete_transition_transaction_record(api, &source).await?;
|
||||||
|
}
|
||||||
|
} else if matches!(
|
||||||
|
recovery,
|
||||||
|
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted | TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||||
|
) {
|
||||||
|
delete_transition_transaction_record(api, ¤t).await?;
|
||||||
|
}
|
||||||
|
recovery
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transition_recovery_control_identity(transaction: &TransitionTransaction, record_name: &str) -> IlmRecoveryControlIdentity {
|
||||||
|
IlmRecoveryControlIdentity {
|
||||||
|
protocol: IlmRecoveryProtocol::TransitionTransaction,
|
||||||
|
canonical_source_path: record_name.to_string(),
|
||||||
|
stable_operation_identity: transaction.transaction_id.to_string(),
|
||||||
|
record_class: "transition_transaction_v1".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
pub(crate) fn transition_recovery_control_id(transaction: &TransitionTransaction) -> Result<String> {
|
||||||
|
let record_name = transition_transaction_record_object_name(transaction.transaction_id)?;
|
||||||
|
transition_recovery_control_identity(transaction, &record_name)
|
||||||
|
.source_operation_digest()
|
||||||
|
.map_err(|_| TransitionTransactionError::Corrupt("transition recovery control identity is invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transition_state_needs_recovery_control(transaction: &TransitionTransaction, now_unix_nanos: i64) -> bool {
|
||||||
|
now_unix_nanos >= transaction.not_after_unix_nanos
|
||||||
|
&& !matches!(
|
||||||
|
transaction.state,
|
||||||
|
TransitionTransactionState::AbortedNoRemote | TransitionTransactionState::Committed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_terminal_transition_recovery_control(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
transaction: &TransitionTransaction,
|
||||||
|
record_name: &str,
|
||||||
|
identity: &IlmRecoveryControlIdentity,
|
||||||
|
control_id: &str,
|
||||||
|
) -> EcstoreResult<bool> {
|
||||||
|
let observed = match load_recovery_control(api.clone(), IlmRecoveryProtocol::TransitionTransaction, control_id).await {
|
||||||
|
Ok(observed) => observed,
|
||||||
|
Err(Error::ConfigNotFound) => return Ok(false),
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
if observed.control.classification != IlmRecoveryClassification::Terminal {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let source = observe_recovery_source(api.clone(), record_name, TRANSITION_TRANSACTION_SCHEMA).await?;
|
||||||
|
let exact_source = source.is_consistent()
|
||||||
|
&& source.generation == observed.control.observed_source_generation
|
||||||
|
&& source.canonical_data.as_deref().is_some_and(|data| {
|
||||||
|
TransitionTransaction::decode(transaction.transaction_id, data).is_ok_and(|decoded| decoded == *transaction)
|
||||||
|
});
|
||||||
|
if observed.control.identity != *identity || !exact_source {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
delete_transition_transaction_record(api, transaction).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claim_transition_recovery_control(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
transaction: &TransitionTransaction,
|
||||||
|
record_name: &str,
|
||||||
|
identity: IlmRecoveryControlIdentity,
|
||||||
|
control_id: &str,
|
||||||
|
now_unix_nanos: i64,
|
||||||
|
) -> EcstoreResult<Option<ObservedIlmRecoveryControl>> {
|
||||||
|
let existing = match load_recovery_control(api.clone(), IlmRecoveryProtocol::TransitionTransaction, control_id).await {
|
||||||
|
Ok(control) => Some(control),
|
||||||
|
Err(Error::ConfigNotFound) => None,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
if let Some(observed) = existing.as_ref() {
|
||||||
|
if observed.control.identity != identity {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if observed
|
||||||
|
.control
|
||||||
|
.owner
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|owner| owner.lease_expires_at_unix_nanos <= now_unix_nanos)
|
||||||
|
{
|
||||||
|
let mut expired = observed.control.clone();
|
||||||
|
expired
|
||||||
|
.record_expired_attempt(now_unix_nanos)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
save_recovery_control_if_current(api, observed, &expired).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !observed.control.should_attempt_at(now_unix_nanos) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = match observe_recovery_source(api.clone(), record_name, TRANSITION_TRANSACTION_SCHEMA).await {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(observed) = existing {
|
||||||
|
persist_transition_recovery_source_failure(api, observed, now_unix_nanos).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let source_matches = source.is_consistent()
|
||||||
|
&& source.canonical_data.as_deref().is_some_and(|data| {
|
||||||
|
TransitionTransaction::decode(transaction.transaction_id, data).is_ok_and(|observed| observed == *transaction)
|
||||||
|
});
|
||||||
|
let source_error = if source_matches {
|
||||||
|
IlmRecoveryErrorCode::None
|
||||||
|
} else if source.canonical_data.is_some() {
|
||||||
|
IlmRecoveryErrorCode::SourceGenerationChanged
|
||||||
|
} else {
|
||||||
|
IlmRecoveryErrorCode::SourceDivergent
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut observed = match existing {
|
||||||
|
Some(control) => control,
|
||||||
|
None => {
|
||||||
|
let candidate = IlmRecoveryControl::new(
|
||||||
|
identity.clone(),
|
||||||
|
source.generation.clone(),
|
||||||
|
if source_matches {
|
||||||
|
IlmRecoveryClassification::Retrying
|
||||||
|
} else {
|
||||||
|
IlmRecoveryClassification::Corrupt
|
||||||
|
},
|
||||||
|
now_unix_nanos,
|
||||||
|
source_error,
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
match save_recovery_control_if_absent(api.clone(), &candidate).await {
|
||||||
|
Ok(()) | Err(Error::PreconditionFailed) => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
load_recovery_control(api.clone(), IlmRecoveryProtocol::TransitionTransaction, control_id).await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if observed.control.identity != identity || !observed.control.should_attempt_at(now_unix_nanos) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut claimed = observed.control.clone();
|
||||||
|
claimed
|
||||||
|
.claim_for_source_generation(
|
||||||
|
api.id.to_string(),
|
||||||
|
Uuid::new_v4(),
|
||||||
|
now_unix_nanos,
|
||||||
|
TRANSITION_RECOVERY_CONTROL_LEASE_NANOS,
|
||||||
|
source.generation,
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
save_recovery_control_if_current(api.clone(), &observed, &claimed).await?;
|
||||||
|
observed = load_recovery_control(api.clone(), IlmRecoveryProtocol::TransitionTransaction, control_id).await?;
|
||||||
|
if observed.control != claimed {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
if !source_matches {
|
||||||
|
let mut corrupt = observed.control.clone();
|
||||||
|
corrupt
|
||||||
|
.finish_attempt(IlmRecoveryClassification::Corrupt, source_error)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
save_recovery_control_if_current(api, &observed, &corrupt).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(observed))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_transition_recovery_source_failure(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
observed: ObservedIlmRecoveryControl,
|
||||||
|
now_unix_nanos: i64,
|
||||||
|
) -> EcstoreResult<()> {
|
||||||
|
let mut claimed = observed.control.clone();
|
||||||
|
claimed
|
||||||
|
.claim(
|
||||||
|
api.id.to_string(),
|
||||||
|
Uuid::new_v4(),
|
||||||
|
now_unix_nanos,
|
||||||
|
TRANSITION_RECOVERY_CONTROL_LEASE_NANOS,
|
||||||
|
)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
save_recovery_control_if_current(api.clone(), &observed, &claimed).await?;
|
||||||
|
let claimed = load_recovery_control(
|
||||||
|
api.clone(),
|
||||||
|
IlmRecoveryProtocol::TransitionTransaction,
|
||||||
|
&claimed
|
||||||
|
.identity
|
||||||
|
.source_operation_digest()
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut failed = claimed.control.clone();
|
||||||
|
failed
|
||||||
|
.record_retryable_failure(now_unix_nanos, IlmRecoveryErrorCode::SourceUnavailable)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
save_recovery_control_if_current(api, &claimed, &failed).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_transition_recovery_control_source(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
mut observed: ObservedIlmRecoveryControl,
|
||||||
|
record_name: &str,
|
||||||
|
transaction_id: Uuid,
|
||||||
|
) -> EcstoreResult<(ObservedIlmRecoveryControl, Option<TransitionTransaction>)> {
|
||||||
|
let transaction = match load_transition_transaction_record(api.clone(), transaction_id).await {
|
||||||
|
Ok(transaction) => transaction,
|
||||||
|
Err(Error::ConfigNotFound) => return Ok((observed, None)),
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
let source = observe_recovery_source(api.clone(), record_name, TRANSITION_TRANSACTION_SCHEMA).await?;
|
||||||
|
let exact_source = source.is_consistent()
|
||||||
|
&& source
|
||||||
|
.canonical_data
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|data| TransitionTransaction::decode(transaction_id, data).is_ok_and(|decoded| decoded == transaction));
|
||||||
|
if !exact_source {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
if observed.control.observed_source_generation != source.generation {
|
||||||
|
let mut refreshed = observed.control.clone();
|
||||||
|
refreshed
|
||||||
|
.refresh_owned_source_generation(source.generation)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
save_recovery_control_if_current(api.clone(), &observed, &refreshed).await?;
|
||||||
|
observed = load_recovery_control(
|
||||||
|
api,
|
||||||
|
IlmRecoveryProtocol::TransitionTransaction,
|
||||||
|
&refreshed
|
||||||
|
.identity
|
||||||
|
.source_operation_digest()
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if observed.control != refreshed {
|
||||||
|
return Err(Error::PreconditionFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((observed, Some(transaction)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_transition_recovery_result(
|
||||||
|
api: Arc<ECStore>,
|
||||||
|
observed: ObservedIlmRecoveryControl,
|
||||||
|
recovery: &EcstoreResult<TransitionTransactionRecoveryOutcome>,
|
||||||
|
now_unix_nanos: i64,
|
||||||
|
) -> EcstoreResult<()> {
|
||||||
|
let mut next = observed.control.clone();
|
||||||
|
match recovery {
|
||||||
|
Ok(
|
||||||
|
TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted | TransitionTransactionRecoveryOutcome::RecordDeleted,
|
||||||
|
) => next
|
||||||
|
.finish_attempt(IlmRecoveryClassification::Terminal, IlmRecoveryErrorCode::None)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?,
|
||||||
|
Ok(TransitionTransactionRecoveryOutcome::Retained) => next
|
||||||
|
.record_retryable_failure(now_unix_nanos, IlmRecoveryErrorCode::SourceGenerationChanged)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?,
|
||||||
|
Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(code)) => next
|
||||||
|
.finish_attempt(IlmRecoveryClassification::RetainedAmbiguous, *code)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?,
|
||||||
|
Ok(TransitionTransactionRecoveryOutcome::OperatorRequired(code)) => next
|
||||||
|
.finish_attempt(IlmRecoveryClassification::OperatorRequired, *code)
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?,
|
||||||
|
Err(err) => next
|
||||||
|
.record_retryable_failure(now_unix_nanos, transition_recovery_error_code(err))
|
||||||
|
.map_err(|err| Error::other(err.to_string()))?,
|
||||||
|
}
|
||||||
|
save_recovery_control_if_current(api, &observed, &next).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transition_recovery_error_code(err: &Error) -> IlmRecoveryErrorCode {
|
||||||
|
match err {
|
||||||
|
Error::PreconditionFailed => IlmRecoveryErrorCode::CasConflict,
|
||||||
|
Error::ConfigNotFound
|
||||||
|
| Error::FileNotFound
|
||||||
|
| Error::FileVersionNotFound
|
||||||
|
| Error::ObjectNotFound(_, _)
|
||||||
|
| Error::VersionNotFound(_, _, _)
|
||||||
|
| Error::BucketNotFound(_) => IlmRecoveryErrorCode::SourceUnavailable,
|
||||||
|
Error::SlowDown => IlmRecoveryErrorCode::BackendThrottled,
|
||||||
|
_ => IlmRecoveryErrorCode::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,10 +1600,7 @@ async fn recover_cleanup_pending(
|
|||||||
transaction: &TransitionTransaction,
|
transaction: &TransitionTransaction,
|
||||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||||
match local_commit_matches_transaction(api.clone(), transaction).await {
|
match local_commit_matches_transaction(api.clone(), transaction).await {
|
||||||
Ok(true) => {
|
Ok(true) => Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
||||||
delete_transition_transaction_record(api, transaction).await?;
|
|
||||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
|
||||||
}
|
|
||||||
Ok(false) => delete_unreferenced_transition_candidate(api, transaction).await,
|
Ok(false) => delete_unreferenced_transition_candidate(api, transaction).await,
|
||||||
Err(err) if transition_source_is_missing(&err) => delete_unreferenced_transition_candidate(api, transaction).await,
|
Err(err) if transition_source_is_missing(&err) => delete_unreferenced_transition_candidate(api, transaction).await,
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
@@ -1155,7 +1620,6 @@ async fn delete_unreferenced_transition_candidate(
|
|||||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||||
}
|
}
|
||||||
delete_transition_remote_candidate(api.clone(), ¤t).await?;
|
delete_transition_remote_candidate(api.clone(), ¤t).await?;
|
||||||
delete_transition_transaction_record(api, ¤t).await?;
|
|
||||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1176,24 +1640,26 @@ async fn recover_unknown_upload_outcome(
|
|||||||
.await
|
.await
|
||||||
.map_err(Error::other)?
|
.map_err(Error::other)?
|
||||||
{
|
{
|
||||||
TransitionCandidateProbe::Missing => {
|
TransitionCandidateProbe::Missing => Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
||||||
delete_transition_transaction_record(api, transaction).await?;
|
|
||||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
|
||||||
}
|
|
||||||
TransitionCandidateProbe::UnversionedPresent => {
|
TransitionCandidateProbe::UnversionedPresent => {
|
||||||
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::unversioned()).await
|
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::unversioned()).await
|
||||||
}
|
}
|
||||||
TransitionCandidateProbe::VersionedPresent(version_id)
|
TransitionCandidateProbe::VersionedPresent(version_id)
|
||||||
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
|
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
|
||||||
{
|
{
|
||||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
|
||||||
|
IlmRecoveryErrorCode::RemoteVersionUnknown,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
TransitionCandidateProbe::VersionedPresent(version_id) => {
|
TransitionCandidateProbe::VersionedPresent(version_id) => {
|
||||||
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
|
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
|
||||||
}
|
}
|
||||||
TransitionCandidateProbe::Ambiguous | TransitionCandidateProbe::Unsupported => {
|
TransitionCandidateProbe::Ambiguous => Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
|
||||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
IlmRecoveryErrorCode::RemoteProbeAmbiguous,
|
||||||
}
|
)),
|
||||||
|
TransitionCandidateProbe::Unsupported => Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
|
||||||
|
IlmRecoveryErrorCode::RemoteProbeUnsupported,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,7 +1753,7 @@ pub async fn recover_transition_transaction_records(
|
|||||||
recover_transition_transaction_records_with_now(api, limit, marker, None).await
|
recover_transition_transaction_records_with_now(api, limit, marker, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn recover_transition_transaction_records_at(
|
pub async fn recover_transition_transaction_records_at(
|
||||||
api: Arc<ECStore>,
|
api: Arc<ECStore>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
@@ -1321,6 +1787,11 @@ async fn recover_transition_transaction_records_with_now(
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
if list.is_truncated && list.next_continuation_token.is_none() {
|
||||||
|
return Err(Error::other(
|
||||||
|
"transition transaction recovery returned a truncated page without a continuation marker",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let mut stats = TransitionTransactionRecoveryStats {
|
let mut stats = TransitionTransactionRecoveryStats {
|
||||||
scanned: 0,
|
scanned: 0,
|
||||||
@@ -1379,7 +1850,11 @@ async fn recover_transition_transaction_records_with_now(
|
|||||||
) => {
|
) => {
|
||||||
stats.recovered += 1;
|
stats.recovered += 1;
|
||||||
}
|
}
|
||||||
Ok(TransitionTransactionRecoveryOutcome::Retained) => {
|
Ok(
|
||||||
|
TransitionTransactionRecoveryOutcome::Retained
|
||||||
|
| TransitionTransactionRecoveryOutcome::RetainedAmbiguous(_)
|
||||||
|
| TransitionTransactionRecoveryOutcome::OperatorRequired(_),
|
||||||
|
) => {
|
||||||
stats.retained += 1;
|
stats.retained += 1;
|
||||||
debug!(
|
debug!(
|
||||||
event = EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY,
|
event = EVENT_LIFECYCLE_TRANSITION_TRANSACTION_RECOVERY,
|
||||||
@@ -1507,11 +1982,74 @@ fn state_requires_known_remote_version(state: TransitionTransactionState) -> boo
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const BACKEND_FINGERPRINT: [u8; 32] = [7; 32];
|
const BACKEND_FINGERPRINT: [u8; 32] = [7; 32];
|
||||||
|
|
||||||
|
struct RecoveryAttemptDropGuard(Arc<AtomicBool>);
|
||||||
|
|
||||||
|
impl Drop for RecoveryAttemptDropGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pending_recovery_attempt(started: Arc<tokio::sync::Notify>, dropped: Arc<AtomicBool>) -> EcstoreResult<()> {
|
||||||
|
let _drop_guard = RecoveryAttemptDropGuard(dropped);
|
||||||
|
started.notify_one();
|
||||||
|
std::future::pending().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn transition_recovery_timeout_and_cancellation_drop_inflight_attempts() {
|
||||||
|
let timeout_started = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let timeout_dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let timeout_task = tokio::spawn({
|
||||||
|
let started = Arc::clone(&timeout_started);
|
||||||
|
let dropped = Arc::clone(&timeout_dropped);
|
||||||
|
async move {
|
||||||
|
await_transition_transaction_recovery(
|
||||||
|
&CancellationToken::new(),
|
||||||
|
TRANSITION_TRANSACTION_RECOVERY_TIMEOUT,
|
||||||
|
pending_recovery_attempt(started, dropped),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
timeout_started.notified().await;
|
||||||
|
tokio::time::advance(TRANSITION_TRANSACTION_RECOVERY_TIMEOUT).await;
|
||||||
|
let timed_out = timeout_task.await.expect("timeout wrapper task should join");
|
||||||
|
assert!(matches!(timed_out, Some(Err(_))), "outer timeout should fail the recovery pass");
|
||||||
|
assert!(timeout_dropped.load(Ordering::SeqCst), "outer timeout must drop its in-flight attempt");
|
||||||
|
|
||||||
|
let cancel_token = CancellationToken::new();
|
||||||
|
let cancel_started = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let cancel_dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let cancel_task = tokio::spawn({
|
||||||
|
let cancel_token = cancel_token.clone();
|
||||||
|
let started = Arc::clone(&cancel_started);
|
||||||
|
let dropped = Arc::clone(&cancel_dropped);
|
||||||
|
async move {
|
||||||
|
await_transition_transaction_recovery(
|
||||||
|
&cancel_token,
|
||||||
|
TRANSITION_TRANSACTION_RECOVERY_TIMEOUT,
|
||||||
|
pending_recovery_attempt(started, dropped),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cancel_started.notified().await;
|
||||||
|
cancel_token.cancel();
|
||||||
|
let cancelled = cancel_task.await.expect("cancellation wrapper task should join");
|
||||||
|
assert!(cancelled.is_none(), "outer cancellation should stop the recovery loop");
|
||||||
|
assert!(
|
||||||
|
cancel_dropped.load(Ordering::SeqCst),
|
||||||
|
"outer cancellation must drop its in-flight attempt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct MemoryTransactionStore {
|
struct MemoryTransactionStore {
|
||||||
records: HashMap<Uuid, Vec<u8>>,
|
records: HashMap<Uuid, Vec<u8>>,
|
||||||
@@ -1966,5 +2504,19 @@ mod tests {
|
|||||||
transition_transaction_record_object_name(Uuid::nil()),
|
transition_transaction_record_object_name(Uuid::nil()),
|
||||||
Err(TransitionTransactionError::Corrupt("transaction_id is nil"))
|
Err(TransitionTransactionError::Corrupt("transaction_id is nil"))
|
||||||
));
|
));
|
||||||
|
assert_eq!(
|
||||||
|
transition_transaction_id_from_record_object_name(&object).expect("canonical record path should parse"),
|
||||||
|
transaction_id
|
||||||
|
);
|
||||||
|
for malformed in [
|
||||||
|
object.to_ascii_uppercase(),
|
||||||
|
object.replace("/aa/aa/", "/ff/aa/"),
|
||||||
|
object.replace("/aa/aa/", "/aa/aa/extra/"),
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
transition_transaction_id_from_record_object_name(&malformed),
|
||||||
|
Err(TransitionTransactionError::Corrupt(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -904,13 +905,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 +958,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 +1107,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());
|
||||||
@@ -1535,6 +1560,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 +1988,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 +2023,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,6 +641,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 +666,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 +696,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 +704,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,
|
||||||
@@ -1008,15 +1035,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;
|
||||||
@@ -2118,7 +2151,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 +2464,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 +2537,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 +2561,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 +2571,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 +2632,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 +2828,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 +3197,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;
|
||||||
@@ -4066,6 +4212,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 +4375,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 +4404,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 +4417,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 +4474,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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -3084,6 +3084,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use rustfs_protos::{
|
|||||||
ChannelClass, create_new_channel, get_channel_for_class,
|
ChannelClass, create_new_channel, get_channel_for_class,
|
||||||
proto_gen::node_service::{
|
proto_gen::node_service::{
|
||||||
heal_control_service_client::HealControlServiceClient, node_service_client::NodeServiceClient,
|
heal_control_service_client::HealControlServiceClient, node_service_client::NodeServiceClient,
|
||||||
|
scanner_control_service_client::ScannerControlServiceClient,
|
||||||
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -60,6 +61,24 @@ pub async fn node_service_time_out_client(
|
|||||||
node_service_time_out_client_for_class(addr, interceptor, ChannelClass::Control).await
|
node_service_time_out_client_for_class(addr, interceptor, ChannelClass::Control).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn scanner_control_time_out_client(
|
||||||
|
addr: &str,
|
||||||
|
interceptor: TonicInterceptor,
|
||||||
|
) -> crate::error::Result<ScannerControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>> {
|
||||||
|
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||||
|
let channel = match runtime_sources::cached_node_channel(addr).await {
|
||||||
|
Some(channel) => channel,
|
||||||
|
None => create_new_channel(addr)
|
||||||
|
.await
|
||||||
|
.map_err(|err| crate::error::Error::other(err.to_string()))?,
|
||||||
|
};
|
||||||
|
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||||
|
let limit = rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES as usize;
|
||||||
|
Ok(ScannerControlServiceClient::with_interceptor(channel, interceptor)
|
||||||
|
.max_decoding_message_size(limit)
|
||||||
|
.max_encoding_message_size(limit))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn heal_control_time_out_client(
|
pub async fn heal_control_time_out_client(
|
||||||
addr: &str,
|
addr: &str,
|
||||||
interceptor: TonicInterceptor,
|
interceptor: TonicInterceptor,
|
||||||
|
|||||||
@@ -2050,6 +2050,53 @@ impl PeerRestClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probe only: scoped ACK production requires a durable per-bucket proof.
|
||||||
|
pub async fn scanner_scoped_dirty_usage_capability(
|
||||||
|
&self,
|
||||||
|
owner_id: String,
|
||||||
|
instance_id: String,
|
||||||
|
entries: Vec<rustfs_protos::proto_gen::node_service::ScannerScopedDirtyUsageEntry>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
use rustfs_protos::scoped_dirty_usage::*;
|
||||||
|
let payload = rustfs_protos::proto_gen::node_service::ScannerScopedDirtyUsageAckRequest {
|
||||||
|
challenge: Uuid::new_v4().as_bytes().to_vec().into(),
|
||||||
|
protocol_version: SCOPED_DIRTY_USAGE_PROTOCOL_VERSION,
|
||||||
|
owner_id,
|
||||||
|
instance_id,
|
||||||
|
scope: SCOPED_DIRTY_USAGE_BUCKET_SCOPE,
|
||||||
|
probe_only: true,
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
let canonical = canonical_scoped_dirty_usage_request(&payload).map_err(|err| Error::other(err.to_string()))?;
|
||||||
|
self.finalize_result(
|
||||||
|
async {
|
||||||
|
let mut client = super::client::scanner_control_time_out_client(
|
||||||
|
&self.grid_host,
|
||||||
|
TonicInterceptor::Signature(gen_tonic_signature_interceptor()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut request = Request::new(payload.clone());
|
||||||
|
set_tonic_canonical_body_digest(&mut request, &canonical)?;
|
||||||
|
let response = client.scanner_scoped_dirty_usage_ack(request).await?.into_inner();
|
||||||
|
let body = canonical_scoped_dirty_usage_response(&canonical, &response)
|
||||||
|
.map_err(|_| Error::other("scoped dirty usage capability response is too large"))?;
|
||||||
|
verify_tonic_rpc_response_proof(&body, response.response_proof.as_ref())?;
|
||||||
|
if response.protocol_version != SCOPED_DIRTY_USAGE_PROTOCOL_VERSION
|
||||||
|
|| response.owner_id != payload.owner_id
|
||||||
|
|| response.instance_id != payload.instance_id
|
||||||
|
|| response.max_entries != SCOPED_DIRTY_USAGE_MAX_ENTRIES
|
||||||
|
|| response.max_request_bytes != SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES
|
||||||
|
|| response.cleared != 0
|
||||||
|
{
|
||||||
|
return Err(Error::other("scoped dirty usage capability response does not match request"));
|
||||||
|
}
|
||||||
|
Ok(response.supported)
|
||||||
|
}
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn acknowledge_scanner_dirty_usage(&self, instance_id: String, generation: u64) -> Result<ScannerPeerActivity> {
|
pub async fn acknowledge_scanner_dirty_usage(&self, instance_id: String, generation: u64) -> Result<ScannerPeerActivity> {
|
||||||
let result = self
|
let result = self
|
||||||
.scanner_activity_request_with_protocol(instance_id.clone(), generation, SCANNER_ACTIVITY_PROTOCOL_VERSION)
|
.scanner_activity_request_with_protocol(instance_id.clone(), generation, SCANNER_ACTIVITY_PROTOCOL_VERSION)
|
||||||
|
|||||||
@@ -3013,7 +3013,7 @@ fn parse_decommission_durable_ilm_receipt_path(path: &str) -> Result<Decommissio
|
|||||||
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` is missing its record id")))?;
|
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` is missing its record id")))?;
|
||||||
let id_kind = parts
|
let id_kind = parts
|
||||||
.next()
|
.next()
|
||||||
.filter(|id_kind| matches!(*id_kind, "operation_id" | "transaction_id" | "job_id"))
|
.filter(|id_kind| matches!(*id_kind, "operation_id" | "transaction_id" | "job_id" | "control_id"))
|
||||||
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` has an invalid id kind")))?;
|
.ok_or_else(|| Error::other(format!("durable ILM receipt path `{path}` has an invalid id kind")))?;
|
||||||
let source_path = parts
|
let source_path = parts
|
||||||
.next()
|
.next()
|
||||||
@@ -3023,8 +3023,9 @@ fn parse_decommission_durable_ilm_receipt_path(path: &str) -> Result<Decommissio
|
|||||||
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid run token")));
|
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid run token")));
|
||||||
}
|
}
|
||||||
match id_kind {
|
match id_kind {
|
||||||
"operation_id" if !is_sha256_checksum(id) => {
|
"operation_id" | "control_id" if !is_sha256_checksum(id) => {
|
||||||
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid operation id")));
|
let id_label = id_kind.trim_end_matches("_id");
|
||||||
|
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid {id_label} id")));
|
||||||
}
|
}
|
||||||
"transaction_id" | "job_id" if uuid::Uuid::parse_str(id).is_err() => {
|
"transaction_id" | "job_id" if uuid::Uuid::parse_str(id).is_err() => {
|
||||||
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid UUID")));
|
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid UUID")));
|
||||||
@@ -5493,6 +5494,7 @@ where
|
|||||||
fence.ensure_held()?;
|
fence.ensure_held()?;
|
||||||
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(pool_meta_cas_preconditions(token, object)?),
|
http_preconditions: Some(pool_meta_cas_preconditions(token, object)?),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -14412,6 +14414,7 @@ impl ECStore {
|
|||||||
encoded.clone(),
|
encoded.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()
|
||||||
@@ -14566,6 +14569,7 @@ impl ECStore {
|
|||||||
encoded,
|
encoded,
|
||||||
&ObjectOptions {
|
&ObjectOptions {
|
||||||
max_parity: true,
|
max_parity: true,
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
http_preconditions: Some(http_preconditions),
|
http_preconditions: Some(http_preconditions),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -14957,6 +14961,7 @@ impl ECStore {
|
|||||||
encoded,
|
encoded,
|
||||||
&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()
|
||||||
@@ -19296,8 +19301,8 @@ mod pools_tests {
|
|||||||
load_decommission_entry_versions, local_decommission_queue_prefix, mark_decommission_bucket_done,
|
load_decommission_entry_versions, local_decommission_queue_prefix, mark_decommission_bucket_done,
|
||||||
merge_decommission_durable_ilm_receipts, merge_pool_meta_updates_for_save, merge_pool_status_refresh,
|
merge_decommission_durable_ilm_receipts, merge_pool_meta_updates_for_save, merge_pool_status_refresh,
|
||||||
missing_decommission_worker_prefix, next_decommission_capacity_generation, observe_decommission_terminal_reload_result,
|
missing_decommission_worker_prefix, next_decommission_capacity_generation, observe_decommission_terminal_reload_result,
|
||||||
pool_meta_has_active_decommission, publish_pool_meta_updates, read_pool_meta_replica,
|
parse_decommission_durable_ilm_receipt_path, pool_meta_has_active_decommission, publish_pool_meta_updates,
|
||||||
reconcile_decommission_meta_buckets, reconcile_decommission_unresolved_entries_for_completion,
|
read_pool_meta_replica, reconcile_decommission_meta_buckets, reconcile_decommission_unresolved_entries_for_completion,
|
||||||
record_decommission_unresolved_entry, recover_decommission_capacity_reservations,
|
record_decommission_unresolved_entry, recover_decommission_capacity_reservations,
|
||||||
renew_decommission_capacity_reservation, require_decommission_store, reserve_decommission_start_cancelers,
|
renew_decommission_capacity_reservation, require_decommission_store, reserve_decommission_start_cancelers,
|
||||||
reserve_decommission_start_target_capacity, resolve_decommission_bucket_state,
|
reserve_decommission_start_target_capacity, resolve_decommission_bucket_state,
|
||||||
@@ -20165,6 +20170,26 @@ mod pools_tests {
|
|||||||
assert!(!old_receipt.starts_with(&decommission_durable_ilm_receipt_run_prefix(&second_token)));
|
assert!(!old_receipt.starts_with(&decommission_durable_ilm_receipt_run_prefix(&second_token)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decommission_recovery_control_receipt_path_round_trips() {
|
||||||
|
let run_token = "b".repeat(64);
|
||||||
|
let control_id = "a".repeat(64);
|
||||||
|
let source_path = format!(
|
||||||
|
"ilm/recovery-controls/transition_transaction/{}/{}/{}.json",
|
||||||
|
&control_id[..2],
|
||||||
|
&control_id[2..4],
|
||||||
|
control_id
|
||||||
|
);
|
||||||
|
let path = decommission_durable_ilm_receipt_path(&run_token, &source_path, "control_id", &control_id);
|
||||||
|
|
||||||
|
let locator = parse_decommission_durable_ilm_receipt_path(&path).expect("recovery control receipt path should parse");
|
||||||
|
|
||||||
|
assert_eq!(locator.run_token, run_token);
|
||||||
|
assert_eq!(locator.source_path, source_path);
|
||||||
|
assert_eq!(locator.id_kind, "control_id");
|
||||||
|
assert_eq!(locator.id, control_id);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decommission_receipt_merge_preserves_terminal_proof() {
|
fn decommission_receipt_merge_preserves_terminal_proof() {
|
||||||
let operation_id = "a".repeat(64);
|
let operation_id = "a".repeat(64);
|
||||||
|
|||||||
@@ -99,11 +99,17 @@ pub(crate) const GET_STAGE_READER_OPEN_MMAP_COPY_FALLBACK: &str = "reader_open_m
|
|||||||
pub(crate) const GET_STAGE_READER_OPEN_MMAP_COPY_SUCCESS: &str = "reader_open_mmap_copy_success";
|
pub(crate) const GET_STAGE_READER_OPEN_MMAP_COPY_SUCCESS: &str = "reader_open_mmap_copy_success";
|
||||||
pub(crate) const GET_STAGE_READER_OPEN_STREAM: &str = "reader_open_stream";
|
pub(crate) const GET_STAGE_READER_OPEN_STREAM: &str = "reader_open_stream";
|
||||||
pub(crate) const GET_STAGE_READER_MMAP_ACCESS_CHECK: &str = "reader_mmap_access_check";
|
pub(crate) const GET_STAGE_READER_MMAP_ACCESS_CHECK: &str = "reader_mmap_access_check";
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) const GET_STAGE_READER_MMAP_BLOCKING_TASK: &str = "reader_mmap_blocking_task";
|
pub(crate) const GET_STAGE_READER_MMAP_BLOCKING_TASK: &str = "reader_mmap_blocking_task";
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) const GET_STAGE_READER_MMAP_BLOCKING_WAIT: &str = "reader_mmap_blocking_wait";
|
pub(crate) const GET_STAGE_READER_MMAP_BLOCKING_WAIT: &str = "reader_mmap_blocking_wait";
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) const GET_STAGE_READER_MMAP_COPY_BUFFER: &str = "reader_mmap_copy_buffer";
|
pub(crate) const GET_STAGE_READER_MMAP_COPY_BUFFER: &str = "reader_mmap_copy_buffer";
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) const GET_STAGE_READER_MMAP_DIRECT_READ_COPY: &str = "reader_mmap_direct_read_copy";
|
pub(crate) const GET_STAGE_READER_MMAP_DIRECT_READ_COPY: &str = "reader_mmap_direct_read_copy";
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) const GET_STAGE_READER_MMAP_FILE_OPEN: &str = "reader_mmap_file_open";
|
pub(crate) const GET_STAGE_READER_MMAP_FILE_OPEN: &str = "reader_mmap_file_open";
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) const GET_STAGE_READER_MMAP_MAP: &str = "reader_mmap_map";
|
pub(crate) const GET_STAGE_READER_MMAP_MAP: &str = "reader_mmap_map";
|
||||||
pub(crate) const GET_STAGE_READER_MMAP_METADATA_LOOKUP: &str = "reader_mmap_metadata_lookup";
|
pub(crate) const GET_STAGE_READER_MMAP_METADATA_LOOKUP: &str = "reader_mmap_metadata_lookup";
|
||||||
pub(crate) const GET_STAGE_READER_MMAP_METADATA_VALIDATE: &str = "reader_mmap_metadata_validate";
|
pub(crate) const GET_STAGE_READER_MMAP_METADATA_VALIDATE: &str = "reader_mmap_metadata_validate";
|
||||||
|
|||||||
@@ -317,6 +317,74 @@ impl DiskStoreRenameDataExt for LocalDiskWrapper {
|
|||||||
dst_path: &str,
|
dst_path: &str,
|
||||||
external_guard: Option<Arc<dyn Send + Sync>>,
|
external_guard: Option<Arc<dyn Send + Sync>>,
|
||||||
) -> Result<RenameDataResp> {
|
) -> Result<RenameDataResp> {
|
||||||
|
self.rename_data_observed(src_volume, src_path, fi, dst_volume, dst_path, external_guard)
|
||||||
|
.await
|
||||||
|
.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalDiskWrapper {
|
||||||
|
pub(in crate::disk) async fn undo_write_with_namespace_owner(
|
||||||
|
&self,
|
||||||
|
volume: &str,
|
||||||
|
path: &str,
|
||||||
|
fi: FileInfo,
|
||||||
|
opts: DeleteOptions,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.track_disk_health_mutation(
|
||||||
|
"delete_version",
|
||||||
|
DiskMetricMutation::Delete,
|
||||||
|
|| async {
|
||||||
|
// Preserve the old DiskAPI future's boxing boundary.
|
||||||
|
Box::pin(
|
||||||
|
self.disk
|
||||||
|
.undo_write_with_namespace_owner(volume, path, fi, opts, namespace_owner),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
},
|
||||||
|
get_max_timeout_duration(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::disk) async fn rename_data_observed(
|
||||||
|
&self,
|
||||||
|
src_volume: &str,
|
||||||
|
src_path: &str,
|
||||||
|
fi: &FileInfo,
|
||||||
|
dst_volume: &str,
|
||||||
|
dst_path: &str,
|
||||||
|
external_guard: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> super::RenameDataObservation {
|
||||||
|
self.rename_data_observed_with_guards(
|
||||||
|
src_volume,
|
||||||
|
src_path,
|
||||||
|
fi,
|
||||||
|
dst_volume,
|
||||||
|
dst_path,
|
||||||
|
super::RenameDataGuards {
|
||||||
|
external_guard,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::disk) async fn rename_data_observed_with_guards(
|
||||||
|
&self,
|
||||||
|
src_volume: &str,
|
||||||
|
src_path: &str,
|
||||||
|
fi: &FileInfo,
|
||||||
|
dst_volume: &str,
|
||||||
|
dst_path: &str,
|
||||||
|
guards: super::RenameDataGuards,
|
||||||
|
) -> super::RenameDataObservation {
|
||||||
|
let super::RenameDataGuards {
|
||||||
|
external_guard,
|
||||||
|
namespace_owner,
|
||||||
|
..
|
||||||
|
} = guards;
|
||||||
let operation = self.clone();
|
let operation = self.clone();
|
||||||
let src_volume = src_volume.to_owned();
|
let src_volume = src_volume.to_owned();
|
||||||
let src_path = src_path.to_owned();
|
let src_path = src_path.to_owned();
|
||||||
@@ -333,22 +401,37 @@ impl DiskStoreRenameDataExt for LocalDiskWrapper {
|
|||||||
} else {
|
} else {
|
||||||
get_max_timeout_duration()
|
get_max_timeout_duration()
|
||||||
};
|
};
|
||||||
run_owned_mutation(external_guard, move || async move {
|
let observed = run_owned_mutation(external_guard, move || async move {
|
||||||
operation
|
let mut preflight_rejection = None;
|
||||||
|
let result = operation
|
||||||
.track_disk_health_mutation(
|
.track_disk_health_mutation(
|
||||||
"rename_data",
|
"rename_data",
|
||||||
DiskMetricMutation::Write,
|
DiskMetricMutation::Write,
|
||||||
|| async {
|
|| async {
|
||||||
operation
|
// Preserve the former DiskAPI future's single boxing boundary.
|
||||||
.disk
|
let observed = Box::pin(operation.disk.rename_data_observed(
|
||||||
.rename_data_borrowed(&src_volume, &src_path, &fi, &dst_volume, &dst_path)
|
&src_volume,
|
||||||
.await
|
&src_path,
|
||||||
|
&fi,
|
||||||
|
&dst_volume,
|
||||||
|
&dst_path,
|
||||||
|
namespace_owner,
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
preflight_rejection = observed.preflight_rejection;
|
||||||
|
observed.result
|
||||||
},
|
},
|
||||||
timeout_duration,
|
timeout_duration,
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
|
// Health tracking must observe the real disk error, not an Ok tuple.
|
||||||
|
Ok(super::RenameDataObservation {
|
||||||
|
result,
|
||||||
|
preflight_rejection,
|
||||||
})
|
})
|
||||||
.await
|
})
|
||||||
|
.await;
|
||||||
|
observed.unwrap_or_else(|error| super::RenameDataObservation::unknown(Err(error)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1272,6 +1355,7 @@ impl LocalDiskWrapper {
|
|||||||
self.disk.get_object_path(volume, path)
|
self.disk.get_object_path(volume, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) fn get_object_path_for_io(&self, volume: &str, path: &str) -> crate::disk::error::Result<std::path::PathBuf> {
|
pub(crate) fn get_object_path_for_io(&self, volume: &str, path: &str) -> crate::disk::error::Result<std::path::PathBuf> {
|
||||||
self.disk.get_object_path_for_io(volume, path)
|
self.disk.get_object_path_for_io(volume, path)
|
||||||
}
|
}
|
||||||
@@ -2588,6 +2672,46 @@ mod tests {
|
|||||||
assert_eq!(wrapper.metrics_snapshot().api_calls.get("unknown"), Some(&1));
|
assert_eq!(wrapper.metrics_snapshot().api_calls.get("unknown"), Some(&1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_preflight_evidence_preserves_health_errors_and_owned_reply() {
|
||||||
|
for source_exists in [false, true] {
|
||||||
|
for guarded in [false, true] {
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||||
|
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be valid UTF-8"))
|
||||||
|
.expect("endpoint should parse");
|
||||||
|
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("local disk should be created"));
|
||||||
|
if source_exists {
|
||||||
|
disk.make_volume("source").await.expect("source volume should exist");
|
||||||
|
}
|
||||||
|
let wrapper = LocalDiskWrapper::new(disk, false);
|
||||||
|
let drops = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let external_guard = guarded.then(|| Arc::new(DropProbe(Arc::clone(&drops))) as Arc<dyn Send + Sync>);
|
||||||
|
let mut file_info = FileInfo::new("object", 1, 0);
|
||||||
|
file_info.mod_time = Some(::time::OffsetDateTime::now_utc());
|
||||||
|
file_info.erasure.index = 1;
|
||||||
|
let observed = wrapper
|
||||||
|
.rename_data_observed("source", "object", &file_info, "missing-destination", "object", external_guard)
|
||||||
|
.await;
|
||||||
|
assert!(observed.rejected_before_publication(), "normal access rejection must carry proof");
|
||||||
|
assert!(matches!(observed.result, Err(DiskError::VolumeNotFound)));
|
||||||
|
let snapshot = wrapper.metrics_snapshot();
|
||||||
|
assert_eq!(snapshot.api_calls.get("rename_data"), Some(&1));
|
||||||
|
assert_eq!(snapshot.total_writes, 0, "health tracking must not observe the rejection as Ok");
|
||||||
|
assert_eq!(drops.load(Ordering::SeqCst), usize::from(guarded));
|
||||||
|
|
||||||
|
wrapper.health.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
|
||||||
|
let observed = wrapper
|
||||||
|
.rename_data_observed("source", "object", &file_info, "missing-destination", "object", None)
|
||||||
|
.await;
|
||||||
|
assert!(!observed.rejected_before_publication(), "wrapper errors carry no local preflight proof");
|
||||||
|
assert!(matches!(observed.result, Err(DiskError::FaultyDisk)));
|
||||||
|
let snapshot = wrapper.metrics_snapshot();
|
||||||
|
assert_eq!(snapshot.total_errors_availability, 1);
|
||||||
|
assert_eq!(snapshot.total_writes, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_disk_health_wrapper_counts_returned_availability_errors() {
|
async fn local_disk_health_wrapper_counts_returned_availability_errors() {
|
||||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||||
|
|||||||
@@ -218,10 +218,12 @@ pub async fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<
|
|||||||
fs::rename(from, to).await
|
fs::rename(from, to).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(not(windows), test))]
|
||||||
pub fn rename_std(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
|
pub fn rename_std(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
|
||||||
std::fs::rename(from, to)
|
std::fs::rename(from, to)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(not(windows), test))]
|
||||||
#[tracing::instrument(level = "debug", skip_all)]
|
#[tracing::instrument(level = "debug", skip_all)]
|
||||||
pub async fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
|
pub async fn read_file(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
|
||||||
fs::read(path.as_ref()).await
|
fs::read(path.as_ref()).await
|
||||||
|
|||||||
+1190
-1470
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,33 @@ use time::OffsetDateTime;
|
|||||||
use tokio::io::{AsyncRead, AsyncWrite};
|
use tokio::io::{AsyncRead, AsyncWrite};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Independent admission and physical ownership for one disk rename.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct RenameDataGuards {
|
||||||
|
pub(crate) scanner_publication_lease_token: Option<Uuid>,
|
||||||
|
pub(crate) external_guard: Option<Arc<dyn Send + Sync>>,
|
||||||
|
pub(crate) namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local preflight evidence stays outside DiskAPI and the RPC response format.
|
||||||
|
pub(crate) struct RenameDataObservation {
|
||||||
|
pub(crate) result: Result<RenameDataResp>,
|
||||||
|
preflight_rejection: Option<local::LocalRenamePreflightRejection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenameDataObservation {
|
||||||
|
fn unknown(result: Result<RenameDataResp>) -> Self {
|
||||||
|
Self {
|
||||||
|
result,
|
||||||
|
preflight_rejection: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rejected_before_publication(&self) -> bool {
|
||||||
|
self.result.is_err() && self.preflight_rejection.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const QUOTA_MUTATION_FENCE_PREFIX: &str = "tmp/quota-mutation-fences/";
|
const QUOTA_MUTATION_FENCE_PREFIX: &str = "tmp/quota-mutation-fences/";
|
||||||
pub(crate) const QUOTA_MUTATION_FENCE_METADATA_SUFFIX: &str = "quota-mutation-fence-token";
|
pub(crate) const QUOTA_MUTATION_FENCE_METADATA_SUFFIX: &str = "quota-mutation-fence-token";
|
||||||
|
|
||||||
@@ -171,11 +198,17 @@ pub struct MmapCopyStageMetrics {
|
|||||||
pub(crate) path_resolve_stage: &'static str,
|
pub(crate) path_resolve_stage: &'static str,
|
||||||
pub(crate) metadata_lookup_stage: &'static str,
|
pub(crate) metadata_lookup_stage: &'static str,
|
||||||
pub(crate) metadata_validate_stage: &'static str,
|
pub(crate) metadata_validate_stage: &'static str,
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) blocking_wait_stage: &'static str,
|
pub(crate) blocking_wait_stage: &'static str,
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) blocking_task_stage: &'static str,
|
pub(crate) blocking_task_stage: &'static str,
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) file_open_stage: &'static str,
|
pub(crate) file_open_stage: &'static str,
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) mmap_map_stage: &'static str,
|
pub(crate) mmap_map_stage: &'static str,
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) mmap_copy_stage: &'static str,
|
pub(crate) mmap_copy_stage: &'static str,
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) direct_read_copy_stage: &'static str,
|
pub(crate) direct_read_copy_stage: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,6 +732,25 @@ impl Disk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep local undo publication owned independently of the wrapper deadline.
|
||||||
|
/// Remote undo retains its existing RPC contract; this is not a remote drain proof.
|
||||||
|
pub(crate) async fn undo_write_with_namespace_owner(
|
||||||
|
&self,
|
||||||
|
volume: &str,
|
||||||
|
path: &str,
|
||||||
|
fi: FileInfo,
|
||||||
|
opts: DeleteOptions,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Local(disk) => {
|
||||||
|
disk.undo_write_with_namespace_owner(volume, path, fi, opts, namespace_owner)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Self::Remote(disk) => disk.delete_version(volume, path, fi, false, opts).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn rename_data_borrowed(
|
pub(crate) async fn rename_data_borrowed(
|
||||||
&self,
|
&self,
|
||||||
src_volume: &str,
|
src_volume: &str,
|
||||||
@@ -711,6 +763,36 @@ impl Disk {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn rename_data_borrowed_with_fence_observed(
|
||||||
|
&self,
|
||||||
|
src_volume: &str,
|
||||||
|
src_path: &str,
|
||||||
|
fi: &FileInfo,
|
||||||
|
dst_volume: &str,
|
||||||
|
dst_path: &str,
|
||||||
|
guards: RenameDataGuards,
|
||||||
|
) -> RenameDataObservation {
|
||||||
|
match self {
|
||||||
|
Disk::Local(local_disk) => {
|
||||||
|
local_disk
|
||||||
|
.rename_data_observed_with_guards(src_volume, src_path, fi, dst_volume, dst_path, guards)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Disk::Remote(remote_disk) => RenameDataObservation::unknown(
|
||||||
|
remote_disk
|
||||||
|
.rename_data_borrowed_with_fence(
|
||||||
|
src_volume,
|
||||||
|
src_path,
|
||||||
|
fi,
|
||||||
|
dst_volume,
|
||||||
|
dst_path,
|
||||||
|
guards.scanner_publication_lease_token,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn rename_data_borrowed_with_fence(
|
pub(crate) async fn rename_data_borrowed_with_fence(
|
||||||
&self,
|
&self,
|
||||||
src_volume: &str,
|
src_volume: &str,
|
||||||
@@ -873,6 +955,7 @@ impl Disk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) fn get_object_path_for_io_if_local(
|
pub(crate) fn get_object_path_for_io_if_local(
|
||||||
&self,
|
&self,
|
||||||
volume: &str,
|
volume: &str,
|
||||||
|
|||||||
+488
-15
@@ -91,6 +91,7 @@ pub(crate) mod fsync_dir_recorder {
|
|||||||
static RECORDED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
static RECORDED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||||
static LIMITED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
static LIMITED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||||
static GROUPED: Mutex<Vec<(PathBuf, usize)>> = Mutex::new(Vec::new());
|
static GROUPED: Mutex<Vec<(PathBuf, usize)>> = Mutex::new(Vec::new());
|
||||||
|
#[cfg(unix)]
|
||||||
static BEFORE_LIMITED: std::sync::LazyLock<Mutex<HashMap<PathBuf, Hook>>> =
|
static BEFORE_LIMITED: std::sync::LazyLock<Mutex<HashMap<PathBuf, Hook>>> =
|
||||||
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
static BEFORE_GROUP_BATCH: std::sync::LazyLock<Mutex<HashMap<PathBuf, Hook>>> =
|
static BEFORE_GROUP_BATCH: std::sync::LazyLock<Mutex<HashMap<PathBuf, Hook>>> =
|
||||||
@@ -150,6 +151,7 @@ pub(crate) mod fsync_dir_recorder {
|
|||||||
contains_path(&RECORDED.lock().expect("fsync dir recorder poisoned"), dir)
|
contains_path(&RECORDED.lock().expect("fsync dir recorder poisoned"), dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) fn record_limited(dir: &Path) {
|
pub(crate) fn record_limited(dir: &Path) {
|
||||||
record_path(&LIMITED, dir, "limited fsync dir recorder");
|
record_path(&LIMITED, dir, "limited fsync dir recorder");
|
||||||
let hook = remove_hook(&BEFORE_LIMITED, dir, "limited fsync hook poisoned");
|
let hook = remove_hook(&BEFORE_LIMITED, dir, "limited fsync hook poisoned");
|
||||||
@@ -162,6 +164,7 @@ pub(crate) mod fsync_dir_recorder {
|
|||||||
contains_path(&LIMITED.lock().expect("limited fsync dir recorder poisoned"), dir)
|
contains_path(&LIMITED.lock().expect("limited fsync dir recorder poisoned"), dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) fn set_before_limited(dir: &Path, hook: impl FnOnce() + Send + 'static) {
|
pub(crate) fn set_before_limited(dir: &Path, hook: impl FnOnce() + Send + 'static) {
|
||||||
BEFORE_LIMITED
|
BEFORE_LIMITED
|
||||||
.lock()
|
.lock()
|
||||||
@@ -237,11 +240,56 @@ pub(crate) mod fsync_dir_recorder {
|
|||||||
.insert(dir.to_path_buf(), kind);
|
.insert(dir.to_path_buf(), kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
pub(crate) fn take_grouped_failure(dir: &Path) -> Option<io::ErrorKind> {
|
pub(crate) fn take_grouped_failure(dir: &Path) -> Option<io::ErrorKind> {
|
||||||
remove_path_keyed(&GROUPED_FAILURES, dir, "grouped fsync failure hook poisoned")
|
remove_path_keyed(&GROUPED_FAILURES, dir, "grouped fsync failure hook poisoned")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pause a real namespace mutation inside its physical executor.
|
||||||
|
#[cfg(all(test, not(windows)))]
|
||||||
|
pub(crate) mod prepared_publication_test_hooks {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub(crate) enum Stage {
|
||||||
|
PreparedRename,
|
||||||
|
Rename,
|
||||||
|
Remove,
|
||||||
|
Rollback,
|
||||||
|
DirFsync,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hook = Box<dyn FnOnce() + Send>;
|
||||||
|
type Key = (Stage, PathBuf);
|
||||||
|
static BEFORE_PUBLICATION: LazyLock<Mutex<HashMap<Key, Hook>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
pub(crate) struct Guard(Key);
|
||||||
|
|
||||||
|
impl Drop for Guard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
BEFORE_PUBLICATION.lock().remove(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install(path: &Path, hook: impl FnOnce() + Send + 'static) -> Guard {
|
||||||
|
install_at(Stage::PreparedRename, path, hook)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_at(stage: Stage, path: &Path, hook: impl FnOnce() + Send + 'static) -> Guard {
|
||||||
|
let key = (stage, path.to_path_buf());
|
||||||
|
assert!(BEFORE_PUBLICATION.lock().insert(key.clone(), Box::new(hook)).is_none());
|
||||||
|
Guard(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn run(stage: Stage, path: &Path) {
|
||||||
|
let hook = BEFORE_PUBLICATION.lock().remove(&(stage, path.to_path_buf()));
|
||||||
|
if let Some(hook) = hook {
|
||||||
|
hook();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, windows))]
|
#[cfg(all(test, windows))]
|
||||||
pub(crate) mod windows_rename_test_hooks {
|
pub(crate) mod windows_rename_test_hooks {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -576,6 +624,7 @@ impl OpenedDstDirFsyncGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct DstDirFsyncWaiter {
|
struct DstDirFsyncWaiter {
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
result_tx: oneshot::Sender<SharedDstDirFsyncResult>,
|
result_tx: oneshot::Sender<SharedDstDirFsyncResult>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +683,7 @@ impl DstDirFsyncGroupCommit {
|
|||||||
fn enqueue_opened(
|
fn enqueue_opened(
|
||||||
&self,
|
&self,
|
||||||
opened: OpenedDstDirFsyncGroup,
|
opened: OpenedDstDirFsyncGroup,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
) -> io::Result<(oneshot::Receiver<SharedDstDirFsyncResult>, Option<Arc<DstDirFsyncGroup>>)> {
|
) -> io::Result<(oneshot::Receiver<SharedDstDirFsyncResult>, Option<Arc<DstDirFsyncGroup>>)> {
|
||||||
let (result_tx, result_rx) = oneshot::channel();
|
let (result_tx, result_rx) = oneshot::channel();
|
||||||
let mut registry = self.inner.lock();
|
let mut registry = self.inner.lock();
|
||||||
@@ -664,7 +714,10 @@ impl DstDirFsyncGroupCommit {
|
|||||||
group
|
group
|
||||||
};
|
};
|
||||||
let mut group_state = group.inner.lock();
|
let mut group_state = group.inner.lock();
|
||||||
group_state.pending.push_back(DstDirFsyncWaiter { result_tx });
|
group_state.pending.push_back(DstDirFsyncWaiter {
|
||||||
|
result_tx,
|
||||||
|
namespace_owner,
|
||||||
|
});
|
||||||
let start_worker = !group_state.worker_running;
|
let start_worker = !group_state.worker_running;
|
||||||
if start_worker {
|
if start_worker {
|
||||||
group_state.worker_running = true;
|
group_state.worker_running = true;
|
||||||
@@ -686,7 +739,13 @@ impl DstDirFsyncGroupCommit {
|
|||||||
fn remove_idle_group(&self, group: &Arc<DstDirFsyncGroup>) {
|
fn remove_idle_group(&self, group: &Arc<DstDirFsyncGroup>) {
|
||||||
let mut registry = self.inner.lock();
|
let mut registry = self.inner.lock();
|
||||||
let group_state = group.inner.lock();
|
let group_state = group.inner.lock();
|
||||||
if !group_state.worker_running && group_state.pending.is_empty() {
|
if !group_state.worker_running
|
||||||
|
&& group_state.pending.is_empty()
|
||||||
|
&& registry
|
||||||
|
.groups
|
||||||
|
.get(&group.key)
|
||||||
|
.is_some_and(|registered| Arc::ptr_eq(registered, group))
|
||||||
|
{
|
||||||
registry.groups.remove(&group.key);
|
registry.groups.remove(&group.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,16 +768,20 @@ impl DstDirFsyncGroupCommit {
|
|||||||
&self,
|
&self,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
) -> io::Result<(oneshot::Receiver<SharedDstDirFsyncResult>, Option<Arc<DstDirFsyncGroup>>)> {
|
) -> io::Result<(oneshot::Receiver<SharedDstDirFsyncResult>, Option<Arc<DstDirFsyncGroup>>)> {
|
||||||
self.enqueue_opened(OpenedDstDirFsyncGroup::open(dir)?)
|
self.enqueue_opened(OpenedDstDirFsyncGroup::open(dir)?, None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup) -> io::Result<()> {
|
async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup, namespace_owners: Vec<Arc<dyn Send + Sync>>) -> io::Result<()> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
let dir = group.dir.clone();
|
let dir = group.dir.clone();
|
||||||
let dir_file = group.dir_file.clone();
|
let dir_file = group.dir_file.clone();
|
||||||
fsync_spawn_blocking(move || {
|
fsync_spawn_blocking(move || {
|
||||||
|
// The batch worker may be cancelled while this syscall is still running.
|
||||||
|
let _namespace_owners = namespace_owners;
|
||||||
|
#[cfg(all(test, not(windows)))]
|
||||||
|
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::DirFsync, &dir);
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
{
|
{
|
||||||
if let Some(kind) = fsync_dir_recorder::take_grouped_failure(&dir) {
|
if let Some(kind) = fsync_dir_recorder::take_grouped_failure(&dir) {
|
||||||
@@ -733,38 +796,85 @@ async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup) -> io::Result<()> {
|
async fn fsync_open_dst_dir_group(group: &DstDirFsyncGroup, namespace_owners: Vec<Arc<dyn Send + Sync>>) -> io::Result<()> {
|
||||||
|
let _namespace_owners = namespace_owners;
|
||||||
fsync_dir(&group.dir).await
|
fsync_dir(&group.dir).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_dst_dir_fsync_group_worker(group: Arc<DstDirFsyncGroup>) {
|
struct DstDirFsyncWorkerGuard {
|
||||||
|
group: Arc<DstDirFsyncGroup>,
|
||||||
|
in_flight: usize,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DstDirFsyncWorkerGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.armed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cancellation must release queued owners, but the physical batch keeps
|
||||||
|
// its own owners until its blocking syscall returns.
|
||||||
|
let pending = {
|
||||||
|
let mut registry = DST_DIR_FSYNC_GROUP_COMMIT.inner.lock();
|
||||||
|
let mut group_state = self.group.inner.lock();
|
||||||
|
let pending = std::mem::take(&mut group_state.pending);
|
||||||
|
group_state.worker_running = false;
|
||||||
|
if registry
|
||||||
|
.groups
|
||||||
|
.get(&self.group.key)
|
||||||
|
.is_some_and(|group| Arc::ptr_eq(group, &self.group))
|
||||||
|
{
|
||||||
|
registry.total_waiters = registry.total_waiters.saturating_sub(pending.len() + self.in_flight);
|
||||||
|
registry.groups.remove(&self.group.key);
|
||||||
|
}
|
||||||
|
pending
|
||||||
|
};
|
||||||
|
// Lease and channel destructors must run outside the registry locks.
|
||||||
|
drop(pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_dst_dir_fsync_group_worker(group: Arc<DstDirFsyncGroup>) -> impl std::future::Future<Output = ()> {
|
||||||
|
// Capture before spawning: shutdown may drop the future without polling it.
|
||||||
|
let worker_guard = DstDirFsyncWorkerGuard {
|
||||||
|
group: group.clone(),
|
||||||
|
in_flight: 0,
|
||||||
|
armed: true,
|
||||||
|
};
|
||||||
|
async move {
|
||||||
|
let mut worker_guard = worker_guard;
|
||||||
loop {
|
loop {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fsync_dir_recorder::run_before_group_batch(&group.dir);
|
fsync_dir_recorder::run_before_group_batch(&group.dir);
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
let batch: Vec<DstDirFsyncWaiter> = {
|
let mut batch: Vec<DstDirFsyncWaiter> = {
|
||||||
let mut group_state = group.inner.lock();
|
let mut group_state = group.inner.lock();
|
||||||
group_state.pending.drain(..).collect()
|
group_state.pending.drain(..).collect()
|
||||||
};
|
};
|
||||||
if batch.is_empty() {
|
if batch.is_empty() {
|
||||||
let mut group_state = group.inner.lock();
|
let mut group_state = group.inner.lock();
|
||||||
|
worker_guard.armed = false;
|
||||||
group_state.worker_running = false;
|
group_state.worker_running = false;
|
||||||
drop(group_state);
|
drop(group_state);
|
||||||
DST_DIR_FSYNC_GROUP_COMMIT.remove_idle_group(&group);
|
DST_DIR_FSYNC_GROUP_COMMIT.remove_idle_group(&group);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
worker_guard.in_flight = batch.len();
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fsync_dir_recorder::record_grouped(&group.dir, batch.len());
|
fsync_dir_recorder::record_grouped(&group.dir, batch.len());
|
||||||
let result = fsync_open_dst_dir_group(&group)
|
let namespace_owners = batch.iter_mut().filter_map(|waiter| waiter.namespace_owner.take()).collect();
|
||||||
|
let result = fsync_open_dst_dir_group(&group, namespace_owners)
|
||||||
.await
|
.await
|
||||||
.map_err(SharedDstDirFsyncError::from_error);
|
.map_err(SharedDstDirFsyncError::from_error);
|
||||||
let batch_len = batch.len();
|
let batch_len = batch.len();
|
||||||
DST_DIR_FSYNC_GROUP_COMMIT.complete_batch(batch_len);
|
DST_DIR_FSYNC_GROUP_COMMIT.complete_batch(batch_len);
|
||||||
|
worker_guard.in_flight = 0;
|
||||||
|
|
||||||
let should_stop = {
|
let should_stop = {
|
||||||
let mut group_state = group.inner.lock();
|
let mut group_state = group.inner.lock();
|
||||||
if group_state.pending.is_empty() {
|
if group_state.pending.is_empty() {
|
||||||
|
worker_guard.armed = false;
|
||||||
group_state.worker_running = false;
|
group_state.worker_running = false;
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
@@ -781,18 +891,23 @@ async fn run_dst_dir_fsync_group_worker(group: Arc<DstDirFsyncGroup>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fsync_dst_dir_group_commit_with_enabled(dir: impl AsRef<Path>, enabled: bool) -> io::Result<()> {
|
async fn fsync_dst_dir_group_commit_with_enabled(
|
||||||
|
dir: impl AsRef<Path>,
|
||||||
|
enabled: bool,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> io::Result<()> {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return fsync_dir(dir).await;
|
return fsync_dir_with_owner(dir.as_ref(), namespace_owner).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dir = dir.as_ref().to_path_buf();
|
let dir = dir.as_ref().to_path_buf();
|
||||||
let opened = tokio::task::spawn_blocking(move || OpenedDstDirFsyncGroup::open(&dir))
|
let opened = tokio::task::spawn_blocking(move || OpenedDstDirFsyncGroup::open(&dir))
|
||||||
.await
|
.await
|
||||||
.map_err(|err| io::Error::other(format!("blocking dst dir group open failed: {err}")))??;
|
.map_err(|err| io::Error::other(format!("blocking dst dir group open failed: {err}")))??;
|
||||||
let (result_rx, worker) = DST_DIR_FSYNC_GROUP_COMMIT.enqueue_opened(opened)?;
|
let (result_rx, worker) = DST_DIR_FSYNC_GROUP_COMMIT.enqueue_opened(opened, namespace_owner)?;
|
||||||
if let Some(group) = worker {
|
if let Some(group) = worker {
|
||||||
tokio::spawn(run_dst_dir_fsync_group_worker(group));
|
tokio::spawn(run_dst_dir_fsync_group_worker(group));
|
||||||
}
|
}
|
||||||
@@ -804,8 +919,11 @@ async fn fsync_dst_dir_group_commit_with_enabled(dir: impl AsRef<Path>, enabled:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn fsync_dst_dir_group_commit(dir: impl AsRef<Path>) -> io::Result<()> {
|
pub(crate) async fn fsync_dst_dir_group_commit(
|
||||||
fsync_dst_dir_group_commit_with_enabled(dir, dst_dir_fsync_group_commit_enabled()).await
|
dir: impl AsRef<Path>,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
fsync_dst_dir_group_commit_with_enabled(dir, dst_dir_fsync_group_commit_enabled(), namespace_owner).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn fsync_dst_dir_group_commit_or_namespace_file_sync_limit(
|
pub(crate) async fn fsync_dst_dir_group_commit_or_namespace_file_sync_limit(
|
||||||
@@ -814,7 +932,7 @@ pub(crate) async fn fsync_dst_dir_group_commit_or_namespace_file_sync_limit(
|
|||||||
admission: &FileSyncAdmission,
|
admission: &FileSyncAdmission,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
if dst_dir_fsync_group_commit_enabled() {
|
if dst_dir_fsync_group_commit_enabled() {
|
||||||
fsync_dst_dir_group_commit_with_enabled(dir, true).await
|
fsync_dst_dir_group_commit_with_enabled(dir, true, Some(lease)).await
|
||||||
} else {
|
} else {
|
||||||
fsync_dir_with_namespace_file_sync_limit(dir, lease, admission).await
|
fsync_dir_with_namespace_file_sync_limit(dir, lease, admission).await
|
||||||
}
|
}
|
||||||
@@ -822,7 +940,7 @@ pub(crate) async fn fsync_dst_dir_group_commit_or_namespace_file_sync_limit(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) async fn fsync_dst_dir_group_commit_for_test(dir: impl AsRef<Path>, enabled: bool) -> io::Result<()> {
|
pub(crate) async fn fsync_dst_dir_group_commit_for_test(dir: impl AsRef<Path>, enabled: bool) -> io::Result<()> {
|
||||||
fsync_dst_dir_group_commit_with_enabled(dir, enabled).await
|
fsync_dst_dir_group_commit_with_enabled(dir, enabled, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1229,6 +1347,8 @@ pub(crate) struct NamespaceMutationLease {
|
|||||||
_namespace_guard: OwnedMutexGuard<()>,
|
_namespace_guard: OwnedMutexGuard<()>,
|
||||||
_volume_guard: Option<OwnedRwLockReadGuard<()>>,
|
_volume_guard: Option<OwnedRwLockReadGuard<()>>,
|
||||||
external_guard: Mutex<Option<Arc<dyn Send + Sync>>>,
|
external_guard: Mutex<Option<Arc<dyn Send + Sync>>>,
|
||||||
|
// Independent of the quota claim; both survive cancellation of the waiter.
|
||||||
|
_namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NamespaceMutationLease {
|
impl NamespaceMutationLease {
|
||||||
@@ -1238,10 +1358,18 @@ impl NamespaceMutationLease {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn acquire_namespace_mutation_lease(path: &Path) -> Arc<NamespaceMutationLease> {
|
async fn acquire_namespace_mutation_lease(path: &Path) -> Arc<NamespaceMutationLease> {
|
||||||
|
acquire_namespace_mutation_lease_with_owner(path, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn acquire_namespace_mutation_lease_with_owner(
|
||||||
|
path: &Path,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> Arc<NamespaceMutationLease> {
|
||||||
Arc::new(NamespaceMutationLease {
|
Arc::new(NamespaceMutationLease {
|
||||||
_namespace_guard: disk_namespace_mutation_lock(path).lock_owned().await,
|
_namespace_guard: disk_namespace_mutation_lock(path).lock_owned().await,
|
||||||
_volume_guard: None,
|
_volume_guard: None,
|
||||||
external_guard: Mutex::new(None),
|
external_guard: Mutex::new(None),
|
||||||
|
_namespace_owner: namespace_owner,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1251,6 +1379,15 @@ pub(crate) async fn acquire_rename_data_mutation_lease(
|
|||||||
root: &Path,
|
root: &Path,
|
||||||
volume: &str,
|
volume: &str,
|
||||||
destination_object: &Path,
|
destination_object: &Path,
|
||||||
|
) -> Arc<NamespaceMutationLease> {
|
||||||
|
acquire_rename_data_mutation_lease_with_owner(root, volume, destination_object, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn acquire_rename_data_mutation_lease_with_owner(
|
||||||
|
root: &Path,
|
||||||
|
volume: &str,
|
||||||
|
destination_object: &Path,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
) -> Arc<NamespaceMutationLease> {
|
) -> Arc<NamespaceMutationLease> {
|
||||||
let namespace_guard = disk_namespace_mutation_lock(destination_object).lock_owned().await;
|
let namespace_guard = disk_namespace_mutation_lock(destination_object).lock_owned().await;
|
||||||
let volume_guard = disk_volume_mutation_lock(root, volume).read_owned().await;
|
let volume_guard = disk_volume_mutation_lock(root, volume).read_owned().await;
|
||||||
@@ -1258,6 +1395,7 @@ pub(crate) async fn acquire_rename_data_mutation_lease(
|
|||||||
_namespace_guard: namespace_guard,
|
_namespace_guard: namespace_guard,
|
||||||
_volume_guard: Some(volume_guard),
|
_volume_guard: Some(volume_guard),
|
||||||
external_guard: Mutex::new(None),
|
external_guard: Mutex::new(None),
|
||||||
|
_namespace_owner: namespace_owner,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1747,6 +1885,69 @@ pub async fn rename_all(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn fsync_dir_with_owner(path: &Path, namespace_owner: Option<Arc<dyn Send + Sync>>) -> io::Result<()> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
if namespace_owner.is_none() {
|
||||||
|
return fsync_dir(path).await;
|
||||||
|
}
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
fsync_spawn_blocking(move || {
|
||||||
|
let _namespace_owner = namespace_owner;
|
||||||
|
fsync_dir_std(path)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = namespace_owner;
|
||||||
|
fsync_dir(path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retain namespace ownership in the actual filesystem executor after timeout.
|
||||||
|
pub(crate) async fn remove_file_with_owner(
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if namespace_owner.is_none() {
|
||||||
|
return tokio::fs::remove_file(path).await;
|
||||||
|
}
|
||||||
|
let path = path.as_ref().to_path_buf();
|
||||||
|
let lease = acquire_namespace_mutation_lease_with_owner(&path, namespace_owner).await;
|
||||||
|
run_blocking_namespace_operation(lease, move || {
|
||||||
|
#[cfg(all(test, not(windows)))]
|
||||||
|
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Remove, &path);
|
||||||
|
std::fs::remove_file(path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retain namespace ownership in the actual filesystem executor after timeout.
|
||||||
|
pub(crate) async fn remove_dir_with_owner(
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if namespace_owner.is_none() {
|
||||||
|
return tokio::fs::remove_dir(path).await;
|
||||||
|
}
|
||||||
|
let path = path.as_ref().to_path_buf();
|
||||||
|
let lease = acquire_namespace_mutation_lease_with_owner(&path, namespace_owner).await;
|
||||||
|
run_blocking_namespace_operation(lease, move || std::fs::remove_dir(path)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "rename_all", level = "debug", skip_all)]
|
||||||
|
pub(crate) async fn rename_all_with_owner(
|
||||||
|
src_file_path: impl AsRef<Path>,
|
||||||
|
dst_file_path: impl AsRef<Path>,
|
||||||
|
base_dir: impl AsRef<Path>,
|
||||||
|
publication_root: &PublicationRoot,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let lease = acquire_namespace_mutation_lease_with_owner(dst_file_path.as_ref(), namespace_owner).await;
|
||||||
|
rename_all_with_lease(src_file_path, dst_file_path, base_dir, publication_root, lease).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn rename_all_with_lease(
|
pub(crate) async fn rename_all_with_lease(
|
||||||
src_file_path: impl AsRef<Path>,
|
src_file_path: impl AsRef<Path>,
|
||||||
dst_file_path: impl AsRef<Path>,
|
dst_file_path: impl AsRef<Path>,
|
||||||
@@ -1939,6 +2140,8 @@ pub(crate) async fn rename_all_with_prepared_source(
|
|||||||
move || {
|
move || {
|
||||||
validate_prepared_rename_source(&prepared_source, &src_file_path)?;
|
validate_prepared_rename_source(&prepared_source, &src_file_path)?;
|
||||||
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
|
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
|
||||||
|
#[cfg(test)]
|
||||||
|
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::PreparedRename, &dst_file_path);
|
||||||
rename_prepared(&src_file_path, &dst_file_path, &preparation)
|
rename_prepared(&src_file_path, &dst_file_path, &preparation)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1977,6 +2180,32 @@ pub async fn rename_all_ignore_missing_source(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "rename_all_ignore_missing_source", level = "debug", skip_all)]
|
||||||
|
pub(crate) async fn rename_all_ignore_missing_source_with_owner(
|
||||||
|
src_file_path: impl AsRef<Path>,
|
||||||
|
dst_file_path: impl AsRef<Path>,
|
||||||
|
base_dir: impl AsRef<Path>,
|
||||||
|
publication_root: &PublicationRoot,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let src_file_path = src_file_path.as_ref();
|
||||||
|
let lease = acquire_namespace_mutation_lease_with_owner(dst_file_path.as_ref(), namespace_owner).await;
|
||||||
|
match reliable_rename_inner_with_lease(
|
||||||
|
src_file_path.to_path_buf(),
|
||||||
|
dst_file_path.as_ref().to_path_buf(),
|
||||||
|
base_dir.as_ref().to_path_buf(),
|
||||||
|
publication_root.clone(),
|
||||||
|
false,
|
||||||
|
lease,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound && rename_source_is_missing(src_file_path, publication_root) => Ok(()),
|
||||||
|
Err(err) => Err(to_file_error(err).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub(crate) fn rename_source_is_missing(src_file_path: &Path, publication_root: &PublicationRoot) -> bool {
|
pub(crate) fn rename_source_is_missing(src_file_path: &Path, publication_root: &PublicationRoot) -> bool {
|
||||||
let Some(source_parent) = src_file_path.parent() else {
|
let Some(source_parent) = src_file_path.parent() else {
|
||||||
@@ -2042,6 +2271,11 @@ async fn reliable_rename_inner_with_lease(
|
|||||||
let base_dir = base_dir.clone();
|
let base_dir = base_dir.clone();
|
||||||
move || {
|
move || {
|
||||||
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
|
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
|
||||||
|
#[cfg(all(test, not(windows)))]
|
||||||
|
{
|
||||||
|
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &src_file_path);
|
||||||
|
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &dst_file_path);
|
||||||
|
}
|
||||||
rename_prepared(&src_file_path, &dst_file_path, &preparation)
|
rename_prepared(&src_file_path, &dst_file_path, &preparation)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -6136,6 +6370,245 @@ mod tests {
|
|||||||
wait_for_dst_dir_fsync_group_commit_idle().await;
|
wait_for_dst_dir_fsync_group_commit_idle().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(dst_dir_fsync_group_commit)]
|
||||||
|
async fn grouped_fsync_physical_batch_keeps_all_owners_after_worker_cancellation() {
|
||||||
|
let temp_dir = tempdir().expect("fixture directory");
|
||||||
|
let dir = temp_dir.path().canonicalize().expect("canonical fsync path");
|
||||||
|
let first_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||||
|
let second_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||||
|
let first_owner = first_ctx.begin_namespace_commit();
|
||||||
|
let second_owner = second_ctx.begin_namespace_commit();
|
||||||
|
let first_probe = Arc::downgrade(&first_owner);
|
||||||
|
let second_probe = Arc::downgrade(&second_owner);
|
||||||
|
let (first_rx, group) = DST_DIR_FSYNC_GROUP_COMMIT
|
||||||
|
.enqueue_opened(
|
||||||
|
OpenedDstDirFsyncGroup::open(&dir).expect("open first waiter directory"),
|
||||||
|
Some(first_owner),
|
||||||
|
)
|
||||||
|
.expect("queue first real waiter");
|
||||||
|
let group = group.expect("first waiter starts the group");
|
||||||
|
let (second_rx, second_worker) = DST_DIR_FSYNC_GROUP_COMMIT
|
||||||
|
.enqueue_opened(
|
||||||
|
OpenedDstDirFsyncGroup::open(&dir).expect("open second waiter directory"),
|
||||||
|
Some(second_owner),
|
||||||
|
)
|
||||||
|
.expect("queue second real waiter");
|
||||||
|
assert!(second_worker.is_none(), "same directory must join the same batch");
|
||||||
|
assert_eq!(group.inner.lock().pending.len(), 2);
|
||||||
|
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
let _hook =
|
||||||
|
prepared_publication_test_hooks::install_at(prepared_publication_test_hooks::Stage::DirFsync, &dir, move || {
|
||||||
|
let _ = entered_tx.send(());
|
||||||
|
let _ = release_rx.recv();
|
||||||
|
});
|
||||||
|
let worker = tokio::spawn(run_dst_dir_fsync_group_worker(group.clone()));
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), entered_rx)
|
||||||
|
.await
|
||||||
|
.expect("batch must reach its physical fsync")
|
||||||
|
.expect("physical fsync entry");
|
||||||
|
assert_eq!(fsync_dir_recorder::grouped_batch_sizes(&dir), vec![2]);
|
||||||
|
assert!(
|
||||||
|
group.inner.lock().pending.is_empty(),
|
||||||
|
"both waiters were transferred into the physical batch"
|
||||||
|
);
|
||||||
|
let queued_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||||
|
let queued_owner = queued_ctx.begin_namespace_commit();
|
||||||
|
let queued_probe = Arc::downgrade(&queued_owner);
|
||||||
|
let queued_generation = queued_ctx.namespace_commit_generation();
|
||||||
|
let (queued_rx, queued_worker) = DST_DIR_FSYNC_GROUP_COMMIT
|
||||||
|
.enqueue_opened(
|
||||||
|
OpenedDstDirFsyncGroup::open(&dir).expect("open queued waiter directory"),
|
||||||
|
Some(queued_owner),
|
||||||
|
)
|
||||||
|
.expect("queue a waiter after the physical batch was frozen");
|
||||||
|
assert!(queued_worker.is_none());
|
||||||
|
assert_eq!(group.inner.lock().pending.len(), 1);
|
||||||
|
drop((first_rx, second_rx));
|
||||||
|
worker.abort();
|
||||||
|
assert!(worker.await.expect_err("cancel the async batch owner").is_cancelled());
|
||||||
|
assert!(queued_rx.await.is_err(), "an undispatched waiter must observe worker cancellation");
|
||||||
|
assert!(queued_probe.upgrade().is_none());
|
||||||
|
assert!(!queued_ctx.namespace_commits_pending());
|
||||||
|
assert!(queued_ctx.namespace_commit_generation() > queued_generation);
|
||||||
|
assert!(group.inner.lock().pending.is_empty());
|
||||||
|
assert!(!group.inner.lock().worker_running);
|
||||||
|
assert_eq!(DST_DIR_FSYNC_GROUP_COMMIT.counts_for_test(), (0, 0));
|
||||||
|
let first_pending = first_ctx.namespace_commits_pending() && first_probe.upgrade().is_some();
|
||||||
|
let second_pending = second_ctx.namespace_commits_pending() && second_probe.upgrade().is_some();
|
||||||
|
let generations = (first_ctx.namespace_commit_generation(), second_ctx.namespace_commit_generation());
|
||||||
|
drop(release_tx);
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
while Arc::strong_count(&group.dir_file) != 1
|
||||||
|
|| first_probe.upgrade().is_some()
|
||||||
|
|| second_probe.upgrade().is_some()
|
||||||
|
|| first_ctx.namespace_commits_pending()
|
||||||
|
|| second_ctx.namespace_commits_pending()
|
||||||
|
{
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("physical fsync must release every batch owner");
|
||||||
|
assert!(fsync_dir_recorder::was_fsynced(&dir), "the detached syscall must really execute");
|
||||||
|
assert!(
|
||||||
|
first_pending && second_pending,
|
||||||
|
"one physical batch must preserve both independent namespace owners"
|
||||||
|
);
|
||||||
|
assert!(!first_ctx.namespace_commits_pending());
|
||||||
|
assert!(!second_ctx.namespace_commits_pending());
|
||||||
|
assert!(first_ctx.namespace_commit_generation() > generations.0);
|
||||||
|
assert!(second_ctx.namespace_commit_generation() > generations.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(dst_dir_fsync_group_commit)]
|
||||||
|
async fn grouped_fsync_unpolled_worker_releases_queued_owner() {
|
||||||
|
let temp_dir = tempdir().expect("fixture directory");
|
||||||
|
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||||
|
let owner = ctx.begin_namespace_commit();
|
||||||
|
let probe = Arc::downgrade(&owner);
|
||||||
|
let generation = ctx.namespace_commit_generation();
|
||||||
|
let (rx, group) = DST_DIR_FSYNC_GROUP_COMMIT
|
||||||
|
.enqueue_opened(
|
||||||
|
OpenedDstDirFsyncGroup::open(temp_dir.path()).expect("open queued waiter directory"),
|
||||||
|
Some(owner),
|
||||||
|
)
|
||||||
|
.expect("queue a real waiter");
|
||||||
|
let group = group.expect("first waiter starts the group");
|
||||||
|
let worker = run_dst_dir_fsync_group_worker(group.clone());
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
drop(worker);
|
||||||
|
assert!(rx.await.is_err(), "shutdown before first poll must release the waiter");
|
||||||
|
assert!(probe.upgrade().is_none());
|
||||||
|
assert!(!ctx.namespace_commits_pending());
|
||||||
|
assert!(ctx.namespace_commit_generation() > generation);
|
||||||
|
assert!(group.inner.lock().pending.is_empty());
|
||||||
|
assert!(!group.inner.lock().worker_running);
|
||||||
|
assert_eq!(DST_DIR_FSYNC_GROUP_COMMIT.counts_for_test(), (0, 0));
|
||||||
|
assert!(
|
||||||
|
fsync_dir_recorder::grouped_batch_sizes(temp_dir.path()).is_empty(),
|
||||||
|
"the dropped future must not dispatch a physical batch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn stale_idle_group_cleanup_preserves_successor_registration() {
|
||||||
|
let temp_dir = tempdir().expect("fixture directory");
|
||||||
|
let registry = DstDirFsyncGroupCommit::default();
|
||||||
|
let (mut first_rx, first_worker) = registry.enqueue_for_test(temp_dir.path()).expect("enqueue first worker");
|
||||||
|
let old_group = first_worker.expect("first waiter starts a worker");
|
||||||
|
// W1 has completed its batch and marked G idle, but has not cleaned G up.
|
||||||
|
let first_waiter = old_group.inner.lock().pending.pop_front().expect("first batch waiter");
|
||||||
|
registry.complete_batch(1);
|
||||||
|
old_group.inner.lock().worker_running = false;
|
||||||
|
|
||||||
|
let (mut second_rx, second_worker) = registry.enqueue_for_test(temp_dir.path()).expect("enqueue second worker");
|
||||||
|
let reused_group = second_worker.expect("idle G starts another worker");
|
||||||
|
assert!(Arc::ptr_eq(&old_group, &reused_group));
|
||||||
|
let second_waiter = reused_group.inner.lock().pending.pop_front().expect("second batch waiter");
|
||||||
|
registry.complete_batch(1);
|
||||||
|
reused_group.inner.lock().worker_running = false;
|
||||||
|
registry.remove_idle_group(&reused_group);
|
||||||
|
assert_eq!(registry.counts_for_test(), (0, 0), "normal idle cleanup must remove G");
|
||||||
|
assert!(second_waiter.result_tx.send(Ok(())).is_ok());
|
||||||
|
assert!(second_rx.try_recv().expect("second worker reports completion").is_ok());
|
||||||
|
|
||||||
|
let (mut successor_rx, successor_worker) = registry.enqueue_for_test(temp_dir.path()).expect("enqueue successor");
|
||||||
|
let successor = successor_worker.expect("successor starts a new group");
|
||||||
|
assert!(!Arc::ptr_eq(&old_group, &successor));
|
||||||
|
assert_eq!(registry.counts_for_test(), (1, 1));
|
||||||
|
// W1 resumes with its old Arc after W2 removed G and W3 installed G2.
|
||||||
|
registry.remove_idle_group(&old_group);
|
||||||
|
assert!(first_waiter.result_tx.send(Ok(())).is_ok());
|
||||||
|
assert!(first_rx.try_recv().expect("first worker reports completion").is_ok());
|
||||||
|
assert!(
|
||||||
|
registry
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.groups
|
||||||
|
.get(&successor.key)
|
||||||
|
.is_some_and(|registered| Arc::ptr_eq(registered, &successor)),
|
||||||
|
"stale cleanup must retain the exact successor Arc"
|
||||||
|
);
|
||||||
|
assert_eq!(registry.counts_for_test(), (1, 1));
|
||||||
|
assert!(successor.inner.lock().worker_running);
|
||||||
|
assert_eq!(successor.inner.lock().pending.len(), 1);
|
||||||
|
assert!(matches!(successor_rx.try_recv(), Err(oneshot::error::TryRecvError::Empty)));
|
||||||
|
|
||||||
|
let (_joined_rx, new_worker) = registry.enqueue_for_test(temp_dir.path()).expect("join successor");
|
||||||
|
assert!(new_worker.is_none(), "a later waiter must join G2 instead of creating G3");
|
||||||
|
assert_eq!(successor.inner.lock().pending.len(), 2);
|
||||||
|
assert_eq!(registry.counts_for_test(), (1, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial(dst_dir_fsync_group_commit)]
|
||||||
|
async fn stale_idle_cleanup_then_unpolled_worker_drop_releases_waiter_budget() {
|
||||||
|
wait_for_dst_dir_fsync_group_commit_idle().await;
|
||||||
|
let temp_dir = tempdir().expect("fixture directory");
|
||||||
|
let (old_rx, old_worker) = DST_DIR_FSYNC_GROUP_COMMIT
|
||||||
|
.enqueue_for_test(temp_dir.path())
|
||||||
|
.expect("enqueue old group");
|
||||||
|
let old_group = old_worker.expect("old group starts a worker");
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), run_dst_dir_fsync_group_worker(old_group.clone()))
|
||||||
|
.await
|
||||||
|
.expect("old worker must finish its actual fsync");
|
||||||
|
assert!(old_rx.await.expect("old worker reports completion").is_ok());
|
||||||
|
assert!(fsync_dir_recorder::was_fsynced(temp_dir.path()));
|
||||||
|
assert_eq!(fsync_dir_recorder::grouped_batch_sizes(temp_dir.path()), vec![1]);
|
||||||
|
assert_eq!(DST_DIR_FSYNC_GROUP_COMMIT.counts_for_test(), (0, 0));
|
||||||
|
|
||||||
|
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||||
|
let owner = ctx.begin_namespace_commit();
|
||||||
|
let probe = Arc::downgrade(&owner);
|
||||||
|
let generation = ctx.namespace_commit_generation();
|
||||||
|
let (rx, successor_worker) = DST_DIR_FSYNC_GROUP_COMMIT
|
||||||
|
.enqueue_opened(
|
||||||
|
OpenedDstDirFsyncGroup::open(temp_dir.path()).expect("open successor directory"),
|
||||||
|
Some(owner),
|
||||||
|
)
|
||||||
|
.expect("enqueue successor owner");
|
||||||
|
let successor = successor_worker.expect("successor starts a new group");
|
||||||
|
assert!(!Arc::ptr_eq(&old_group, &successor));
|
||||||
|
let worker = run_dst_dir_fsync_group_worker(successor.clone());
|
||||||
|
// The stale Arc represents W1 resuming after another worker removed G.
|
||||||
|
DST_DIR_FSYNC_GROUP_COMMIT.remove_idle_group(&old_group);
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert!(probe.upgrade().is_some());
|
||||||
|
drop(worker);
|
||||||
|
let channel_closed = tokio::time::timeout(Duration::from_secs(5), rx)
|
||||||
|
.await
|
||||||
|
.expect("dropping the unpolled worker must release its channel")
|
||||||
|
.is_err();
|
||||||
|
let counts_after_drop = DST_DIR_FSYNC_GROUP_COMMIT.counts_for_test();
|
||||||
|
let owner_released = probe.upgrade().is_none();
|
||||||
|
let namespace_pending = ctx.namespace_commits_pending();
|
||||||
|
let generation_after_drop = ctx.namespace_commit_generation();
|
||||||
|
let successor_pending = successor.inner.lock().pending.len();
|
||||||
|
let worker_running = successor.inner.lock().worker_running;
|
||||||
|
// Preserve the observed result before cleanup, so a RED run cannot leak
|
||||||
|
// its phantom count into unrelated tests in the same process.
|
||||||
|
clear_dst_dir_fsync_group_commit_for_test();
|
||||||
|
assert!(channel_closed);
|
||||||
|
assert!(owner_released);
|
||||||
|
assert!(!namespace_pending);
|
||||||
|
assert!(generation_after_drop > generation);
|
||||||
|
assert_eq!(successor_pending, 0);
|
||||||
|
assert!(!worker_running);
|
||||||
|
assert_eq!(
|
||||||
|
fsync_dir_recorder::grouped_batch_sizes(temp_dir.path()),
|
||||||
|
vec![1],
|
||||||
|
"dropping the successor before its first poll must not dispatch another fsync"
|
||||||
|
);
|
||||||
|
assert_eq!(counts_after_drop, (0, 0), "stale cleanup must not strand a phantom waiter");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
#[serial_test::serial(dst_dir_fsync_group_commit)]
|
#[serial_test::serial(dst_dir_fsync_group_commit)]
|
||||||
async fn dst_dir_fsync_group_commit_cancellation_releases_waiter_state() {
|
async fn dst_dir_fsync_group_commit_cancellation_releases_waiter_state() {
|
||||||
|
|||||||
@@ -13,12 +13,15 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::diagnostics::get::{
|
use crate::diagnostics::get::{
|
||||||
GET_STAGE_READER_MMAP_ACCESS_CHECK, GET_STAGE_READER_MMAP_BLOCKING_TASK, GET_STAGE_READER_MMAP_BLOCKING_WAIT,
|
GET_STAGE_READER_MMAP_ACCESS_CHECK, GET_STAGE_READER_MMAP_METADATA_LOOKUP, GET_STAGE_READER_MMAP_METADATA_VALIDATE,
|
||||||
GET_STAGE_READER_MMAP_COPY_BUFFER, GET_STAGE_READER_MMAP_DIRECT_READ_COPY, GET_STAGE_READER_MMAP_FILE_OPEN,
|
|
||||||
GET_STAGE_READER_MMAP_MAP, GET_STAGE_READER_MMAP_METADATA_LOOKUP, GET_STAGE_READER_MMAP_METADATA_VALIDATE,
|
|
||||||
GET_STAGE_READER_MMAP_PATH_RESOLVE, GET_STAGE_READER_OPEN_MMAP_COPY_FALLBACK, GET_STAGE_READER_OPEN_MMAP_COPY_SUCCESS,
|
GET_STAGE_READER_MMAP_PATH_RESOLVE, GET_STAGE_READER_OPEN_MMAP_COPY_FALLBACK, GET_STAGE_READER_OPEN_MMAP_COPY_SUCCESS,
|
||||||
GET_STAGE_READER_OPEN_STREAM, GET_STAGE_READER_STREAM_FIRST_READ, record_get_stage_duration_if_enabled,
|
GET_STAGE_READER_OPEN_STREAM, GET_STAGE_READER_STREAM_FIRST_READ, record_get_stage_duration_if_enabled,
|
||||||
};
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
|
use crate::diagnostics::get::{
|
||||||
|
GET_STAGE_READER_MMAP_BLOCKING_TASK, GET_STAGE_READER_MMAP_BLOCKING_WAIT, GET_STAGE_READER_MMAP_COPY_BUFFER,
|
||||||
|
GET_STAGE_READER_MMAP_DIRECT_READ_COPY, GET_STAGE_READER_MMAP_FILE_OPEN, GET_STAGE_READER_MMAP_MAP,
|
||||||
|
};
|
||||||
#[cfg(feature = "hotpath")]
|
#[cfg(feature = "hotpath")]
|
||||||
use crate::disk::FileWriter;
|
use crate::disk::FileWriter;
|
||||||
use crate::disk::{self, DiskAPI as _, DiskStore, FileReader, MmapCopyStageMetrics, error::DiskError};
|
use crate::disk::{self, DiskAPI as _, DiskStore, FileReader, MmapCopyStageMetrics, error::DiskError};
|
||||||
@@ -406,11 +409,17 @@ async fn open_disk_reader(
|
|||||||
path_resolve_stage: GET_STAGE_READER_MMAP_PATH_RESOLVE,
|
path_resolve_stage: GET_STAGE_READER_MMAP_PATH_RESOLVE,
|
||||||
metadata_lookup_stage: GET_STAGE_READER_MMAP_METADATA_LOOKUP,
|
metadata_lookup_stage: GET_STAGE_READER_MMAP_METADATA_LOOKUP,
|
||||||
metadata_validate_stage: GET_STAGE_READER_MMAP_METADATA_VALIDATE,
|
metadata_validate_stage: GET_STAGE_READER_MMAP_METADATA_VALIDATE,
|
||||||
|
#[cfg(unix)]
|
||||||
blocking_wait_stage: GET_STAGE_READER_MMAP_BLOCKING_WAIT,
|
blocking_wait_stage: GET_STAGE_READER_MMAP_BLOCKING_WAIT,
|
||||||
|
#[cfg(unix)]
|
||||||
blocking_task_stage: GET_STAGE_READER_MMAP_BLOCKING_TASK,
|
blocking_task_stage: GET_STAGE_READER_MMAP_BLOCKING_TASK,
|
||||||
|
#[cfg(unix)]
|
||||||
file_open_stage: GET_STAGE_READER_MMAP_FILE_OPEN,
|
file_open_stage: GET_STAGE_READER_MMAP_FILE_OPEN,
|
||||||
|
#[cfg(unix)]
|
||||||
mmap_map_stage: GET_STAGE_READER_MMAP_MAP,
|
mmap_map_stage: GET_STAGE_READER_MMAP_MAP,
|
||||||
|
#[cfg(unix)]
|
||||||
mmap_copy_stage: GET_STAGE_READER_MMAP_COPY_BUFFER,
|
mmap_copy_stage: GET_STAGE_READER_MMAP_COPY_BUFFER,
|
||||||
|
#[cfg(unix)]
|
||||||
direct_read_copy_stage: GET_STAGE_READER_MMAP_DIRECT_READ_COPY,
|
direct_read_copy_stage: GET_STAGE_READER_MMAP_DIRECT_READ_COPY,
|
||||||
});
|
});
|
||||||
let mmap_result = {
|
let mmap_result = {
|
||||||
|
|||||||
@@ -870,6 +870,18 @@ impl TierFreeVersionReceiptSink {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Internal PUT completion boundary; this does not change fsync or write quorum.
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WriteCompletion {
|
||||||
|
/// Return at write quorum when the commit owner can retain its guards.
|
||||||
|
#[default]
|
||||||
|
Quorum,
|
||||||
|
/// Drain the rename fan-out before returning. Minority failures still heal
|
||||||
|
/// after a successful quorum commit; this does not require every disk to succeed.
|
||||||
|
TailDrained,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
pub struct ObjectOptions {
|
pub struct ObjectOptions {
|
||||||
// Use the maximum parity (N/2), used when saving server configuration files
|
// Use the maximum parity (N/2), used when saving server configuration files
|
||||||
@@ -896,6 +908,10 @@ pub struct ObjectOptions {
|
|||||||
/// Persisted bucket incarnation observed before authorization.
|
/// Persisted bucket incarnation observed before authorization.
|
||||||
pub expected_bucket_incarnation_id: Option<Uuid>,
|
pub expected_bucket_incarnation_id: Option<Uuid>,
|
||||||
pub no_lock: bool,
|
pub no_lock: bool,
|
||||||
|
/// Control-plane writers that immediately read or CAS the same namespace
|
||||||
|
/// key use TailDrained without changing namespace lock ownership.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub write_completion: WriteCompletion,
|
||||||
/// True when an upper layer already holds the object read lock before
|
/// True when an upper layer already holds the object read lock before
|
||||||
/// forwarding a no_lock read to the set layer.
|
/// forwarding a no_lock read to the set layer.
|
||||||
pub metadata_cache_safe: bool,
|
pub metadata_cache_safe: bool,
|
||||||
@@ -940,6 +956,9 @@ pub struct ObjectOptions {
|
|||||||
pub preserve_etag: Option<String>,
|
pub preserve_etag: Option<String>,
|
||||||
pub metadata_chg: bool,
|
pub metadata_chg: bool,
|
||||||
pub http_preconditions: Option<HTTPPreconditions>,
|
pub http_preconditions: Option<HTTPPreconditions>,
|
||||||
|
/// Internal create-only writes may also preserve an acknowledged deletion.
|
||||||
|
/// Evaluated with `http_preconditions` under the namespace commit lock.
|
||||||
|
pub preserve_delete_marker: bool,
|
||||||
|
|
||||||
pub delete_replication: Option<ReplicationState>,
|
pub delete_replication: Option<ReplicationState>,
|
||||||
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
pub delete_replication_config_snapshot: Option<Arc<DeleteReplicationConfigSnapshot>>,
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ pub(crate) struct ScannerPublicationLeaseEntry {
|
|||||||
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
pub(crate) _operation_guard: OwnedRwLockReadGuard<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct NamespaceCommitGuard {
|
||||||
|
ctx: Arc<InstanceContext>,
|
||||||
|
counted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for NamespaceCommitGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.counted {
|
||||||
|
// Publish the new generation before a zero-pending publication probe.
|
||||||
|
self.ctx.advance_namespace_commit_generation();
|
||||||
|
self.ctx.namespace_commits.fetch_sub(1, Ordering::AcqRel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runtime state owned by a single `ECStore` instance.
|
/// Runtime state owned by a single `ECStore` instance.
|
||||||
///
|
///
|
||||||
/// This is intentionally minimal in the first migration slice; subsequent
|
/// This is intentionally minimal in the first migration slice; subsequent
|
||||||
@@ -209,9 +224,13 @@ pub struct InstanceContext {
|
|||||||
/// Last storage-owned movement snapshot observed under the operation
|
/// Last storage-owned movement snapshot observed under the operation
|
||||||
/// gate. SetDisks cache writers fail closed until ECStore refreshes it.
|
/// gate. SetDisks cache writers fail closed until ECStore refreshes it.
|
||||||
scanner_publication_state: AtomicU8,
|
scanner_publication_state: AtomicU8,
|
||||||
|
namespace_commits: AtomicU64,
|
||||||
|
namespace_commit_generation: AtomicU64,
|
||||||
/// Resolves object-encryption material at the application boundary.
|
/// Resolves object-encryption material at the application boundary.
|
||||||
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
|
#[cfg(test)]
|
||||||
|
suppress_tier_delete_journal_recovery: bool,
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
|
tier_delete_journal_recovery_wakeup: tokio::sync::Notify,
|
||||||
}
|
}
|
||||||
@@ -256,8 +275,12 @@ impl InstanceContext {
|
|||||||
data_movement_generation_exhausted: AtomicBool::new(false),
|
data_movement_generation_exhausted: AtomicBool::new(false),
|
||||||
data_movement_generation_notify: Arc::new(Notify::new()),
|
data_movement_generation_notify: Arc::new(Notify::new()),
|
||||||
scanner_publication_state: AtomicU8::new(SCANNER_PUBLICATION_STATE_UNKNOWN),
|
scanner_publication_state: AtomicU8::new(SCANNER_PUBLICATION_STATE_UNKNOWN),
|
||||||
|
namespace_commits: AtomicU64::new(0),
|
||||||
|
namespace_commit_generation: AtomicU64::new(0),
|
||||||
object_encryption_resolver: OnceLock::new(),
|
object_encryption_resolver: OnceLock::new(),
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
|
#[cfg(test)]
|
||||||
|
suppress_tier_delete_journal_recovery: false,
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
|
tier_delete_journal_recovery_wakeup: tokio::sync::Notify::new(),
|
||||||
}
|
}
|
||||||
@@ -385,6 +408,36 @@ impl InstanceContext {
|
|||||||
&& self.scanner_publication_state.load(Ordering::Acquire) == SCANNER_PUBLICATION_STATE_ALLOWED
|
&& self.scanner_publication_state.load(Ordering::Acquire) == SCANNER_PUBLICATION_STATE_ALLOWED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn begin_namespace_commit(self: &Arc<Self>) -> Arc<NamespaceCommitGuard> {
|
||||||
|
let counted = self
|
||||||
|
.namespace_commits
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| count.checked_add(1))
|
||||||
|
.is_ok();
|
||||||
|
if counted {
|
||||||
|
self.advance_namespace_commit_generation();
|
||||||
|
} else {
|
||||||
|
self.namespace_commit_generation.store(u64::MAX, Ordering::Release);
|
||||||
|
}
|
||||||
|
Arc::new(NamespaceCommitGuard {
|
||||||
|
ctx: Arc::clone(self),
|
||||||
|
counted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_namespace_commit_generation(&self) {
|
||||||
|
let _ = self
|
||||||
|
.namespace_commit_generation
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |generation| Some(generation.saturating_add(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn namespace_commit_generation(&self) -> u64 {
|
||||||
|
self.namespace_commit_generation.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn namespace_commits_pending(&self) -> bool {
|
||||||
|
self.namespace_commits.load(Ordering::Acquire) != 0 || self.namespace_commit_generation() == u64::MAX
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn set_scanner_publication_state(&self, blocked: bool) {
|
pub(crate) fn set_scanner_publication_state(&self, blocked: bool) {
|
||||||
self.scanner_publication_state.store(
|
self.scanner_publication_state.store(
|
||||||
if blocked {
|
if blocked {
|
||||||
@@ -640,12 +693,21 @@ impl InstanceContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn mark_tier_delete_journal_recovery_started(&self, store_id: Uuid) -> bool {
|
pub(crate) fn mark_tier_delete_journal_recovery_started(&self, store_id: Uuid) -> bool {
|
||||||
|
#[cfg(test)]
|
||||||
|
if self.suppress_tier_delete_journal_recovery {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
self.tier_delete_journal_recovery_stores
|
self.tier_delete_journal_recovery_stores
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
.insert(store_id)
|
.insert(store_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn suppress_tier_delete_journal_recovery_for_test(&mut self) {
|
||||||
|
self.suppress_tier_delete_journal_recovery = true;
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn mark_transition_transaction_recovery_started(&self, store_id: Uuid) -> bool {
|
pub(crate) fn mark_transition_transaction_recovery_started(&self, store_id: Uuid) -> bool {
|
||||||
self.transition_transaction_recovery_stores
|
self.transition_transaction_recovery_stores
|
||||||
.lock()
|
.lock()
|
||||||
@@ -756,6 +818,50 @@ pub fn bootstrap_ctx() -> Arc<InstanceContext> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_commit_guards_are_instance_local_and_count_until_last_owner() {
|
||||||
|
let first = Arc::new(InstanceContext::new());
|
||||||
|
let other = Arc::new(InstanceContext::new());
|
||||||
|
first.set_scanner_publication_state(false);
|
||||||
|
other.set_scanner_publication_state(false);
|
||||||
|
assert!(first.scanner_publication_state_allowed());
|
||||||
|
let one = first.begin_namespace_commit();
|
||||||
|
let shared_owner = Arc::clone(&one);
|
||||||
|
let two = first.begin_namespace_commit();
|
||||||
|
assert!(first.namespace_commits_pending());
|
||||||
|
assert!(first.scanner_publication_state_allowed(), "pending writes must not block scan admission");
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 2);
|
||||||
|
assert!(!other.namespace_commits_pending());
|
||||||
|
assert_eq!(other.namespace_commit_generation(), 0);
|
||||||
|
assert!(other.scanner_publication_state_allowed());
|
||||||
|
drop(one);
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 2);
|
||||||
|
drop(shared_owner);
|
||||||
|
assert!(first.namespace_commits_pending());
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 3);
|
||||||
|
drop(two);
|
||||||
|
assert!(!first.namespace_commits_pending());
|
||||||
|
assert_eq!(first.namespace_commit_generation(), 4);
|
||||||
|
assert!(first.scanner_publication_state_allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn namespace_commit_counter_exhaustion_keeps_publication_blocked() {
|
||||||
|
for (count, generation) in [(0, u64::MAX - 1), (u64::MAX, 0)] {
|
||||||
|
let ctx = Arc::new(InstanceContext::new());
|
||||||
|
ctx.set_scanner_publication_state(false);
|
||||||
|
ctx.namespace_commits.store(count, Ordering::Release);
|
||||||
|
ctx.namespace_commit_generation.store(generation, Ordering::Release);
|
||||||
|
let guard = ctx.begin_namespace_commit();
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), u64::MAX);
|
||||||
|
drop(guard);
|
||||||
|
assert!(ctx.namespace_commits_pending());
|
||||||
|
assert_eq!(ctx.namespace_commit_generation(), u64::MAX);
|
||||||
|
assert_eq!(ctx.namespace_commits.load(Ordering::Acquire), count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The SetupType inputs must derive the exact (is_erasure,
|
// The SetupType inputs must derive the exact (is_erasure,
|
||||||
// is_dist_erasure, is_erasure_sd) triples that the original three
|
// is_dist_erasure, is_erasure_sd) triples that the original three
|
||||||
// process-global erasure bools produced via update_erasure_type().
|
// process-global erasure bools produced via update_erasure_type().
|
||||||
@@ -1073,6 +1179,12 @@ mod tests {
|
|||||||
assert!(!ctx_a.mark_tier_delete_journal_recovery_started(store_a));
|
assert!(!ctx_a.mark_tier_delete_journal_recovery_started(store_a));
|
||||||
assert!(ctx_a.mark_tier_delete_journal_recovery_started(store_b));
|
assert!(ctx_a.mark_tier_delete_journal_recovery_started(store_b));
|
||||||
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_a));
|
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_a));
|
||||||
|
|
||||||
|
let mut manual_ctx = InstanceContext::new();
|
||||||
|
manual_ctx.suppress_tier_delete_journal_recovery_for_test();
|
||||||
|
assert!(!manual_ctx.mark_tier_delete_journal_recovery_started(store_a));
|
||||||
|
assert!(!manual_ctx.mark_tier_delete_journal_recovery_started(store_b));
|
||||||
|
assert!(ctx_b.mark_tier_delete_journal_recovery_started(store_b));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -62,12 +62,27 @@ const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
|
|||||||
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 2;
|
const CROSS_POOL_FENCE_SUPPORTED_VERSION: u32 = 2;
|
||||||
const TIER_DELETE_JOURNAL_POLICY_SUPPORTED_VERSION: u32 = 3;
|
const TIER_DELETE_JOURNAL_POLICY_SUPPORTED_VERSION: u32 = 3;
|
||||||
const DECOMMISSION_TARGET_FENCE_POLICY_SUPPORTED_VERSION: u32 = 4;
|
const DECOMMISSION_TARGET_FENCE_POLICY_SUPPORTED_VERSION: u32 = 4;
|
||||||
|
// Keep this synchronized with the version served by node_service. Including
|
||||||
|
// the local member in the minimum prevents an older coordinator from
|
||||||
|
// self-authorizing a policy implemented only by newer remote peers.
|
||||||
|
const LOCAL_CROSS_POOL_FENCE_POLICY_SUPPORTED_VERSION: u32 = 4;
|
||||||
|
/// Version 5 is reserved for a fleet whose every metadata writer preserves
|
||||||
|
/// explicit transition version state and destination identity, and implements
|
||||||
|
/// conditional per-generation `xl.meta` writes with strong readback. The node
|
||||||
|
/// service must not advertise this version until the conditional writer from
|
||||||
|
/// rustfs/backlog#684 is available.
|
||||||
|
const LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION: u32 = 5;
|
||||||
type CrossPoolFencePolicyResult = Result<BTreeMap<String, Uuid>>;
|
type CrossPoolFencePolicyResult = Result<BTreeMap<String, Uuid>>;
|
||||||
|
|
||||||
fn cross_pool_fence_policy_results(
|
fn cross_pool_fence_policy_results(
|
||||||
peer_epochs: BTreeMap<String, Uuid>,
|
peer_epochs: BTreeMap<String, Uuid>,
|
||||||
minimum_version: u32,
|
minimum_version: u32,
|
||||||
) -> (CrossPoolFencePolicyResult, CrossPoolFencePolicyResult, CrossPoolFencePolicyResult) {
|
) -> (
|
||||||
|
CrossPoolFencePolicyResult,
|
||||||
|
CrossPoolFencePolicyResult,
|
||||||
|
CrossPoolFencePolicyResult,
|
||||||
|
CrossPoolFencePolicyResult,
|
||||||
|
) {
|
||||||
let journal_result = if minimum_version >= TIER_DELETE_JOURNAL_POLICY_SUPPORTED_VERSION {
|
let journal_result = if minimum_version >= TIER_DELETE_JOURNAL_POLICY_SUPPORTED_VERSION {
|
||||||
Ok(peer_epochs.clone())
|
Ok(peer_epochs.clone())
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +93,18 @@ fn cross_pool_fence_policy_results(
|
|||||||
} else {
|
} else {
|
||||||
Err(Error::other("decommission target fence policy capability version is unsupported"))
|
Err(Error::other("decommission target fence policy capability version is unsupported"))
|
||||||
};
|
};
|
||||||
(Ok(peer_epochs), journal_result, decommission_target_fence_result)
|
let legacy_transition_state_reconcile_result =
|
||||||
|
if minimum_version >= LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION {
|
||||||
|
Ok(peer_epochs.clone())
|
||||||
|
} else {
|
||||||
|
Err(Error::other("legacy transition state reconcile policy capability version is unsupported"))
|
||||||
|
};
|
||||||
|
(
|
||||||
|
Ok(peer_epochs),
|
||||||
|
journal_result,
|
||||||
|
decommission_target_fence_result,
|
||||||
|
legacy_transition_state_reconcile_result,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -252,10 +278,21 @@ pub(crate) struct TierDeleteJournalFleetProofToken {
|
|||||||
_permit: FleetCapabilityProofPermit,
|
_permit: FleetCapabilityProofPermit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Effect-window authority for one legacy transition-state reconciliation.
|
||||||
|
///
|
||||||
|
/// The token intentionally cannot be cloned. Its permit keeps the admitted
|
||||||
|
/// fleet generation alive until the caller finishes the final strong
|
||||||
|
/// readback, while revocation makes every later validation fail immediately.
|
||||||
|
pub struct LegacyTransitionStateReconcileFleetProofToken {
|
||||||
|
token: FleetCapabilityProofToken,
|
||||||
|
_permit: FleetCapabilityProofPermit,
|
||||||
|
}
|
||||||
|
|
||||||
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||||
static CROSS_POOL_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
static CROSS_POOL_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||||
static TIER_DELETE_JOURNAL_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
static TIER_DELETE_JOURNAL_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||||
static DECOMMISSION_TARGET_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
static DECOMMISSION_TARGET_FENCE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||||
|
static LEGACY_TRANSITION_STATE_RECONCILE_FLEET_PROOF: OnceLock<std::sync::RwLock<FleetCapabilityProofState>> = OnceLock::new();
|
||||||
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
|
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
fn cross_pool_fence_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
|
fn cross_pool_fence_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
|
||||||
@@ -274,6 +311,10 @@ fn decommission_target_fence_fleet_proof_slot() -> &'static std::sync::RwLock<Fl
|
|||||||
DECOMMISSION_TARGET_FENCE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
|
DECOMMISSION_TARGET_FENCE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn legacy_transition_state_reconcile_fleet_proof_slot() -> &'static std::sync::RwLock<FleetCapabilityProofState> {
|
||||||
|
LEGACY_TRANSITION_STATE_RECONCILE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(FleetCapabilityProofState::default()))
|
||||||
|
}
|
||||||
|
|
||||||
fn revoke_fleet_capability_proof_state(state: &mut FleetCapabilityProofState) {
|
fn revoke_fleet_capability_proof_state(state: &mut FleetCapabilityProofState) {
|
||||||
if let Some(proof) = state.proof.take() {
|
if let Some(proof) = state.proof.take() {
|
||||||
proof.generation.revoke();
|
proof.generation.revoke();
|
||||||
@@ -444,6 +485,125 @@ pub(crate) fn tier_delete_journal_topology_generation(proof: &TierDeleteJournalF
|
|||||||
stable_tier_delete_journal_topology_generation(&proof.token.topology_fingerprint)
|
stable_tier_delete_journal_topology_generation(&proof.token.topology_fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acquire one non-cloneable authority that must span the complete reconcile
|
||||||
|
/// effect window, including its final strong readback.
|
||||||
|
pub async fn acquire_legacy_transition_state_reconcile_fleet_proof() -> Option<LegacyTransitionStateReconcileFleetProofToken> {
|
||||||
|
let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?;
|
||||||
|
let proof = {
|
||||||
|
let state = legacy_transition_state_reconcile_fleet_proof_slot()
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, expected_topology, Instant::now())?
|
||||||
|
};
|
||||||
|
let observed_peer_epochs = observe_legacy_transition_state_reconcile_fleet(expected_topology).await?;
|
||||||
|
let state = legacy_transition_state_reconcile_fleet_proof_slot()
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_matches_observation_at(
|
||||||
|
&state,
|
||||||
|
&proof,
|
||||||
|
expected_topology,
|
||||||
|
&observed_peer_epochs,
|
||||||
|
Instant::now(),
|
||||||
|
)
|
||||||
|
.then_some(proof)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquire_legacy_transition_state_reconcile_fleet_proof_from(
|
||||||
|
state: &FleetCapabilityProofState,
|
||||||
|
expected_topology: &str,
|
||||||
|
now: Instant,
|
||||||
|
) -> Option<LegacyTransitionStateReconcileFleetProofToken> {
|
||||||
|
let token = acquire_fleet_capability_proof_from(state, expected_topology, now)?;
|
||||||
|
let permit = state.proof.as_ref()?.generation.try_acquire()?;
|
||||||
|
Some(LegacyTransitionStateReconcileFleetProofToken { token, _permit: permit })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn observe_legacy_transition_state_reconcile_fleet(expected_topology: &str) -> Option<BTreeMap<String, Uuid>> {
|
||||||
|
let notification_sys = get_global_notification_sys()?;
|
||||||
|
let (peer_epochs, minimum_version) = timeout(
|
||||||
|
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
|
||||||
|
notification_sys.probe_cross_pool_fence_fleet(expected_topology),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.ok()?;
|
||||||
|
let (_, _, _, reconcile_result) = cross_pool_fence_policy_results(peer_epochs, minimum_version);
|
||||||
|
reconcile_result.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revalidate the exact fleet generation captured by a reconcile token with a
|
||||||
|
/// fresh synchronous observation. Callers must await this before each
|
||||||
|
/// conditional metadata write and after the final strong readback.
|
||||||
|
pub async fn legacy_transition_state_reconcile_fleet_proof_matches(
|
||||||
|
proof: &LegacyTransitionStateReconcileFleetProofToken,
|
||||||
|
) -> bool {
|
||||||
|
let Some(expected_topology) = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_matches_with_observer(
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_slot(),
|
||||||
|
proof,
|
||||||
|
expected_topology,
|
||||||
|
|| observe_legacy_transition_state_reconcile_fleet(expected_topology),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn legacy_transition_state_reconcile_fleet_proof_matches_with_observer<F, Fut>(
|
||||||
|
slot: &std::sync::RwLock<FleetCapabilityProofState>,
|
||||||
|
proof: &LegacyTransitionStateReconcileFleetProofToken,
|
||||||
|
expected_topology: &str,
|
||||||
|
observe: F,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = Option<BTreeMap<String, Uuid>>>,
|
||||||
|
{
|
||||||
|
{
|
||||||
|
let state = slot.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if !legacy_transition_state_reconcile_fleet_proof_matches_at(&state, proof, expected_topology, Instant::now()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(observed_peer_epochs) = observe().await else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let state = slot.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_matches_observation_at(
|
||||||
|
&state,
|
||||||
|
proof,
|
||||||
|
expected_topology,
|
||||||
|
&observed_peer_epochs,
|
||||||
|
Instant::now(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_transition_state_reconcile_fleet_proof_matches_at(
|
||||||
|
state: &FleetCapabilityProofState,
|
||||||
|
proof: &LegacyTransitionStateReconcileFleetProofToken,
|
||||||
|
expected_topology: &str,
|
||||||
|
now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
proof._permit.generation.is_accepting()
|
||||||
|
&& fleet_capability_proof_matches_at(state, &proof.token, expected_topology, now)
|
||||||
|
&& state
|
||||||
|
.proof
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|current| Arc::ptr_eq(¤t.generation, &proof._permit.generation))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_transition_state_reconcile_fleet_proof_matches_observation_at(
|
||||||
|
state: &FleetCapabilityProofState,
|
||||||
|
proof: &LegacyTransitionStateReconcileFleetProofToken,
|
||||||
|
expected_topology: &str,
|
||||||
|
observed_peer_epochs: &BTreeMap<String, Uuid>,
|
||||||
|
now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_matches_at(state, proof, expected_topology, now)
|
||||||
|
&& proof.token.peer_epochs.as_ref() == observed_peer_epochs
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "test-util"))]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
pub(crate) fn tier_delete_journal_fleet_proof_has_inflight_for_test() -> bool {
|
pub(crate) fn tier_delete_journal_fleet_proof_has_inflight_for_test() -> bool {
|
||||||
let state = tier_delete_journal_fleet_proof_slot()
|
let state = tier_delete_journal_fleet_proof_slot()
|
||||||
@@ -766,6 +926,7 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
|||||||
cross_pool_fence_fleet_proof_slot(),
|
cross_pool_fence_fleet_proof_slot(),
|
||||||
tier_delete_journal_fleet_proof_slot(),
|
tier_delete_journal_fleet_proof_slot(),
|
||||||
decommission_target_fence_fleet_proof_slot(),
|
decommission_target_fence_fleet_proof_slot(),
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_slot(),
|
||||||
] {
|
] {
|
||||||
mark_fleet_capability_topology_conflict(slot);
|
mark_fleet_capability_topology_conflict(slot);
|
||||||
}
|
}
|
||||||
@@ -798,11 +959,12 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
|||||||
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out"))),
|
.unwrap_or_else(|_| Err(Error::other("cross-pool fence fleet capability probe timed out"))),
|
||||||
None => Err(Error::other("cross-pool fence fleet capability notification system is unavailable")),
|
None => Err(Error::other("cross-pool fence fleet capability notification system is unavailable")),
|
||||||
};
|
};
|
||||||
let (fence_result, journal_result, decommission_target_fence_result) = match fence_probe {
|
let (fence_result, journal_result, decommission_target_fence_result, reconcile_result) = match fence_probe {
|
||||||
Ok((peer_epochs, minimum_version)) => cross_pool_fence_policy_results(peer_epochs, minimum_version),
|
Ok((peer_epochs, minimum_version)) => cross_pool_fence_policy_results(peer_epochs, minimum_version),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let message = err.to_string();
|
let message = err.to_string();
|
||||||
(
|
(
|
||||||
|
Err(Error::other(message.clone())),
|
||||||
Err(Error::other(message.clone())),
|
Err(Error::other(message.clone())),
|
||||||
Err(Error::other(message.clone())),
|
Err(Error::other(message.clone())),
|
||||||
Err(Error::other(message)),
|
Err(Error::other(message)),
|
||||||
@@ -818,6 +980,7 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
|||||||
revoke_fleet_capability_proof(cross_pool_fence_fleet_proof_slot());
|
revoke_fleet_capability_proof(cross_pool_fence_fleet_proof_slot());
|
||||||
revoke_fleet_capability_proof(tier_delete_journal_fleet_proof_slot());
|
revoke_fleet_capability_proof(tier_delete_journal_fleet_proof_slot());
|
||||||
revoke_fleet_capability_proof(decommission_target_fence_fleet_proof_slot());
|
revoke_fleet_capability_proof(decommission_target_fence_fleet_proof_slot());
|
||||||
|
revoke_fleet_capability_proof(legacy_transition_state_reconcile_fleet_proof_slot());
|
||||||
} else if let Some(err) = publish_fleet_capability_probe_result(
|
} else if let Some(err) = publish_fleet_capability_probe_result(
|
||||||
remote_version_state_fleet_proof_slot(),
|
remote_version_state_fleet_proof_slot(),
|
||||||
&topology_fingerprint,
|
&topology_fingerprint,
|
||||||
@@ -880,6 +1043,24 @@ pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
|||||||
"notification capability probe"
|
"notification capability probe"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if !topology_conflict
|
||||||
|
&& let Some(err) = publish_fleet_capability_probe_result(
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_slot(),
|
||||||
|
&topology_fingerprint,
|
||||||
|
reconcile_result,
|
||||||
|
Instant::now(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
event = EVENT_NOTIFICATION_CAPABILITY_PROBE,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||||
|
capability = "legacy_transition_state_reconcile_v1",
|
||||||
|
state = "failed_closed",
|
||||||
|
error = %err,
|
||||||
|
"notification capability probe"
|
||||||
|
);
|
||||||
|
}
|
||||||
sleep(REMOTE_VERSION_STATE_PROBE_INTERVAL).await;
|
sleep(REMOTE_VERSION_STATE_PROBE_INTERVAL).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -959,7 +1140,7 @@ impl NotificationSys {
|
|||||||
client.probe_cross_pool_fence(topology_fingerprint.to_string()).await
|
client.probe_cross_pool_fence(topology_fingerprint.to_string()).await
|
||||||
});
|
});
|
||||||
let mut peer_epochs = BTreeMap::new();
|
let mut peer_epochs = BTreeMap::new();
|
||||||
let mut minimum_version = u32::MAX;
|
let mut minimum_version = LOCAL_CROSS_POOL_FENCE_POLICY_SUPPORTED_VERSION;
|
||||||
for result in join_all(probes).await {
|
for result in join_all(probes).await {
|
||||||
let (peer, version, epoch) = result?;
|
let (peer, version, epoch) = result?;
|
||||||
if version < CROSS_POOL_FENCE_SUPPORTED_VERSION {
|
if version < CROSS_POOL_FENCE_SUPPORTED_VERSION {
|
||||||
@@ -968,11 +1149,6 @@ impl NotificationSys {
|
|||||||
minimum_version = minimum_version.min(version);
|
minimum_version = minimum_version.min(version);
|
||||||
insert_remote_version_state_peer(&mut peer_epochs, peer, epoch)?;
|
insert_remote_version_state_peer(&mut peer_epochs, peer, epoch)?;
|
||||||
}
|
}
|
||||||
// A single-node deployment has no remote member to lower the local
|
|
||||||
// policy version advertised by this binary.
|
|
||||||
if minimum_version == u32::MAX {
|
|
||||||
minimum_version = DECOMMISSION_TARGET_FENCE_POLICY_SUPPORTED_VERSION;
|
|
||||||
}
|
|
||||||
Ok((peer_epochs, minimum_version))
|
Ok((peer_epochs, minimum_version))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3190,20 +3366,36 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cross_pool_policy_versions_authorize_only_their_supported_protocols() {
|
fn cross_pool_policy_versions_authorize_only_their_supported_protocols() {
|
||||||
let peers = BTreeMap::from([("node-b:9000".to_string(), Uuid::new_v4())]);
|
let peers = BTreeMap::from([("node-b:9000".to_string(), Uuid::new_v4())]);
|
||||||
let (generic_v2, journal_v2, decommission_v2) = cross_pool_fence_policy_results(peers.clone(), 2);
|
let (generic_v2, journal_v2, decommission_v2, reconcile_v2) = cross_pool_fence_policy_results(peers.clone(), 2);
|
||||||
assert!(generic_v2.is_ok(), "v2 remains valid for existing cross-pool fencing");
|
assert!(generic_v2.is_ok(), "v2 remains valid for existing cross-pool fencing");
|
||||||
assert!(journal_v2.is_err(), "a mixed v2/v3 fleet must fail closed for journal-v6 deletion");
|
assert!(journal_v2.is_err(), "a mixed v2/v3 fleet must fail closed for journal-v6 deletion");
|
||||||
assert!(decommission_v2.is_err(), "v2 cannot authorize the sticky per-target decommission fence");
|
assert!(decommission_v2.is_err(), "v2 cannot authorize the sticky per-target decommission fence");
|
||||||
|
assert!(reconcile_v2.is_err(), "v2 cannot authorize legacy transition-state reconciliation");
|
||||||
|
|
||||||
let (generic_v3, journal_v3, decommission_v3) = cross_pool_fence_policy_results(peers.clone(), 3);
|
let (generic_v3, journal_v3, decommission_v3, reconcile_v3) = cross_pool_fence_policy_results(peers.clone(), 3);
|
||||||
assert!(generic_v3.is_ok());
|
assert!(generic_v3.is_ok());
|
||||||
assert!(journal_v3.is_ok(), "an all-v3 fleet may authorize journal-v6 deletion");
|
assert!(journal_v3.is_ok(), "an all-v3 fleet may authorize journal-v6 deletion");
|
||||||
assert!(decommission_v3.is_err(), "v3 members do not understand the per-target decommission fence");
|
assert!(decommission_v3.is_err(), "v3 members do not understand the per-target decommission fence");
|
||||||
|
assert!(reconcile_v3.is_err());
|
||||||
|
|
||||||
let (generic_v4, journal_v4, decommission_v4) = cross_pool_fence_policy_results(peers, 4);
|
let (generic_v4, journal_v4, decommission_v4, reconcile_v4) =
|
||||||
|
cross_pool_fence_policy_results(peers.clone(), LOCAL_CROSS_POOL_FENCE_POLICY_SUPPORTED_VERSION);
|
||||||
assert!(generic_v4.is_ok());
|
assert!(generic_v4.is_ok());
|
||||||
assert!(journal_v4.is_ok());
|
assert!(journal_v4.is_ok());
|
||||||
assert!(decommission_v4.is_ok(), "an all-v4 fleet may create sticky per-target reservations");
|
assert!(decommission_v4.is_ok(), "an all-v4 fleet may create sticky per-target reservations");
|
||||||
|
assert!(
|
||||||
|
reconcile_v4.is_err(),
|
||||||
|
"the current local policy lacks the conditional xl.meta writer required by reconcile"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (generic_v5, journal_v5, decommission_v5, reconcile_v5) = cross_pool_fence_policy_results(peers, 5);
|
||||||
|
assert!(generic_v5.is_ok());
|
||||||
|
assert!(journal_v5.is_ok());
|
||||||
|
assert!(decommission_v5.is_ok());
|
||||||
|
assert!(
|
||||||
|
reconcile_v5.is_ok(),
|
||||||
|
"only an all-v5 fleet preserves destination identity and conditional reconcile writes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3458,6 +3650,234 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_transition_state_reconcile_admits_only_compatible_single_and_multi_node_fleets() {
|
||||||
|
let now = Instant::now();
|
||||||
|
for peers in [
|
||||||
|
BTreeMap::new(),
|
||||||
|
BTreeMap::from([("peer-a".to_string(), Uuid::new_v4()), ("peer-b".to_string(), Uuid::new_v4())]),
|
||||||
|
] {
|
||||||
|
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||||
|
let (_, _, _, result) =
|
||||||
|
cross_pool_fence_policy_results(peers, LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION);
|
||||||
|
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", result, now).is_none());
|
||||||
|
|
||||||
|
let admitted = {
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, "topology-a", now)
|
||||||
|
.expect("an all-compatible fleet should admit reconciliation")
|
||||||
|
};
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
assert!(legacy_transition_state_reconcile_fleet_proof_matches_at(
|
||||||
|
&state,
|
||||||
|
&admitted,
|
||||||
|
"topology-a",
|
||||||
|
now,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_transition_state_reconcile_restart_drains_concurrent_effect_windows() {
|
||||||
|
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||||
|
let now = Instant::now();
|
||||||
|
let original_peers = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
||||||
|
let (_, _, _, original_result) =
|
||||||
|
cross_pool_fence_policy_results(original_peers, LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION);
|
||||||
|
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", original_result, now).is_none());
|
||||||
|
|
||||||
|
let (first, second) = {
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
(
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, "topology-a", now)
|
||||||
|
.expect("the first reconcile writer should be admitted"),
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, "topology-a", now)
|
||||||
|
.expect("the second reconcile writer should be admitted"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let restarted_peers = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
||||||
|
let (_, _, _, restarted_result) =
|
||||||
|
cross_pool_fence_policy_results(restarted_peers.clone(), LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION);
|
||||||
|
let blocked =
|
||||||
|
publish_fleet_capability_probe_result(&slot, "topology-a", restarted_result, now + Duration::from_millis(1))
|
||||||
|
.expect("a restarted member must revoke the old generation and wait for both writers");
|
||||||
|
assert!(blocked.to_string().contains("previous generation to drain"));
|
||||||
|
{
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
assert!(state.proof.is_none());
|
||||||
|
assert!(state.draining_generation.is_some());
|
||||||
|
assert!(!legacy_transition_state_reconcile_fleet_proof_matches_at(
|
||||||
|
&state,
|
||||||
|
&first,
|
||||||
|
"topology-a",
|
||||||
|
now + Duration::from_millis(1),
|
||||||
|
));
|
||||||
|
assert!(!legacy_transition_state_reconcile_fleet_proof_matches_at(
|
||||||
|
&state,
|
||||||
|
&second,
|
||||||
|
"topology-a",
|
||||||
|
now + Duration::from_millis(1),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(first);
|
||||||
|
let (_, _, _, still_blocked_result) =
|
||||||
|
cross_pool_fence_policy_results(restarted_peers.clone(), LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION);
|
||||||
|
assert!(
|
||||||
|
publish_fleet_capability_probe_result(&slot, "topology-a", still_blocked_result, now + Duration::from_millis(2),)
|
||||||
|
.is_some(),
|
||||||
|
"one remaining writer must keep the successor generation closed"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(second);
|
||||||
|
let (_, _, _, admitted_result) =
|
||||||
|
cross_pool_fence_policy_results(restarted_peers, LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION);
|
||||||
|
assert!(
|
||||||
|
publish_fleet_capability_probe_result(&slot, "topology-a", admitted_result, now + Duration::from_millis(3),)
|
||||||
|
.is_none(),
|
||||||
|
"the restarted generation may publish only after every old writer drains"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_transition_state_reconcile_fresh_observation_closes_the_polling_window() {
|
||||||
|
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||||
|
let now = Instant::now();
|
||||||
|
let original_peers = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
||||||
|
let (_, _, _, original_result) =
|
||||||
|
cross_pool_fence_policy_results(original_peers.clone(), LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION);
|
||||||
|
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", original_result, now).is_none());
|
||||||
|
let admitted = {
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, "topology-a", now)
|
||||||
|
.expect("the original fleet should admit reconciliation")
|
||||||
|
};
|
||||||
|
|
||||||
|
let restarted_peers = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
assert!(
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_matches_at(&state, &admitted, "topology-a", now),
|
||||||
|
"the periodic cache has not observed the restart yet"
|
||||||
|
);
|
||||||
|
assert!(!legacy_transition_state_reconcile_fleet_proof_matches_observation_at(
|
||||||
|
&state,
|
||||||
|
&admitted,
|
||||||
|
"topology-a",
|
||||||
|
&restarted_peers,
|
||||||
|
now,
|
||||||
|
));
|
||||||
|
|
||||||
|
let (_, _, _, downgraded) = cross_pool_fence_policy_results(original_peers, 4);
|
||||||
|
assert!(
|
||||||
|
downgraded.is_err(),
|
||||||
|
"a synchronous observation of a downgraded peer must fail before any cached proof can authorize a write"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_transition_state_reconcile_invalid_token_skips_fleet_observation() {
|
||||||
|
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||||
|
let now = Instant::now();
|
||||||
|
let peers = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
||||||
|
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(peers), now).is_none());
|
||||||
|
let admitted = {
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, "topology-a", now)
|
||||||
|
.expect("the original fleet should admit reconciliation")
|
||||||
|
};
|
||||||
|
revoke_fleet_capability_proof(&slot);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!legacy_transition_state_reconcile_fleet_proof_matches_with_observer(&slot, &admitted, "topology-a", || async {
|
||||||
|
panic!("an invalid local generation must not trigger a fleet observation");
|
||||||
|
},)
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_transition_state_reconcile_membership_and_topology_changes_revoke_authority() {
|
||||||
|
let now = Instant::now();
|
||||||
|
for replacement in [
|
||||||
|
BTreeMap::from([("peer-a".to_string(), Uuid::new_v4()), ("peer-b".to_string(), Uuid::new_v4())]),
|
||||||
|
BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]),
|
||||||
|
] {
|
||||||
|
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||||
|
let original = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
||||||
|
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(original), now).is_none());
|
||||||
|
let admitted = {
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, "topology-a", now)
|
||||||
|
.expect("the original fleet should admit reconciliation")
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
publish_fleet_capability_probe_result(&slot, "topology-a", Ok(replacement), now + Duration::from_millis(1),)
|
||||||
|
.is_some(),
|
||||||
|
"membership or process-epoch replacement must wait for the admitted writer"
|
||||||
|
);
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
assert!(!legacy_transition_state_reconcile_fleet_proof_matches_at(
|
||||||
|
&state,
|
||||||
|
&admitted,
|
||||||
|
"topology-a",
|
||||||
|
now + Duration::from_millis(1),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||||
|
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", Ok(BTreeMap::new()), now).is_none());
|
||||||
|
let admitted = {
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, "topology-a", now)
|
||||||
|
.expect("the original topology should admit reconciliation")
|
||||||
|
};
|
||||||
|
mark_fleet_capability_topology_conflict(&slot);
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
assert!(state.topology_conflict);
|
||||||
|
assert!(!legacy_transition_state_reconcile_fleet_proof_matches_at(
|
||||||
|
&state,
|
||||||
|
&admitted,
|
||||||
|
"topology-a",
|
||||||
|
now,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_transition_state_reconcile_capability_downgrade_fails_closed() {
|
||||||
|
let slot = std::sync::RwLock::new(FleetCapabilityProofState::default());
|
||||||
|
let now = Instant::now();
|
||||||
|
let peers = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
||||||
|
let (_, _, _, compatible_result) =
|
||||||
|
cross_pool_fence_policy_results(peers.clone(), LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION);
|
||||||
|
assert!(publish_fleet_capability_probe_result(&slot, "topology-a", compatible_result, now).is_none());
|
||||||
|
let admitted = {
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, "topology-a", now)
|
||||||
|
.expect("v5 should admit reconciliation")
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_, _, _, downgraded_result) =
|
||||||
|
cross_pool_fence_policy_results(peers, LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION - 1);
|
||||||
|
let err = publish_fleet_capability_probe_result(&slot, "topology-a", downgraded_result, now + Duration::from_millis(1))
|
||||||
|
.expect("a v4 member must revoke reconcile authority");
|
||||||
|
assert!(err.to_string().contains("reconcile policy capability version is unsupported"));
|
||||||
|
let state = slot.read().expect("reconcile proof slot should not poison");
|
||||||
|
assert!(state.proof.is_none());
|
||||||
|
assert!(!legacy_transition_state_reconcile_fleet_proof_matches_at(
|
||||||
|
&state,
|
||||||
|
&admitted,
|
||||||
|
"topology-a",
|
||||||
|
now + Duration::from_millis(1),
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
acquire_legacy_transition_state_reconcile_fleet_proof_from(&state, "topology-a", now + Duration::from_millis(1),)
|
||||||
|
.is_none(),
|
||||||
|
"a downgraded fleet must remain inspect-only"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_version_state_fleet_proof_conflict_revokes_atomic_snapshot() {
|
fn remote_version_state_fleet_proof_conflict_revokes_atomic_snapshot() {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
@@ -3539,6 +3959,57 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("incomplete"));
|
assert!(err.to_string().contains("incomplete"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_transition_state_reconcile_probe_rejects_missing_or_unreachable_members() {
|
||||||
|
let missing = NotificationSys {
|
||||||
|
peer_clients: Vec::new(),
|
||||||
|
all_peer_clients: vec![None],
|
||||||
|
peer_topology_hosts: vec!["peer-a".to_string()],
|
||||||
|
peer_admin_caches: Vec::new(),
|
||||||
|
tier_config_reload_workers: Default::default(),
|
||||||
|
};
|
||||||
|
let missing_err = missing
|
||||||
|
.probe_cross_pool_fence_fleet("topology-a")
|
||||||
|
.await
|
||||||
|
.expect_err("a missing member slot must prevent reconcile capability proof");
|
||||||
|
assert!(missing_err.to_string().contains("incomplete"));
|
||||||
|
|
||||||
|
let unreachable = NotificationSys {
|
||||||
|
peer_clients: vec![None],
|
||||||
|
all_peer_clients: vec![None, None],
|
||||||
|
peer_topology_hosts: vec!["peer-a".to_string()],
|
||||||
|
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
|
||||||
|
tier_config_reload_workers: Default::default(),
|
||||||
|
};
|
||||||
|
let unreachable_err = unreachable
|
||||||
|
.probe_cross_pool_fence_fleet("topology-a")
|
||||||
|
.await
|
||||||
|
.expect_err("an unreachable member must prevent reconcile capability proof");
|
||||||
|
assert!(unreachable_err.to_string().contains("unreachable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_transition_state_reconcile_single_node_stays_closed_before_local_cas_support() {
|
||||||
|
let notification_sys = NotificationSys {
|
||||||
|
peer_clients: Vec::new(),
|
||||||
|
all_peer_clients: vec![None],
|
||||||
|
peer_topology_hosts: Vec::new(),
|
||||||
|
peer_admin_caches: Vec::new(),
|
||||||
|
tier_config_reload_workers: Default::default(),
|
||||||
|
};
|
||||||
|
let (peers, minimum_version) = notification_sys
|
||||||
|
.probe_cross_pool_fence_fleet("topology-a")
|
||||||
|
.await
|
||||||
|
.expect("a single-node capability probe should complete");
|
||||||
|
assert!(peers.is_empty());
|
||||||
|
assert_eq!(minimum_version, LOCAL_CROSS_POOL_FENCE_POLICY_SUPPORTED_VERSION);
|
||||||
|
let (_, _, _, reconcile_result) = cross_pool_fence_policy_results(peers, minimum_version);
|
||||||
|
assert!(
|
||||||
|
reconcile_result.is_err(),
|
||||||
|
"the current node must not self-authorize reconcile before the conditional writer lands"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn build_props(endpoint: &str) -> ServerProperties {
|
fn build_props(endpoint: &str) -> ServerProperties {
|
||||||
ServerProperties {
|
ServerProperties {
|
||||||
endpoint: endpoint.to_string(),
|
endpoint: endpoint.to_string(),
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ pub mod tier_gen;
|
|||||||
pub mod tier_handlers;
|
pub mod tier_handlers;
|
||||||
pub(crate) mod tier_mutation_intent;
|
pub(crate) mod tier_mutation_intent;
|
||||||
pub mod tier_mutation_peer;
|
pub mod tier_mutation_peer;
|
||||||
|
pub(crate) mod tier_probe_intent;
|
||||||
pub mod warm_backend;
|
pub mod warm_backend;
|
||||||
pub mod warm_backend_aliyun;
|
pub mod warm_backend_aliyun;
|
||||||
pub mod warm_backend_azure;
|
pub mod warm_backend_azure;
|
||||||
|
#[cfg(feature = "gcs")]
|
||||||
pub mod warm_backend_gcs;
|
pub mod warm_backend_gcs;
|
||||||
pub mod warm_backend_huaweicloud;
|
pub mod warm_backend_huaweicloud;
|
||||||
pub mod warm_backend_minio;
|
pub mod warm_backend_minio;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc,
|
Arc,
|
||||||
@@ -68,21 +69,28 @@ use tokio::io::AsyncReadExt;
|
|||||||
use tokio::sync::{Mutex, Notify, RwLock};
|
use tokio::sync::{Mutex, Notify, RwLock};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
use crate::disk::endpoint::Endpoint;
|
use crate::disk::endpoint::Endpoint;
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
use crate::disk::format::FormatV3;
|
use crate::disk::format::FormatV3;
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
use crate::disk::{DiskAPI, DiskOption, FORMAT_CONFIG_FILE, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE, new_disk};
|
use crate::disk::{DiskAPI, DiskOption, FORMAT_CONFIG_FILE, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE, new_disk};
|
||||||
use crate::services::tier::tier::TierConfigMgr;
|
use crate::services::tier::tier::TierConfigMgr;
|
||||||
use crate::services::tier::tier_config::{TierConfig, TierMinIO, TierType};
|
use crate::services::tier::tier_config::{TierConfig, TierMinIO, TierType};
|
||||||
use crate::services::tier::warm_backend::{
|
use crate::services::tier::warm_backend::{
|
||||||
TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options,
|
TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
use rustfs_filemeta::FileMeta;
|
use rustfs_filemeta::FileMeta;
|
||||||
use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl};
|
use rustfs_s3_client::transition_api::{ReadCloser, ReaderImpl};
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
use rustfs_utils::path::path_join_buf;
|
use rustfs_utils::path::path_join_buf;
|
||||||
|
|
||||||
/// One-shot barrier before rejected transition cleanup resolves its ECStore.
|
/// One-shot barrier before rejected transition cleanup resolves its ECStore.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub struct TransitionCleanupStoreBarrier(crate::set_disk::SetDiskTransitionCleanupStoreBarrier);
|
pub struct TransitionCleanupStoreBarrier(crate::set_disk::SetDiskTransitionCleanupStoreBarrier);
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
impl TransitionCleanupStoreBarrier {
|
impl TransitionCleanupStoreBarrier {
|
||||||
/// Install the barrier for the next rejected transition cleanup.
|
/// Install the barrier for the next rejected transition cleanup.
|
||||||
pub fn install() -> Self {
|
pub fn install() -> Self {
|
||||||
@@ -96,6 +104,7 @@ impl TransitionCleanupStoreBarrier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Default polling cadence used by the `wait_for_*` helpers.
|
/// Default polling cadence used by the `wait_for_*` helpers.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
/// A fault to inject into [`MockWarmBackend`] operations.
|
/// A fault to inject into [`MockWarmBackend`] operations.
|
||||||
@@ -208,10 +217,12 @@ impl Drop for MockRemoveOperationGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot barrier that pauses a mock tier PUT after storing its remote body.
|
/// One-shot barrier that pauses a mock tier PUT after storing its remote body.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub struct MockPutBarrier {
|
pub struct MockPutBarrier {
|
||||||
state: Arc<MockPutBarrierState>,
|
state: Arc<MockPutBarrierState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
impl MockPutBarrier {
|
impl MockPutBarrier {
|
||||||
/// Wait until the remote body is stored and the PUT is paused before returning.
|
/// Wait until the remote body is stored and the PUT is paused before returning.
|
||||||
pub async fn wait_until_paused(&self) {
|
pub async fn wait_until_paused(&self) {
|
||||||
@@ -226,6 +237,7 @@ impl MockPutBarrier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
impl Drop for MockPutBarrier {
|
impl Drop for MockPutBarrier {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.state.release.notify_one();
|
self.state.release.notify_one();
|
||||||
@@ -258,10 +270,12 @@ impl Drop for MockGetBarrier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot barrier that pauses and then fails a mock tier DELETE.
|
/// One-shot barrier that pauses and then fails a mock tier DELETE.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub struct MockRemoveBarrier {
|
pub struct MockRemoveBarrier {
|
||||||
state: Arc<MockRemoveBarrierState>,
|
state: Arc<MockRemoveBarrierState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
impl MockRemoveBarrier {
|
impl MockRemoveBarrier {
|
||||||
/// Wait until DELETE reaches the deterministic failure point.
|
/// Wait until DELETE reaches the deterministic failure point.
|
||||||
pub async fn wait_until_paused(&self) {
|
pub async fn wait_until_paused(&self) {
|
||||||
@@ -283,6 +297,7 @@ impl MockRemoveBarrier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
impl Drop for MockRemoveBarrier {
|
impl Drop for MockRemoveBarrier {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.state.release.notify_one();
|
self.state.release.notify_one();
|
||||||
@@ -306,6 +321,7 @@ impl MockWarmBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Arm a one-shot pause after the next tier PUT stores its remote body.
|
/// Arm a one-shot pause after the next tier PUT stores its remote body.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn arm_put_barrier(&self) -> MockPutBarrier {
|
pub async fn arm_put_barrier(&self) -> MockPutBarrier {
|
||||||
let state = Arc::new(MockPutBarrierState::default());
|
let state = Arc::new(MockPutBarrierState::default());
|
||||||
*self.inner.put_barrier.lock().await = Some(Arc::clone(&state));
|
*self.inner.put_barrier.lock().await = Some(Arc::clone(&state));
|
||||||
@@ -313,6 +329,7 @@ impl MockWarmBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pause and then fail the next DELETE after it reaches the backend.
|
/// Pause and then fail the next DELETE after it reaches the backend.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn arm_failing_remove_barrier(&self) -> MockRemoveBarrier {
|
pub async fn arm_failing_remove_barrier(&self) -> MockRemoveBarrier {
|
||||||
let state = Arc::new(MockRemoveBarrierState::default());
|
let state = Arc::new(MockRemoveBarrierState::default());
|
||||||
let mut barrier = self.inner.remove_barrier.lock().await;
|
let mut barrier = self.inner.remove_barrier.lock().await;
|
||||||
@@ -323,6 +340,7 @@ impl MockWarmBackend {
|
|||||||
|
|
||||||
/// Arm a one-shot pause before the next tier GET, then return an error
|
/// Arm a one-shot pause before the next tier GET, then return an error
|
||||||
/// after the test releases it.
|
/// after the test releases it.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn arm_failing_get_barrier(&self) -> MockGetBarrier {
|
pub async fn arm_failing_get_barrier(&self) -> MockGetBarrier {
|
||||||
let state = Arc::new(MockGetBarrierState {
|
let state = Arc::new(MockGetBarrierState {
|
||||||
fail_after_release: true,
|
fail_after_release: true,
|
||||||
@@ -343,6 +361,7 @@ impl MockWarmBackend {
|
|||||||
// ---- fault injection -------------------------------------------------
|
// ---- fault injection -------------------------------------------------
|
||||||
|
|
||||||
/// Replace the entire fault configuration.
|
/// Replace the entire fault configuration.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn set_faults(&self, faults: FaultConfig) {
|
pub async fn set_faults(&self, faults: FaultConfig) {
|
||||||
*self.inner.faults.lock().await = faults;
|
*self.inner.faults.lock().await = faults;
|
||||||
}
|
}
|
||||||
@@ -353,6 +372,7 @@ impl MockWarmBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle "HTTP 5xx" server errors on every operation.
|
/// Toggle "HTTP 5xx" server errors on every operation.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn set_server_error(&self, server_error: bool) {
|
pub async fn set_server_error(&self, server_error: bool) {
|
||||||
self.inner.faults.lock().await.server_error = server_error;
|
self.inner.faults.lock().await.server_error = server_error;
|
||||||
}
|
}
|
||||||
@@ -363,11 +383,13 @@ impl MockWarmBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set (or clear, with `None`) injected latency applied before each op.
|
/// Set (or clear, with `None`) injected latency applied before each op.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn set_latency(&self, latency: Option<Duration>) {
|
pub async fn set_latency(&self, latency: Option<Duration>) {
|
||||||
self.inner.faults.lock().await.latency = latency;
|
self.inner.faults.lock().await.latency = latency;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all injected faults, restoring healthy behaviour.
|
/// Clear all injected faults, restoring healthy behaviour.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn clear_faults(&self) {
|
pub async fn clear_faults(&self) {
|
||||||
*self.inner.faults.lock().await = FaultConfig::default();
|
*self.inner.faults.lock().await = FaultConfig::default();
|
||||||
}
|
}
|
||||||
@@ -375,6 +397,7 @@ impl MockWarmBackend {
|
|||||||
/// Limit how many body bytes a successful mock PUT consumes. `None` drains
|
/// Limit how many body bytes a successful mock PUT consumes. `None` drains
|
||||||
/// the complete body. This models a backend that incorrectly accepts a
|
/// the complete body. This models a backend that incorrectly accepts a
|
||||||
/// truncated stream while still returning success.
|
/// truncated stream while still returning success.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn set_put_read_limit(&self, limit: Option<usize>) {
|
pub async fn set_put_read_limit(&self, limit: Option<usize>) {
|
||||||
*self.inner.put_read_limit.lock().await = limit;
|
*self.inner.put_read_limit.lock().await = limit;
|
||||||
}
|
}
|
||||||
@@ -395,12 +418,14 @@ impl MockWarmBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reject non-empty remote versions before transition metadata is committed.
|
/// Reject non-empty remote versions before transition metadata is committed.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub fn set_reject_non_empty_remote_versions(&self, reject: bool) {
|
pub fn set_reject_non_empty_remote_versions(&self, reject: bool) {
|
||||||
self.inner.reject_non_empty_remote_versions.store(reject, Ordering::Release);
|
self.inner.reject_non_empty_remote_versions.store(reject, Ordering::Release);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reject the next non-empty remote version validation without changing
|
/// Reject the next non-empty remote version validation without changing
|
||||||
/// subsequent exact-version backend cleanup behavior.
|
/// subsequent exact-version backend cleanup behavior.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub fn reject_next_non_empty_remote_version_validation(&self) {
|
pub fn reject_next_non_empty_remote_version_validation(&self) {
|
||||||
self.inner
|
self.inner
|
||||||
.reject_non_empty_remote_version_validations
|
.reject_non_empty_remote_version_validations
|
||||||
@@ -438,6 +463,7 @@ impl MockWarmBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the operation log without touching stored objects or faults.
|
/// Clear the operation log without touching stored objects or faults.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn clear_op_log(&self) {
|
pub async fn clear_op_log(&self) {
|
||||||
self.inner.op_log.lock().await.clear();
|
self.inner.op_log.lock().await.clear();
|
||||||
}
|
}
|
||||||
@@ -459,11 +485,13 @@ impl MockWarmBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return the exact object/version pairs produced by successful tier PUTs.
|
/// Return the exact object/version pairs produced by successful tier PUTs.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn put_versions(&self) -> Vec<(String, String)> {
|
pub async fn put_versions(&self) -> Vec<(String, String)> {
|
||||||
self.inner.put_versions.lock().await.clone()
|
self.inner.put_versions.lock().await.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the exact object/version pairs passed to successful tier removes.
|
/// Return the exact object/version pairs passed to successful tier removes.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn remove_versions(&self) -> Vec<(String, String)> {
|
pub async fn remove_versions(&self) -> Vec<(String, String)> {
|
||||||
self.inner.remove_versions.lock().await.clone()
|
self.inner.remove_versions.lock().await.clone()
|
||||||
}
|
}
|
||||||
@@ -475,6 +503,7 @@ impl MockWarmBackend {
|
|||||||
|
|
||||||
/// Number of `get` calls recorded — useful to assert restore reads hit the
|
/// Number of `get` calls recorded — useful to assert restore reads hit the
|
||||||
/// local copy rather than the remote tier.
|
/// local copy rather than the remote tier.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn get_count(&self) -> usize {
|
pub async fn get_count(&self) -> usize {
|
||||||
self.inner
|
self.inner
|
||||||
.op_log
|
.op_log
|
||||||
@@ -486,6 +515,7 @@ impl MockWarmBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Number of `put` calls recorded.
|
/// Number of `put` calls recorded.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn put_count(&self) -> usize {
|
pub async fn put_count(&self) -> usize {
|
||||||
self.inner
|
self.inner
|
||||||
.op_log
|
.op_log
|
||||||
@@ -499,6 +529,7 @@ impl MockWarmBackend {
|
|||||||
// ---- storage inspection ---------------------------------------------
|
// ---- storage inspection ---------------------------------------------
|
||||||
|
|
||||||
/// Whether the backend currently stores `object`.
|
/// Whether the backend currently stores `object`.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn contains(&self, object: &str) -> bool {
|
pub async fn contains(&self, object: &str) -> bool {
|
||||||
self.inner.objects.lock().await.contains_key(object)
|
self.inner.objects.lock().await.contains_key(object)
|
||||||
}
|
}
|
||||||
@@ -509,11 +540,13 @@ impl MockWarmBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A clone of the stored object, if present.
|
/// A clone of the stored object, if present.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn stored(&self, object: &str) -> Option<MockStoredObject> {
|
pub async fn stored(&self, object: &str) -> Option<MockStoredObject> {
|
||||||
self.inner.objects.lock().await.get(object).cloned()
|
self.inner.objects.lock().await.get(object).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A clone of the raw bytes stored for `object`, if present.
|
/// A clone of the raw bytes stored for `object`, if present.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn bytes(&self, object: &str) -> Option<Vec<u8>> {
|
pub async fn bytes(&self, object: &str) -> Option<Vec<u8>> {
|
||||||
self.inner.objects.lock().await.get(object).map(|o| o.bytes.clone())
|
self.inner.objects.lock().await.get(object).map(|o| o.bytes.clone())
|
||||||
}
|
}
|
||||||
@@ -538,6 +571,7 @@ impl MockWarmBackend {
|
|||||||
|
|
||||||
/// Poll until `object` is absent from the backend, or `timeout` elapses.
|
/// Poll until `object` is absent from the backend, or `timeout` elapses.
|
||||||
/// Returns `true` if the object disappeared within the budget.
|
/// Returns `true` if the object disappeared within the budget.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn wait_for_remote_absence(&self, object: &str, timeout: Duration) -> bool {
|
pub async fn wait_for_remote_absence(&self, object: &str, timeout: Duration) -> bool {
|
||||||
let deadline = tokio::time::Instant::now() + timeout;
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
@@ -553,6 +587,7 @@ impl MockWarmBackend {
|
|||||||
|
|
||||||
/// Poll until the backend holds exactly `expected` objects, or `timeout`
|
/// Poll until the backend holds exactly `expected` objects, or `timeout`
|
||||||
/// elapses. Returns `true` if the count was reached within the budget.
|
/// elapses. Returns `true` if the count was reached within the budget.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn wait_for_object_count(&self, expected: usize, timeout: Duration) -> bool {
|
pub async fn wait_for_object_count(&self, expected: usize, timeout: Duration) -> bool {
|
||||||
let deadline = tokio::time::Instant::now() + timeout;
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
@@ -701,7 +736,7 @@ impl WarmBackend for MockWarmBackend {
|
|||||||
Ok(version)
|
Ok(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get(&self, object: &str, _rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
|
async fn get(&self, object: &str, rv: &str, opts: WarmBackendGetOpts) -> Result<ReadCloser, std::io::Error> {
|
||||||
self.precondition().await?;
|
self.precondition().await?;
|
||||||
let barrier = self.inner.get_barrier.lock().await.take();
|
let barrier = self.inner.get_barrier.lock().await.take();
|
||||||
if let Some(barrier) = barrier {
|
if let Some(barrier) = barrier {
|
||||||
@@ -719,6 +754,9 @@ impl WarmBackend for MockWarmBackend {
|
|||||||
let Some(stored) = objects.get(object) else {
|
let Some(stored) = objects.get(object) else {
|
||||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "mock object not found"));
|
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "mock object not found"));
|
||||||
};
|
};
|
||||||
|
if !rv.is_empty() && stored.remote_version_id != rv {
|
||||||
|
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "NoSuchVersion"));
|
||||||
|
}
|
||||||
let bytes = &stored.bytes;
|
let bytes = &stored.bytes;
|
||||||
|
|
||||||
let start = opts.start_offset.max(0) as usize;
|
let start = opts.start_offset.max(0) as usize;
|
||||||
@@ -844,6 +882,7 @@ pub async fn register_mock_tier_backend(handle: &Arc<RwLock<TierConfigMgr>>, tie
|
|||||||
/// The transition-state tuple read from an on-disk `xl.meta`, plus the object's
|
/// The transition-state tuple read from an on-disk `xl.meta`, plus the object's
|
||||||
/// free-version count.
|
/// free-version count.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub struct TransitionMeta {
|
pub struct TransitionMeta {
|
||||||
/// `transition_status` (e.g. `"complete"`), empty when not transitioned.
|
/// `transition_status` (e.g. `"complete"`), empty when not transitioned.
|
||||||
pub status: String,
|
pub status: String,
|
||||||
@@ -857,6 +896,7 @@ pub struct TransitionMeta {
|
|||||||
pub free_version_count: usize,
|
pub free_version_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
async fn open_disk(disk_path: &Path) -> Option<crate::disk::DiskStore> {
|
async fn open_disk(disk_path: &Path) -> Option<crate::disk::DiskStore> {
|
||||||
// `LocalDisk::new` rejects an endpoint whose (set_idx, disk_idx) disagrees
|
// `LocalDisk::new` rejects an endpoint whose (set_idx, disk_idx) disagrees
|
||||||
// with the position recorded in the disk's own format.json, so derive the
|
// with the position recorded in the disk's own format.json, so derive the
|
||||||
@@ -887,6 +927,7 @@ async fn open_disk(disk_path: &Path) -> Option<crate::disk::DiskStore> {
|
|||||||
/// The free-version metadata removal lands asynchronously after the remote
|
/// The free-version metadata removal lands asynchronously after the remote
|
||||||
/// object disappears, so callers typically poll via
|
/// object disappears, so callers typically poll via
|
||||||
/// [`wait_for_free_version_absence`] instead of asserting a single read.
|
/// [`wait_for_free_version_absence`] instead of asserting a single read.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn free_version_count(disk_path: &Path, bucket: &str, object: &str) -> usize {
|
pub async fn free_version_count(disk_path: &Path, bucket: &str, object: &str) -> usize {
|
||||||
let Some(disk) = open_disk(disk_path).await else {
|
let Some(disk) = open_disk(disk_path).await else {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -911,6 +952,7 @@ pub async fn free_version_count(disk_path: &Path, bucket: &str, object: &str) ->
|
|||||||
/// fields are taken from the newest version that carries a transition record;
|
/// fields are taken from the newest version that carries a transition record;
|
||||||
/// if no version is transitioned, they are taken from the current version (and
|
/// if no version is transitioned, they are taken from the current version (and
|
||||||
/// will be empty).
|
/// will be empty).
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn read_transition_meta(disk_path: &Path, bucket: &str, object: &str) -> Option<TransitionMeta> {
|
pub async fn read_transition_meta(disk_path: &Path, bucket: &str, object: &str) -> Option<TransitionMeta> {
|
||||||
let disk = open_disk(disk_path).await?;
|
let disk = open_disk(disk_path).await?;
|
||||||
let data = disk
|
let data = disk
|
||||||
@@ -944,6 +986,7 @@ pub async fn read_transition_meta(disk_path: &Path, bucket: &str, object: &str)
|
|||||||
/// disk is missing the object or disagrees — this is the shard-consistency
|
/// disk is missing the object or disagrees — this is the shard-consistency
|
||||||
/// check required by ilm-6 (the `(status, tier, remote key, remote version id)`
|
/// check required by ilm-6 (the `(status, tier, remote key, remote version id)`
|
||||||
/// four-tuple plus free-version count must match across all erasure shards).
|
/// four-tuple plus free-version count must match across all erasure shards).
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn assert_transition_meta_consistent<P: AsRef<Path>>(disk_paths: &[P], bucket: &str, object: &str) -> TransitionMeta {
|
pub async fn assert_transition_meta_consistent<P: AsRef<Path>>(disk_paths: &[P], bucket: &str, object: &str) -> TransitionMeta {
|
||||||
assert!(!disk_paths.is_empty(), "assert_transition_meta_consistent needs at least one disk");
|
assert!(!disk_paths.is_empty(), "assert_transition_meta_consistent needs at least one disk");
|
||||||
|
|
||||||
@@ -969,6 +1012,7 @@ pub async fn assert_transition_meta_consistent<P: AsRef<Path>>(disk_paths: &[P],
|
|||||||
|
|
||||||
/// Poll until `object` retains no free versions on `disk_path`, or `timeout`
|
/// Poll until `object` retains no free versions on `disk_path`, or `timeout`
|
||||||
/// elapses. Returns `true` if the free versions drained within the budget.
|
/// elapses. Returns `true` if the free versions drained within the budget.
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
pub async fn wait_for_free_version_absence(disk_path: &Path, bucket: &str, object: &str, timeout: Duration) -> bool {
|
pub async fn wait_for_free_version_absence(disk_path: &Path, bucket: &str, object: &str, timeout: Duration) -> bool {
|
||||||
let deadline = tokio::time::Instant::now() + timeout;
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
loop {
|
loop {
|
||||||
@@ -1037,6 +1081,44 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_metadata_survives_put_and_external_delete_is_distinct() {
|
||||||
|
let backend = MockWarmBackend::new();
|
||||||
|
let metadata = HashMap::from([
|
||||||
|
("content-type".to_string(), "text/plain".to_string()),
|
||||||
|
("project".to_string(), "archive".to_string()),
|
||||||
|
]);
|
||||||
|
let version = backend
|
||||||
|
.put_with_meta("object", ReaderImpl::Body(Bytes::from_static(b"body")), 4, metadata.clone())
|
||||||
|
.await
|
||||||
|
.expect("mock PUT should preserve remote metadata");
|
||||||
|
assert_eq!(backend.metadata("object").await, Some(metadata));
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.probe_transition_candidate_state("object")
|
||||||
|
.await
|
||||||
|
.expect("probe stored object"),
|
||||||
|
TransitionCandidateProbe::VersionedPresent(version)
|
||||||
|
);
|
||||||
|
|
||||||
|
backend.external_remove("object").await;
|
||||||
|
assert_eq!(backend.metadata("object").await, None);
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.probe_transition_candidate_state("object")
|
||||||
|
.await
|
||||||
|
.expect("probe removed object"),
|
||||||
|
TransitionCandidateProbe::Missing
|
||||||
|
);
|
||||||
|
let operations = backend.op_log().await;
|
||||||
|
assert!(
|
||||||
|
operations
|
||||||
|
.iter()
|
||||||
|
.any(|op| matches!(op, MockWarmOp::ExternalRemove { object } if object == "object"))
|
||||||
|
);
|
||||||
|
assert!(!operations.iter().any(|op| matches!(op, MockWarmOp::Remove { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mock_probe_preserves_fault_fail_closed_behavior() {
|
async fn mock_probe_preserves_fault_fail_closed_behavior() {
|
||||||
let backend = MockWarmBackend::new();
|
let backend = MockWarmBackend::new();
|
||||||
|
|||||||
@@ -143,11 +143,12 @@ struct TierDriverBuildBarrier {
|
|||||||
static TIER_DRIVER_BUILD_BARRIER: LazyLock<Mutex<Option<Arc<TierDriverBuildBarrier>>>> = LazyLock::new(|| Mutex::new(None));
|
static TIER_DRIVER_BUILD_BARRIER: LazyLock<Mutex<Option<Arc<TierDriverBuildBarrier>>>> = LazyLock::new(|| Mutex::new(None));
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
type TierDriverTestFactory = Arc<dyn Fn(&TierConfig) -> std::result::Result<WarmBackendImpl, AdminError> + Send + Sync + 'static>;
|
pub(crate) type TierDriverTestFactory =
|
||||||
|
Arc<dyn Fn(&TierConfig) -> std::result::Result<WarmBackendImpl, AdminError> + Send + Sync + 'static>;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
tokio::task_local! {
|
tokio::task_local! {
|
||||||
static TIER_DRIVER_TEST_FACTORY: TierDriverTestFactory;
|
pub(crate) static TIER_DRIVER_TEST_FACTORY: TierDriverTestFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1377,27 +1378,42 @@ async fn ensure_no_authoritative_persisted_references<S>(
|
|||||||
where
|
where
|
||||||
S: TierReferenceProofStore,
|
S: TierReferenceProofStore,
|
||||||
{
|
{
|
||||||
ensure_no_authoritative_persisted_references_with(api.clone(), TIER_DELETE_JOURNAL_PREFIX, |_object, data| {
|
ensure_no_authoritative_persisted_references_with(
|
||||||
|
api.clone(),
|
||||||
|
TIER_DELETE_JOURNAL_PREFIX,
|
||||||
|
"tier-delete journal",
|
||||||
|
|_object, data| {
|
||||||
let journal = decode_tier_delete_journal_entry(data).map_err(io::Error::other)?;
|
let journal = decode_tier_delete_journal_entry(data).map_err(io::Error::other)?;
|
||||||
Ok((
|
Ok((
|
||||||
journal.tier_name.clone(),
|
journal.tier_name.clone(),
|
||||||
tier_persisted_reference_blocks_any_target(&journal.tier_name, journal.backend_identity, targets),
|
tier_persisted_reference_blocks_any_target(&journal.tier_name, journal.backend_identity, targets),
|
||||||
))
|
))
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ensure_no_authoritative_persisted_references_with(api, TRANSITION_TRANSACTION_RECORD_PREFIX, |object, data| {
|
ensure_no_authoritative_persisted_references_with(
|
||||||
|
api,
|
||||||
|
TRANSITION_TRANSACTION_RECORD_PREFIX,
|
||||||
|
"transition transaction",
|
||||||
|
|object, data| {
|
||||||
let transaction = decode_transition_transaction_record(object, data).map_err(io::Error::other)?;
|
let transaction = decode_transition_transaction_record(object, data).map_err(io::Error::other)?;
|
||||||
Ok((
|
Ok((
|
||||||
transaction.tier_name.clone(),
|
transaction.tier_name.clone(),
|
||||||
tier_persisted_reference_blocks_any_target(&transaction.tier_name, Some(transaction.backend_fingerprint), targets),
|
tier_persisted_reference_blocks_any_target(
|
||||||
|
&transaction.tier_name,
|
||||||
|
Some(transaction.backend_fingerprint),
|
||||||
|
targets,
|
||||||
|
),
|
||||||
))
|
))
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_no_authoritative_persisted_references_with<S, F>(
|
async fn ensure_no_authoritative_persisted_references_with<S, F>(
|
||||||
api: Arc<S>,
|
api: Arc<S>,
|
||||||
prefix: &str,
|
prefix: &str,
|
||||||
|
reference_kind: &str,
|
||||||
blocks_target: F,
|
blocks_target: F,
|
||||||
) -> std::result::Result<(), AdminError>
|
) -> std::result::Result<(), AdminError>
|
||||||
where
|
where
|
||||||
@@ -1426,7 +1442,7 @@ where
|
|||||||
.map_err(tier_reference_proof_admin_error)?;
|
.map_err(tier_reference_proof_admin_error)?;
|
||||||
let (tier_name, blocks) = blocks_target(&object.name, &data).map_err(tier_reference_proof_admin_error)?;
|
let (tier_name, blocks) = blocks_target(&object.name, &data).map_err(tier_reference_proof_admin_error)?;
|
||||||
if blocks {
|
if blocks {
|
||||||
return Err(tier_reference_proof_persisted_in_use_error(&tier_name, &object.name));
|
return Err(tier_reference_proof_persisted_in_use_error(&tier_name, reference_kind, &object.name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !page.is_truncated {
|
if !page.is_truncated {
|
||||||
@@ -1488,16 +1504,21 @@ fn tier_persisted_reference_blocks_target(
|
|||||||
|
|
||||||
fn tier_reference_proof_in_use_error(tier_name: &str, object: &ObjectInfo) -> AdminError {
|
fn tier_reference_proof_in_use_error(tier_name: &str, object: &ObjectInfo) -> AdminError {
|
||||||
let mut err = ERR_TIER_BACKEND_IN_USE.clone();
|
let mut err = ERR_TIER_BACKEND_IN_USE.clone();
|
||||||
|
let reference_kind = if object.transitioned_object.free_version {
|
||||||
|
"free-version ownership"
|
||||||
|
} else {
|
||||||
|
"transitioned-object"
|
||||||
|
};
|
||||||
err.message = format!(
|
err.message = format!(
|
||||||
"Remote tier {tier_name} still has object references, for example {}/{}",
|
"Remote tier {tier_name} still has a {reference_kind} reference, for example {}/{}",
|
||||||
object.bucket, object.name
|
object.bucket, object.name
|
||||||
);
|
);
|
||||||
err
|
err
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tier_reference_proof_persisted_in_use_error(tier_name: &str, object: &str) -> AdminError {
|
fn tier_reference_proof_persisted_in_use_error(tier_name: &str, reference_kind: &str, object: &str) -> AdminError {
|
||||||
let mut err = ERR_TIER_BACKEND_IN_USE.clone();
|
let mut err = ERR_TIER_BACKEND_IN_USE.clone();
|
||||||
err.message = format!("Remote tier {tier_name} still has a persisted reference, for example {object}");
|
err.message = format!("Remote tier {tier_name} still has a {reference_kind} reference, for example {object}");
|
||||||
err
|
err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2346,6 +2367,10 @@ impl WarmBackend for SharedWarmBackendProxy {
|
|||||||
self.0.probe_transition_candidate(object).await
|
self.0.probe_transition_candidate(object).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn probe_transition_version(&self, object: &str, remote_version_id: &str) -> io::Result<TransitionCandidateProbe> {
|
||||||
|
self.0.probe_transition_version(object, remote_version_id).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn in_use(&self) -> io::Result<bool> {
|
async fn in_use(&self) -> io::Result<bool> {
|
||||||
self.0.in_use().await
|
self.0.in_use().await
|
||||||
}
|
}
|
||||||
@@ -2458,6 +2483,15 @@ impl TierOperationLease {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn probe_transition_version(
|
||||||
|
&self,
|
||||||
|
object: &str,
|
||||||
|
remote_version_id: &str,
|
||||||
|
) -> io::Result<TransitionCandidateProbe> {
|
||||||
|
self.validate_remote_version_id(remote_version_id)?;
|
||||||
|
self.inner.driver.probe_transition_version(object, remote_version_id).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn is_current_generation(&self) -> bool {
|
pub(crate) fn is_current_generation(&self) -> bool {
|
||||||
lock_unpoisoned(&self.runtime)
|
lock_unpoisoned(&self.runtime)
|
||||||
.generations
|
.generations
|
||||||
@@ -3528,7 +3562,7 @@ impl TierConfigMgr {
|
|||||||
// Get tier configuration and create new driver
|
// Get tier configuration and create new driver
|
||||||
let tier_config = self.tiers.get(tier_name).ok_or_else(|| ERR_TIER_NOT_FOUND.clone())?;
|
let tier_config = self.tiers.get(tier_name).ok_or_else(|| ERR_TIER_NOT_FOUND.clone())?;
|
||||||
|
|
||||||
let driver = new_warm_backend(tier_config, false).await?;
|
let driver = construct_warm_backend(tier_config).await?;
|
||||||
|
|
||||||
self.replace_driver(tier_name, driver)?;
|
self.replace_driver(tier_name, driver)?;
|
||||||
Ok(self
|
Ok(self
|
||||||
@@ -3672,13 +3706,17 @@ impl TierConfigMgr {
|
|||||||
let manager = handle.read().await;
|
let manager = handle.read().await;
|
||||||
let runtime = tier_driver_runtime(handle, &manager);
|
let runtime = tier_driver_runtime(handle, &manager);
|
||||||
let runtime = lock_unpoisoned(&runtime);
|
let runtime = lock_unpoisoned(&runtime);
|
||||||
if !runtime
|
let prepared = runtime
|
||||||
|
.prepared_mutation_blocks
|
||||||
|
.values()
|
||||||
|
.any(|blocked_mutation_id| *blocked_mutation_id == mutation_id);
|
||||||
|
let committed = runtime
|
||||||
.committed_mutation_blocks
|
.committed_mutation_blocks
|
||||||
.values()
|
.values()
|
||||||
.any(|mutation_ids| mutation_ids.contains(&mutation_id))
|
.any(|mutation_ids| mutation_ids.contains(&mutation_id));
|
||||||
{
|
if !prepared && !committed {
|
||||||
let mut err = ERR_TIER_INVALID_CONFIG.clone();
|
let mut err = ERR_TIER_INVALID_CONFIG.clone();
|
||||||
err.message = "Remote tier committed mutation fence was not installed".to_string();
|
err.message = "Remote tier mutation fence was not installed".to_string();
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
Ok(MutationBlockAllowance {
|
Ok(MutationBlockAllowance {
|
||||||
@@ -3885,14 +3923,6 @@ impl TierConfigMgr {
|
|||||||
Self::begin_tier_transition_with_destinations(handle, manager, changed, replaced_destinations, mutation_block_allowance)
|
Self::begin_tier_transition_with_destinations(handle, manager, changed, replaced_destinations, mutation_block_allowance)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn begin_tier_transition(
|
|
||||||
handle: &Arc<RwLock<Self>>,
|
|
||||||
manager: &mut Self,
|
|
||||||
changed: HashSet<String>,
|
|
||||||
) -> std::result::Result<TierPublishTransition, AdminError> {
|
|
||||||
Self::begin_tier_transition_with_destinations(handle, manager, changed, HashMap::new(), None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn begin_tier_transition_with_destinations(
|
fn begin_tier_transition_with_destinations(
|
||||||
handle: &Arc<RwLock<Self>>,
|
handle: &Arc<RwLock<Self>>,
|
||||||
manager: &mut Self,
|
manager: &mut Self,
|
||||||
@@ -4163,7 +4193,7 @@ impl TierConfigMgr {
|
|||||||
let mut config_lock = config_lock;
|
let mut config_lock = config_lock;
|
||||||
let coordinated_config_update = config_lock.is_some();
|
let coordinated_config_update = config_lock.is_some();
|
||||||
let mut update = Some(update);
|
let mut update = Some(update);
|
||||||
let (mutation_kind, explicit_tier_name, mutation_force, current_for_targets, driver_tier, mut transition) =
|
let (mutation_kind, explicit_tier_name, mutation_force, current_for_targets, driver_tier, target_tiers) =
|
||||||
match mutation {
|
match mutation {
|
||||||
TierCandidateMutation::Prevalidated(prepared) => {
|
TierCandidateMutation::Prevalidated(prepared) => {
|
||||||
if version != prepared.version {
|
if version != prepared.version {
|
||||||
@@ -4179,9 +4209,8 @@ impl TierConfigMgr {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
candidate = prepared.candidate;
|
candidate = prepared.candidate;
|
||||||
let validation_deadline = Instant::now() + TIER_REMOTE_VALIDATION_TIMEOUT;
|
let target_tiers = {
|
||||||
let mut transition = {
|
let manager = handle.read().await;
|
||||||
let mut manager = handle.write().await;
|
|
||||||
let mut target_tiers = changed_tier_names(&manager, &candidate);
|
let mut target_tiers = changed_tier_names(&manager, &candidate);
|
||||||
if let Some(tier_name) = prepared.explicit_tier_name.as_ref()
|
if let Some(tier_name) = prepared.explicit_tier_name.as_ref()
|
||||||
&& (manager.tiers.contains_key(tier_name)
|
&& (manager.tiers.contains_key(tier_name)
|
||||||
@@ -4190,8 +4219,7 @@ impl TierConfigMgr {
|
|||||||
{
|
{
|
||||||
target_tiers.insert(tier_name.clone());
|
target_tiers.insert(tier_name.clone());
|
||||||
}
|
}
|
||||||
Self::begin_tier_transition(&handle, &mut manager, target_tiers)
|
target_tiers
|
||||||
.map_err(TierConfigUpdateError::Publish)?
|
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
prepared.kind,
|
prepared.kind,
|
||||||
@@ -4199,7 +4227,7 @@ impl TierConfigMgr {
|
|||||||
prepared.force,
|
prepared.force,
|
||||||
prepared.current,
|
prepared.current,
|
||||||
prepared.driver_tier,
|
prepared.driver_tier,
|
||||||
transition,
|
target_tiers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
mutation => {
|
mutation => {
|
||||||
@@ -4224,11 +4252,9 @@ impl TierConfigMgr {
|
|||||||
last_refreshed_at: candidate.last_refreshed_at,
|
last_refreshed_at: candidate.last_refreshed_at,
|
||||||
};
|
};
|
||||||
let validation_deadline = Instant::now() + TIER_REMOTE_VALIDATION_TIMEOUT;
|
let validation_deadline = Instant::now() + TIER_REMOTE_VALIDATION_TIMEOUT;
|
||||||
let mut transition = {
|
let target_tiers = {
|
||||||
let mut manager = handle.write().await;
|
let manager = handle.read().await;
|
||||||
let target_tiers = mutation.target_tiers(&manager, &candidate);
|
mutation.target_tiers(&manager, &candidate)
|
||||||
Self::begin_tier_transition(&handle, &mut manager, target_tiers)
|
|
||||||
.map_err(TierConfigUpdateError::Publish)?
|
|
||||||
};
|
};
|
||||||
let driver_tier = apply_tier_candidate_mutation(mutation, &mut candidate, validation_deadline)
|
let driver_tier = apply_tier_candidate_mutation(mutation, &mut candidate, validation_deadline)
|
||||||
.await
|
.await
|
||||||
@@ -4239,7 +4265,7 @@ impl TierConfigMgr {
|
|||||||
mutation_force,
|
mutation_force,
|
||||||
current_for_targets,
|
current_for_targets,
|
||||||
driver_tier,
|
driver_tier,
|
||||||
transition,
|
target_tiers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -4262,28 +4288,83 @@ impl TierConfigMgr {
|
|||||||
save_coordinator_tier_mutation_intent(api.clone(), coordinator_intent.as_ref())
|
save_coordinator_tier_mutation_intent(api.clone(), coordinator_intent.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(TierConfigUpdateError::Save)?;
|
.map_err(TierConfigUpdateError::Save)?;
|
||||||
|
let mut blocked_target_tiers = target_tiers.clone();
|
||||||
if let Some(intent) = coordinator_intent.as_ref() {
|
if let Some(intent) = coordinator_intent.as_ref() {
|
||||||
TierConfigMgr::apply_prepared_mutation_intent_block(&handle, intent)
|
blocked_target_tiers.extend(intent.affected_targets.iter().map(|target| target.tier_name.clone()));
|
||||||
|
}
|
||||||
|
if let Some(intent) = coordinator_intent.as_ref() {
|
||||||
|
// `target_tiers` may include a stale local-only manager
|
||||||
|
// entry that is absent from the persisted proof
|
||||||
|
// snapshot. Fence that local transition under the same
|
||||||
|
// mutation ID as well; recovery may discard this
|
||||||
|
// process-local superset, which advances the revision
|
||||||
|
// and makes the deferred transition fail closed.
|
||||||
|
TierConfigMgr::apply_prepared_mutation_intent_block_for_tiers(&handle, intent, &blocked_target_tiers)
|
||||||
.await
|
.await
|
||||||
.map_err(TierConfigUpdateError::Publish)?;
|
.map_err(TierConfigUpdateError::Publish)?;
|
||||||
}
|
}
|
||||||
|
let prepared_mutation_block_allowance = match coordinator_intent.as_ref() {
|
||||||
|
Some(intent) => Some(
|
||||||
|
TierConfigMgr::mutation_block_allowance_for(&handle, intent.mutation_id)
|
||||||
|
.await
|
||||||
|
.map_err(TierConfigUpdateError::Publish)?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
// A durable coordinator intent supplies the admission
|
||||||
|
// fence that lets us defer generation revocation. Keep the
|
||||||
|
// original early transition for no-intent paths (for
|
||||||
|
// example, reconciling a stale local manager to an
|
||||||
|
// idempotently removed persisted tier), where there is no
|
||||||
|
// Prepared record capable of blocking a new lease.
|
||||||
|
let (mut transition, deferred_target_tiers) = if coordinator_intent.is_some() {
|
||||||
|
(None, Some(target_tiers))
|
||||||
|
} else {
|
||||||
|
let transition = {
|
||||||
|
let mut manager = handle.write().await;
|
||||||
|
Self::begin_tier_transition_with_destinations(
|
||||||
|
&handle,
|
||||||
|
&mut manager,
|
||||||
|
target_tiers,
|
||||||
|
HashMap::new(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(TierConfigUpdateError::Publish)?
|
||||||
|
};
|
||||||
|
(Some(transition), None)
|
||||||
|
};
|
||||||
if coordinated_config_update {
|
if coordinated_config_update {
|
||||||
drop(update.take());
|
drop(update.take());
|
||||||
drop(config_lock.take());
|
drop(config_lock.take());
|
||||||
}
|
}
|
||||||
let drain_deadline = Instant::now() + TIER_REMOTE_VALIDATION_TIMEOUT;
|
// The durable Prepared block closes admission before the
|
||||||
if let Err(drain_error) = transition.wait_for_active_leases_until(drain_deadline).await {
|
// zero-reference proof, but deliberately leaves already
|
||||||
if !abort_prepared_tier_mutation(&handle, api.clone(), coordinator_intent.as_ref(), Vec::new()).await {
|
// issued generations current. In particular, an exact
|
||||||
|
// free-version cleanup that has completed remote DELETE
|
||||||
|
// must still be able to remove its local ownership marker;
|
||||||
|
// revoking its generation here would strand that marker
|
||||||
|
// and make this mutation reject its own interrupted work.
|
||||||
|
if let Some(intent) = coordinator_intent.as_ref()
|
||||||
|
&& let Err(drain_error) =
|
||||||
|
TierConfigMgr::wait_for_blocked_tier_operation_leases_for_tiers(&handle, &blocked_target_tiers).await
|
||||||
|
{
|
||||||
|
if !abort_prepared_tier_mutation(&handle, api.clone(), Some(intent), Vec::new()).await {
|
||||||
warn!(
|
warn!(
|
||||||
event = "tier_mutation_abort",
|
event = "tier_mutation_abort",
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
subsystem = LOG_SUBSYSTEM_TIER,
|
subsystem = LOG_SUBSYSTEM_TIER,
|
||||||
result = "prepared_intent_retained",
|
result = "prepared_intent_retained",
|
||||||
coordinator_intent = coordinator_intent.is_some(),
|
mutation_id = %intent.mutation_id,
|
||||||
"tier mutation lease drain failed and abort was incomplete"
|
"tier mutation lease drain failed and abort was incomplete"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Err(TierConfigUpdateError::Publish(drain_error));
|
return Err(TierConfigUpdateError::Publish(drain_error));
|
||||||
|
} else if let Some(transition) = transition.as_ref() {
|
||||||
|
let drain_deadline = Instant::now() + TIER_REMOTE_VALIDATION_TIMEOUT;
|
||||||
|
transition
|
||||||
|
.wait_for_active_leases_until(drain_deadline)
|
||||||
|
.await
|
||||||
|
.map_err(TierConfigUpdateError::Publish)?;
|
||||||
}
|
}
|
||||||
let prepared_peers = if let Some(intent) = coordinator_intent.as_ref() {
|
let prepared_peers = if let Some(intent) = coordinator_intent.as_ref() {
|
||||||
let peers = match remote_tier_mutation_peers().await {
|
let peers = match remote_tier_mutation_peers().await {
|
||||||
@@ -4350,6 +4431,71 @@ impl TierConfigMgr {
|
|||||||
}
|
}
|
||||||
return Err(TierConfigUpdateError::Publish(proof_error));
|
return Err(TierConfigUpdateError::Publish(proof_error));
|
||||||
}
|
}
|
||||||
|
// No affected-tier lease can start after Prepared, and the
|
||||||
|
// existing set was drained above. It is now safe to revoke
|
||||||
|
// the generation for publication without invalidating a
|
||||||
|
// cleanup between its remote and local commit boundaries.
|
||||||
|
if transition.is_none() {
|
||||||
|
let target_tiers = deferred_target_tiers.ok_or_else(|| {
|
||||||
|
let mut err = ERR_TIER_INVALID_CONFIG.clone();
|
||||||
|
err.message = "Remote tier mutation lost its deferred transition targets".to_string();
|
||||||
|
TierConfigUpdateError::Publish(err)
|
||||||
|
})?;
|
||||||
|
let mut manager = handle.write().await;
|
||||||
|
transition = Some(
|
||||||
|
match Self::begin_tier_transition_with_destinations(
|
||||||
|
&handle,
|
||||||
|
&mut manager,
|
||||||
|
target_tiers,
|
||||||
|
HashMap::new(),
|
||||||
|
prepared_mutation_block_allowance.as_ref(),
|
||||||
|
) {
|
||||||
|
Ok(transition) => transition,
|
||||||
|
Err(transition_error) => {
|
||||||
|
drop(manager);
|
||||||
|
if !abort_prepared_tier_mutation(
|
||||||
|
&handle,
|
||||||
|
api.clone(),
|
||||||
|
coordinator_intent.as_ref(),
|
||||||
|
prepared_peers,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
event = "tier_mutation_abort",
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_TIER,
|
||||||
|
result = "prepared_intent_retained",
|
||||||
|
coordinator_intent = coordinator_intent.is_some(),
|
||||||
|
"tier mutation publish transition failed and abort was incomplete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(TierConfigUpdateError::Publish(transition_error));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut transition = transition.ok_or_else(|| {
|
||||||
|
let mut err = ERR_TIER_INVALID_CONFIG.clone();
|
||||||
|
err.message = "Remote tier mutation lost its publish transition".to_string();
|
||||||
|
TierConfigUpdateError::Publish(err)
|
||||||
|
})?;
|
||||||
|
let drain_deadline = Instant::now() + TIER_REMOTE_VALIDATION_TIMEOUT;
|
||||||
|
if let Err(drain_error) = transition.wait_for_active_leases_until(drain_deadline).await {
|
||||||
|
drop(transition);
|
||||||
|
if !abort_prepared_tier_mutation(&handle, api.clone(), coordinator_intent.as_ref(), prepared_peers).await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
event = "tier_mutation_abort",
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_TIER,
|
||||||
|
result = "prepared_intent_retained",
|
||||||
|
coordinator_intent = coordinator_intent.is_some(),
|
||||||
|
"tier mutation publish drain failed and abort was incomplete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(TierConfigUpdateError::Publish(drain_error));
|
||||||
|
}
|
||||||
let candidate_digest = tier_config_candidate_digest(&candidate).map_err(TierConfigUpdateError::Save)?;
|
let candidate_digest = tier_config_candidate_digest(&candidate).map_err(TierConfigUpdateError::Save)?;
|
||||||
if coordinated_config_update {
|
if coordinated_config_update {
|
||||||
config_lock = match Self::acquire_tier_config_write_lock(api.clone()).await {
|
config_lock = match Self::acquire_tier_config_write_lock(api.clone()).await {
|
||||||
@@ -4473,6 +4619,11 @@ impl TierConfigMgr {
|
|||||||
let committed_coordinator_intent =
|
let committed_coordinator_intent =
|
||||||
committed_tier_mutation_intent(coordinator_intent.as_ref(), &committed_config_etag)
|
committed_tier_mutation_intent(coordinator_intent.as_ref(), &committed_config_etag)
|
||||||
.map_err(TierConfigUpdateError::Save)?;
|
.map_err(TierConfigUpdateError::Save)?;
|
||||||
|
// Persist Committed before notifying refresh; a Prepared disk record
|
||||||
|
// would restore the prepared block and invalidate our publish allowance.
|
||||||
|
let coordinator_commit =
|
||||||
|
commit_coordinator_tier_mutation_intent(api.clone(), coordinator_intent.as_ref(), &committed_config_etag)
|
||||||
|
.await;
|
||||||
if let Some(intent) = committed_coordinator_intent.as_ref() {
|
if let Some(intent) = committed_coordinator_intent.as_ref() {
|
||||||
TierConfigMgr::apply_committed_mutation_intent_block(&handle, intent)
|
TierConfigMgr::apply_committed_mutation_intent_block(&handle, intent)
|
||||||
.await
|
.await
|
||||||
@@ -4483,9 +4634,9 @@ impl TierConfigMgr {
|
|||||||
.map_err(TierConfigUpdateError::Publish)?,
|
.map_err(TierConfigUpdateError::Publish)?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
commit_coordinator_tier_mutation_intent(api.clone(), coordinator_intent.as_ref(), &committed_config_etag)
|
// Config is already saved: retain the committed fence and wake recovery
|
||||||
.await
|
// even when the coordinator commit failed or its outcome is unknown.
|
||||||
.map_err(TierConfigUpdateError::Save)?;
|
coordinator_commit.map_err(TierConfigUpdateError::Save)?;
|
||||||
if coordinated_config_update {
|
if coordinated_config_update {
|
||||||
drop(update.take());
|
drop(update.take());
|
||||||
drop(config_lock.take());
|
drop(config_lock.take());
|
||||||
@@ -5396,11 +5547,40 @@ impl TierConfigMgr {
|
|||||||
handle: &Arc<RwLock<Self>>,
|
handle: &Arc<RwLock<Self>>,
|
||||||
intent: &TierMutationIntent,
|
intent: &TierMutationIntent,
|
||||||
) -> std::result::Result<(), AdminError> {
|
) -> std::result::Result<(), AdminError> {
|
||||||
|
let target_tiers = intent
|
||||||
|
.affected_targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| target.tier_name.clone())
|
||||||
|
.collect();
|
||||||
|
Self::apply_prepared_mutation_intent_block_for_tiers(handle, intent, &target_tiers).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_prepared_mutation_intent_block_for_tiers(
|
||||||
|
handle: &Arc<RwLock<Self>>,
|
||||||
|
intent: &TierMutationIntent,
|
||||||
|
target_tiers: &HashSet<String>,
|
||||||
|
) -> std::result::Result<(), AdminError> {
|
||||||
|
if intent.state != TierMutationIntentState::Prepared {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let manager = handle.read().await;
|
let manager = handle.read().await;
|
||||||
let runtime = tier_driver_runtime(handle, &manager);
|
let runtime = tier_driver_runtime(handle, &manager);
|
||||||
let mut runtime = lock_unpoisoned(&runtime);
|
let mut runtime = lock_unpoisoned(&runtime);
|
||||||
let mut prepared_mutation_blocks = runtime.prepared_mutation_blocks.clone();
|
let mut prepared_mutation_blocks = runtime.prepared_mutation_blocks.clone();
|
||||||
Self::collect_prepared_mutation_intent_block(&mut prepared_mutation_blocks, intent)?;
|
Self::collect_prepared_mutation_intent_block(&mut prepared_mutation_blocks, intent)?;
|
||||||
|
for tier_name in target_tiers {
|
||||||
|
match prepared_mutation_blocks.entry(tier_name.clone()) {
|
||||||
|
Entry::Vacant(entry) => {
|
||||||
|
entry.insert(intent.mutation_id);
|
||||||
|
}
|
||||||
|
Entry::Occupied(entry) if *entry.get() == intent.mutation_id => {}
|
||||||
|
Entry::Occupied(_) => {
|
||||||
|
let mut err = ERR_TIER_BACKEND_IN_USE.clone();
|
||||||
|
err.message = format!("Remote tier {tier_name} already has another prepared mutation");
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if prepared_mutation_blocks == runtime.prepared_mutation_blocks {
|
if prepared_mutation_blocks == runtime.prepared_mutation_blocks {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -5416,6 +5596,18 @@ impl TierConfigMgr {
|
|||||||
pub(crate) async fn wait_for_blocked_tier_operation_leases(
|
pub(crate) async fn wait_for_blocked_tier_operation_leases(
|
||||||
handle: &Arc<RwLock<Self>>,
|
handle: &Arc<RwLock<Self>>,
|
||||||
intent: &TierMutationIntent,
|
intent: &TierMutationIntent,
|
||||||
|
) -> std::result::Result<(), AdminError> {
|
||||||
|
let target_tiers = intent
|
||||||
|
.affected_targets
|
||||||
|
.iter()
|
||||||
|
.map(|target| target.tier_name.clone())
|
||||||
|
.collect();
|
||||||
|
Self::wait_for_blocked_tier_operation_leases_for_tiers(handle, &target_tiers).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_blocked_tier_operation_leases_for_tiers(
|
||||||
|
handle: &Arc<RwLock<Self>>,
|
||||||
|
target_tiers: &HashSet<String>,
|
||||||
) -> std::result::Result<(), AdminError> {
|
) -> std::result::Result<(), AdminError> {
|
||||||
let generations = {
|
let generations = {
|
||||||
let manager = handle.read().await;
|
let manager = handle.read().await;
|
||||||
@@ -5423,10 +5615,9 @@ impl TierConfigMgr {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let runtime = lock_unpoisoned(&runtime);
|
let runtime = lock_unpoisoned(&runtime);
|
||||||
intent
|
target_tiers
|
||||||
.affected_targets
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|target| runtime.generations.get(&target.tier_name).cloned())
|
.filter_map(|tier_name| runtime.generations.get(tier_name).cloned())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
let drain = async {
|
let drain = async {
|
||||||
@@ -10590,6 +10781,11 @@ mod tests {
|
|||||||
.expect_err("coordinator committed-state CAS failure must be observable");
|
.expect_err("coordinator committed-state CAS failure must be observable");
|
||||||
assert!(matches!(err, TierConfigUpdateError::Save(_)));
|
assert!(matches!(err, TierConfigUpdateError::Save(_)));
|
||||||
assert!(manager.read().await.tiers.contains_key("COLD-A"));
|
assert!(manager.read().await.tiers.contains_key("COLD-A"));
|
||||||
|
assert!(TierConfigMgr::has_committed_mutation_block(&manager).await);
|
||||||
|
let refresh = TierConfigMgr::mutation_refresh_notifier(&manager).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), refresh.notified())
|
||||||
|
.await
|
||||||
|
.expect("failed coordinator commit must notify recovery after saving config");
|
||||||
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
|
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
|
||||||
Ok(_) => panic!("failed coordinator commit CAS must retain the local committed fence"),
|
Ok(_) => panic!("failed coordinator commit CAS must retain the local committed fence"),
|
||||||
Err(err) => err,
|
Err(err) => err,
|
||||||
@@ -14316,6 +14512,12 @@ mod tests {
|
|||||||
after_commit: bool,
|
after_commit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct CasCoordinatorCommitBarrier {
|
||||||
|
arrived: tokio::sync::Notify,
|
||||||
|
release: tokio::sync::Notify,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct CasConfigStore {
|
struct CasConfigStore {
|
||||||
objects: tokio::sync::Mutex<HashMap<String, (Vec<u8>, String)>>,
|
objects: tokio::sync::Mutex<HashMap<String, (Vec<u8>, String)>>,
|
||||||
@@ -14328,6 +14530,7 @@ mod tests {
|
|||||||
fail_delete_prefix: tokio::sync::Mutex<Option<(String, usize)>>,
|
fail_delete_prefix: tokio::sync::Mutex<Option<(String, usize)>>,
|
||||||
delete_log: tokio::sync::Mutex<Vec<String>>,
|
delete_log: tokio::sync::Mutex<Vec<String>>,
|
||||||
list_barrier: tokio::sync::Mutex<Option<Arc<CasListBarrier>>>,
|
list_barrier: tokio::sync::Mutex<Option<Arc<CasListBarrier>>>,
|
||||||
|
coordinator_commit_barrier: tokio::sync::Mutex<Option<Arc<CasCoordinatorCommitBarrier>>>,
|
||||||
intent_list_calls: AtomicUsize,
|
intent_list_calls: AtomicUsize,
|
||||||
fail_reference_walk: AtomicBool,
|
fail_reference_walk: AtomicBool,
|
||||||
reference_walk_send_count: AtomicUsize,
|
reference_walk_send_count: AtomicUsize,
|
||||||
@@ -14350,6 +14553,7 @@ mod tests {
|
|||||||
fail_delete_prefix: tokio::sync::Mutex::new(None),
|
fail_delete_prefix: tokio::sync::Mutex::new(None),
|
||||||
delete_log: tokio::sync::Mutex::new(Vec::new()),
|
delete_log: tokio::sync::Mutex::new(Vec::new()),
|
||||||
list_barrier: tokio::sync::Mutex::new(None),
|
list_barrier: tokio::sync::Mutex::new(None),
|
||||||
|
coordinator_commit_barrier: tokio::sync::Mutex::new(None),
|
||||||
intent_list_calls: AtomicUsize::new(0),
|
intent_list_calls: AtomicUsize::new(0),
|
||||||
fail_reference_walk: AtomicBool::new(false),
|
fail_reference_walk: AtomicBool::new(false),
|
||||||
reference_walk_send_count: AtomicUsize::new(0),
|
reference_walk_send_count: AtomicUsize::new(0),
|
||||||
@@ -14383,6 +14587,13 @@ mod tests {
|
|||||||
.push(object);
|
.push(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_listed_version(&self, bucket: &str, object: &str) {
|
||||||
|
self.listed_versions
|
||||||
|
.lock()
|
||||||
|
.expect("tier reference fixture should not poison")
|
||||||
|
.retain(|version| version.bucket != bucket || version.name != object);
|
||||||
|
}
|
||||||
|
|
||||||
fn add_lifecycle_config(&self, bucket: &str, config: BucketLifecycleConfiguration) {
|
fn add_lifecycle_config(&self, bucket: &str, config: BucketLifecycleConfiguration) {
|
||||||
self.lifecycle_configs
|
self.lifecycle_configs
|
||||||
.lock()
|
.lock()
|
||||||
@@ -14541,6 +14752,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let mut payload = Vec::new();
|
let mut payload = Vec::new();
|
||||||
tokio::io::AsyncReadExt::read_to_end(&mut data.stream, &mut payload).await?;
|
tokio::io::AsyncReadExt::read_to_end(&mut data.stream, &mut payload).await?;
|
||||||
|
if object.starts_with(crate::services::tier::tier_mutation_intent::TIER_COORDINATOR_MUTATION_INTENT_RECORD_PREFIX)
|
||||||
|
&& opts
|
||||||
|
.http_preconditions
|
||||||
|
.as_ref()
|
||||||
|
.and_then(HTTPPreconditions::if_match_value)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
let barrier = self.coordinator_commit_barrier.lock().await.take();
|
||||||
|
if let Some(barrier) = barrier {
|
||||||
|
barrier.arrived.notify_one();
|
||||||
|
barrier.release.notified().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
let race_rewrite = if opts
|
let race_rewrite = if opts
|
||||||
.http_preconditions
|
.http_preconditions
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -15639,13 +15863,152 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn force_remove_and_save_bypasses_lifecycle_only_reference() {
|
async fn tier_remove_prepared_fence_allows_inflight_free_version_cleanup_to_finish() {
|
||||||
// rustfs/rustfs#6832: reproduces the admin RemoveTier path (not just the lower-level
|
let store = Arc::new(CasConfigStore::default());
|
||||||
// reference-proof function) for a tier with zero transitioned objects but a lifecycle
|
let tier = build_rustfs_tier("COLD-A");
|
||||||
// rule still pointing at it — the exact shape of
|
let identity = tier_backend_identity(&tier).expect("test tier identity should encode");
|
||||||
// `test_manual_transition_async_tier_failure_reports_terminal_partial` in e2e_test,
|
let mut persisted = empty_mgr();
|
||||||
// which force-removes a tier a lifecycle rule still references to simulate a
|
persisted.tiers.insert("COLD-A".to_string(), tier.clone_with_credentials());
|
||||||
// decommissioned backend.
|
persisted
|
||||||
|
.save_tiering_config_if_current(store.clone(), None)
|
||||||
|
.await
|
||||||
|
.expect("free-version drain fixture should persist");
|
||||||
|
|
||||||
|
let manager = TierConfigMgr::new();
|
||||||
|
{
|
||||||
|
let mut guard = manager.write().await;
|
||||||
|
guard.tiers.insert("COLD-A".to_string(), tier);
|
||||||
|
guard.tiers.insert("COLD-B".to_string(), build_rustfs_tier("COLD-B"));
|
||||||
|
guard
|
||||||
|
.replace_driver("COLD-A", Box::new(LeaseTestBackend::ready("cleanup")))
|
||||||
|
.expect("cleanup driver generation should install");
|
||||||
|
guard
|
||||||
|
.replace_driver("COLD-B", Box::new(LeaseTestBackend::ready("stale-local")))
|
||||||
|
.expect("stale local driver generation should install");
|
||||||
|
}
|
||||||
|
let cleanup_lease = TierConfigMgr::acquire_operation_lease(&manager, "COLD-A")
|
||||||
|
.await
|
||||||
|
.expect("in-flight cleanup lease should be available");
|
||||||
|
let stale_local_lease = TierConfigMgr::acquire_operation_lease(&manager, "COLD-B")
|
||||||
|
.await
|
||||||
|
.expect("stale local tier lease should be available");
|
||||||
|
let mut free_version = transitioned_tier_object("photos", "2026/free-version.jpg", "COLD-A", Some(identity));
|
||||||
|
free_version.transitioned_object.status = "pending".to_string();
|
||||||
|
free_version.transitioned_object.free_version = true;
|
||||||
|
store.add_listed_version(free_version);
|
||||||
|
|
||||||
|
let remove_manager = manager.clone();
|
||||||
|
let remove_store = store.clone();
|
||||||
|
let remove = tokio::spawn(async move {
|
||||||
|
TIER_MUTATION_TEST_PEERS
|
||||||
|
.scope(
|
||||||
|
Vec::new(),
|
||||||
|
TierConfigMgr::remove_and_save_with(&remove_manager, remove_store, "COLD-A", true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
let guard = manager.read().await;
|
||||||
|
let runtime = registered_tier_driver_runtime(&guard).expect("runtime should remain registered");
|
||||||
|
let prepared = {
|
||||||
|
let runtime = lock_unpoisoned(&runtime);
|
||||||
|
runtime.prepared_mutation_blocks.contains_key("COLD-A")
|
||||||
|
&& runtime.prepared_mutation_blocks.contains_key("COLD-B")
|
||||||
|
};
|
||||||
|
if prepared {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("tier remove should install its durable prepared fence");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cleanup_lease.is_current(&manager).await,
|
||||||
|
"the prepared fence must let the already leased cleanup finish its exact local marker deletion"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stale_local_lease.is_current(&manager).await,
|
||||||
|
"the local superset fence must also let an already leased stale-manager operation finish"
|
||||||
|
);
|
||||||
|
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
|
||||||
|
Ok(_) => panic!("the prepared fence must reject new tier operations"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert!(TierConfigMgr::operation_lease_blocked_by_mutation(&blocked));
|
||||||
|
let stale_blocked = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-B").await {
|
||||||
|
Ok(_) => panic!("the local superset fence must reject new stale-manager operations"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert!(TierConfigMgr::operation_lease_blocked_by_mutation(&stale_blocked));
|
||||||
|
|
||||||
|
store.remove_listed_version("photos", "2026/free-version.jpg");
|
||||||
|
drop(cleanup_lease);
|
||||||
|
drop(stale_local_lease);
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), remove)
|
||||||
|
.await
|
||||||
|
.expect("tier remove should finish after both in-flight operations release their leases")
|
||||||
|
.expect("tier remove task should join")
|
||||||
|
.expect("tier remove should pass once the in-flight cleanup removes its marker");
|
||||||
|
assert!(!manager.read().await.tiers.contains_key("COLD-A"));
|
||||||
|
assert!(!manager.read().await.tiers.contains_key("COLD-B"));
|
||||||
|
assert!(
|
||||||
|
!load_tier_config_for_update(store)
|
||||||
|
.await
|
||||||
|
.expect("removed tier config should reload")
|
||||||
|
.0
|
||||||
|
.tiers
|
||||||
|
.contains_key("COLD-A")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_intent_stale_manager_removal_keeps_early_generation_drain() {
|
||||||
|
let store = Arc::new(CasConfigStore::default());
|
||||||
|
empty_mgr()
|
||||||
|
.save_tiering_config_if_current(store.clone(), None)
|
||||||
|
.await
|
||||||
|
.expect("empty persisted tier config should exist");
|
||||||
|
let manager = TierConfigMgr::new();
|
||||||
|
{
|
||||||
|
let mut guard = manager.write().await;
|
||||||
|
install_lease_backend(&mut guard, "COLD-A", LeaseTestBackend::ready("stale"));
|
||||||
|
}
|
||||||
|
let old = TierConfigMgr::acquire_operation_lease(&manager, "COLD-A")
|
||||||
|
.await
|
||||||
|
.expect("stale manager lease should be available");
|
||||||
|
|
||||||
|
let remove_manager = manager.clone();
|
||||||
|
let remove_store = store.clone();
|
||||||
|
let remove =
|
||||||
|
tokio::spawn(async move { TierConfigMgr::remove_and_save_with(&remove_manager, remove_store, "COLD-A", true).await });
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
while old.is_current(&manager).await {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("no-intent stale-manager reconciliation should revoke before its proof");
|
||||||
|
let blocked = match TierConfigMgr::acquire_operation_lease(&manager, "COLD-A").await {
|
||||||
|
Ok(_) => panic!("stale-manager reconciliation must not admit a new operation"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert!(TierConfigMgr::operation_lease_blocked_by_mutation(&blocked));
|
||||||
|
|
||||||
|
drop(old);
|
||||||
|
remove
|
||||||
|
.await
|
||||||
|
.expect("stale-manager removal task should join")
|
||||||
|
.expect("stale-manager removal should converge to the persisted empty config");
|
||||||
|
assert!(!manager.read().await.tiers.contains_key("COLD-A"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_lifecycle_only_reference_obeys_force(clear: bool, force: bool) {
|
||||||
let store = Arc::new(CasConfigStore::default());
|
let store = Arc::new(CasConfigStore::default());
|
||||||
let tier = build_rustfs_tier("COLD-A");
|
let tier = build_rustfs_tier("COLD-A");
|
||||||
let mut persisted = empty_mgr();
|
let mut persisted = empty_mgr();
|
||||||
@@ -15686,22 +16049,55 @@ mod tests {
|
|||||||
|
|
||||||
let manager = TierConfigMgr::new();
|
let manager = TierConfigMgr::new();
|
||||||
manager.write().await.tiers.insert("COLD-A".to_string(), tier);
|
manager.write().await.tiers.insert("COLD-A".to_string(), tier);
|
||||||
TierConfigMgr::remove_and_save_with(&manager, store.clone(), "COLD-A", true)
|
let mutation = if clear {
|
||||||
.await
|
TierCandidateMutation::Clear(force)
|
||||||
.expect("force remove must bypass a lifecycle-config-only reference");
|
} else {
|
||||||
|
TierCandidateMutation::Remove("COLD-A".to_string(), force)
|
||||||
|
};
|
||||||
|
let result = TIER_DRIVER_TEST_FACTORY
|
||||||
|
.scope(
|
||||||
|
healthy_driver_factory(),
|
||||||
|
TierConfigMgr::update_candidate_with_config_lock(&manager, store.clone(), mutation),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if force {
|
||||||
|
result.expect("force mutation must bypass a lifecycle-config-only reference");
|
||||||
|
} else {
|
||||||
|
let err = result.expect_err("non-force mutation must reject a lifecycle-only reference");
|
||||||
|
let TierConfigUpdateError::Publish(err) = err else {
|
||||||
|
panic!("non-force mutation must fail during reference proof: {err:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(err.code, ERR_TIER_BACKEND_IN_USE.code);
|
||||||
|
assert!(err.message.contains("move-current"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
assert!(!manager.read().await.tiers.contains_key("COLD-A"));
|
assert_eq!(manager.read().await.tiers.contains_key("COLD-A"), !force);
|
||||||
assert!(
|
assert_eq!(
|
||||||
!load_tier_config_for_update(store)
|
load_tier_config_for_update(store)
|
||||||
.await
|
.await
|
||||||
.expect("config should still reload")
|
.expect("config should still reload")
|
||||||
.0
|
.0
|
||||||
.tiers
|
.tiers
|
||||||
.contains_key("COLD-A"),
|
.contains_key("COLD-A"),
|
||||||
"force removal must persist the empty candidate"
|
!force,
|
||||||
|
"persisted state must match the force mutation result"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remove_with_config_lock_obeys_force_for_lifecycle_only_reference() {
|
||||||
|
for force in [false, true] {
|
||||||
|
assert_lifecycle_only_reference_obeys_force(false, force).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clear_with_config_lock_obeys_force_for_lifecycle_only_reference() {
|
||||||
|
for force in [false, true] {
|
||||||
|
assert_lifecycle_only_reference_obeys_force(true, force).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn zero_reference_proof_blocks_clear_before_config_save() {
|
async fn zero_reference_proof_blocks_clear_before_config_save() {
|
||||||
let store = Arc::new(CasConfigStore::default());
|
let store = Arc::new(CasConfigStore::default());
|
||||||
@@ -16515,12 +16911,23 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
tokio::time::timeout(Duration::from_secs(1), async {
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
while old.is_current(&manager).await {
|
loop {
|
||||||
|
let guard = manager.read().await;
|
||||||
|
let runtime = registered_tier_driver_runtime(&guard).expect("runtime should remain registered");
|
||||||
|
let prepared = lock_unpoisoned(&runtime).prepared_mutation_blocks.contains_key("COLD-A");
|
||||||
|
if prepared {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("owned update should revoke before caller cancellation");
|
.expect("owned update should install its prepared fence before caller cancellation");
|
||||||
|
assert!(
|
||||||
|
old.is_current(&manager).await,
|
||||||
|
"an already leased operation must remain current until it can finish"
|
||||||
|
);
|
||||||
caller.abort();
|
caller.abort();
|
||||||
drop(old);
|
drop(old);
|
||||||
|
|
||||||
@@ -16565,12 +16972,23 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
tokio::time::timeout(Duration::from_secs(1), async {
|
tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
while old.is_current(&manager).await {
|
loop {
|
||||||
|
let guard = manager.read().await;
|
||||||
|
let runtime = registered_tier_driver_runtime(&guard).expect("runtime should remain registered");
|
||||||
|
let prepared = lock_unpoisoned(&runtime).prepared_mutation_blocks.contains_key("COLD-A");
|
||||||
|
if prepared {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("owned update should revoke before caller cancellation");
|
.expect("owned update should install its prepared fence before caller cancellation");
|
||||||
|
assert!(
|
||||||
|
old.is_current(&manager).await,
|
||||||
|
"the prepared fence must not invalidate an already leased operation"
|
||||||
|
);
|
||||||
caller.abort();
|
caller.abort();
|
||||||
|
|
||||||
let config_file = tier_config_lock_path();
|
let config_file = tier_config_lock_path();
|
||||||
@@ -16956,6 +17374,71 @@ mod tests {
|
|||||||
assert!(current.tiers.contains_key("COLD-B"));
|
assert!(current.tiers.contains_key("COLD-B"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn reference_proof_rejects_a_changed_prepared_fence_revision_before_publish() {
|
||||||
|
let manager = TierConfigMgr::new();
|
||||||
|
let store = Arc::new(CasConfigStore::default());
|
||||||
|
let mut persisted = empty_mgr();
|
||||||
|
persisted.tiers.insert("COLD-A".to_string(), build_rustfs_tier("COLD-A"));
|
||||||
|
persisted
|
||||||
|
.save_tiering_config_if_current(store.clone(), None)
|
||||||
|
.await
|
||||||
|
.expect("prepared-fence revision fixture should persist");
|
||||||
|
{
|
||||||
|
let mut guard = manager.write().await;
|
||||||
|
install_lease_backend(&mut guard, "COLD-A", LeaseTestBackend::ready("old"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let barrier = tier_reference_proof_test_barrier();
|
||||||
|
let scoped_barrier = barrier.clone();
|
||||||
|
let update_manager = manager.clone();
|
||||||
|
let update_store = store.clone();
|
||||||
|
let update = tokio::spawn(async move {
|
||||||
|
TIER_REFERENCE_PROOF_TEST_BARRIER
|
||||||
|
.scope(
|
||||||
|
scoped_barrier,
|
||||||
|
TIER_MUTATION_TEST_PEERS.scope(
|
||||||
|
Vec::new(),
|
||||||
|
TierConfigMgr::update_candidate_with_config_lock(
|
||||||
|
&update_manager,
|
||||||
|
update_store,
|
||||||
|
TierCandidateMutation::Remove("COLD-A".to_string(), true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
barrier.arrived.notified().await;
|
||||||
|
|
||||||
|
let unrelated = prepared_remove_intent("COLD-B", uuid::Uuid::from_u128(0x2237));
|
||||||
|
TierConfigMgr::apply_prepared_mutation_intent_block(&manager, &unrelated)
|
||||||
|
.await
|
||||||
|
.expect("an unrelated prepared fence should advance the runtime revision");
|
||||||
|
barrier.release.add_permits(1);
|
||||||
|
|
||||||
|
let err = update
|
||||||
|
.await
|
||||||
|
.expect("tier update task should join")
|
||||||
|
.expect_err("a reference proof cannot authorize publication across a fence revision change");
|
||||||
|
let TierConfigUpdateError::Publish(err) = err else {
|
||||||
|
panic!("the stale prepared-fence allowance should fail publication: {err:?}");
|
||||||
|
};
|
||||||
|
assert!(err.message.contains("changed before replacement"), "{err}");
|
||||||
|
assert!(manager.read().await.tiers.contains_key("COLD-A"));
|
||||||
|
assert!(
|
||||||
|
load_tier_config_for_update(store)
|
||||||
|
.await
|
||||||
|
.expect("rejected tier config should remain readable")
|
||||||
|
.0
|
||||||
|
.tiers
|
||||||
|
.contains_key("COLD-A")
|
||||||
|
);
|
||||||
|
TierConfigMgr::clear_prepared_mutation_intent_block(&manager, unrelated.mutation_id)
|
||||||
|
.await
|
||||||
|
.expect("unrelated test fence should clear");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn caller_cancellation_after_durable_prepare_does_not_hide_the_mutation() {
|
async fn caller_cancellation_after_durable_prepare_does_not_hide_the_mutation() {
|
||||||
@@ -17242,6 +17725,98 @@ mod tests {
|
|||||||
assert_ne!(manager_a.read().await.empty(), manager_b.read().await.empty());
|
assert_ne!(manager_a.read().await.empty(), manager_b.read().await.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn assert_coordinator_commit_refresh_succeeds(mutation: TierCandidateMutation) {
|
||||||
|
let adding = matches!(mutation, TierCandidateMutation::Add(..));
|
||||||
|
let manager = TierConfigMgr::new();
|
||||||
|
let store = Arc::new(CasConfigStore::default());
|
||||||
|
if !adding {
|
||||||
|
let mut persisted = empty_mgr();
|
||||||
|
persisted.tiers.insert("COLD-A".to_string(), build_rustfs_tier("COLD-A"));
|
||||||
|
persisted
|
||||||
|
.save_tiering_config_if_current(store.clone(), None)
|
||||||
|
.await
|
||||||
|
.expect("existing tier fixture should persist");
|
||||||
|
let mut guard = manager.write().await;
|
||||||
|
install_lease_backend(&mut guard, "COLD-A", LeaseTestBackend::ready("old"));
|
||||||
|
}
|
||||||
|
let barrier = Arc::new(CasCoordinatorCommitBarrier::default());
|
||||||
|
*store.coordinator_commit_barrier.lock().await = Some(barrier.clone());
|
||||||
|
let update_manager = manager.clone();
|
||||||
|
let update_store = store.clone();
|
||||||
|
let update = tokio::spawn(async move {
|
||||||
|
TIER_DRIVER_TEST_FACTORY
|
||||||
|
.scope(
|
||||||
|
healthy_driver_factory(),
|
||||||
|
TIER_MUTATION_TEST_PEERS.scope(
|
||||||
|
Vec::new(),
|
||||||
|
TierConfigMgr::update_candidate_with_config_lock(&update_manager, update_store, mutation),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), barrier.arrived.notified())
|
||||||
|
.await
|
||||||
|
.expect("mutation should reach coordinator commit after saving config");
|
||||||
|
assert_eq!(
|
||||||
|
load_tier_config_for_update(store.clone())
|
||||||
|
.await
|
||||||
|
.expect("saved config should be readable before coordinator commit")
|
||||||
|
.0
|
||||||
|
.tiers
|
||||||
|
.contains_key("COLD-A"),
|
||||||
|
adding
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
TierConfigMgr::load_coordinator_mutation_intents(store.clone())
|
||||||
|
.await
|
||||||
|
.expect("coordinator intent should remain readable")[0]
|
||||||
|
.state,
|
||||||
|
TierMutationIntentState::Prepared
|
||||||
|
);
|
||||||
|
|
||||||
|
let lock_requests = lock_unpoisoned(&store.lock_requests).len();
|
||||||
|
// Also exercise an independently scheduled refresh while the durable
|
||||||
|
// coordinator record is still Prepared, before its commit notification.
|
||||||
|
TierConfigMgr::request_committed_mutation_refresh(&manager).await;
|
||||||
|
TIER_MUTATION_TEST_PEERS
|
||||||
|
.scope(Vec::new(), async {
|
||||||
|
let worker = TierConfigMgr::refresh_tier_config_handle_with(manager.clone(), store.clone());
|
||||||
|
tokio::pin!(worker);
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
while lock_unpoisoned(&store.lock_requests).len() == lock_requests {
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut worker => panic!("refresh worker must remain available"),
|
||||||
|
_ = tokio::task::yield_now() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("refresh should reconcile the Prepared record before waiting for the config lock");
|
||||||
|
barrier.release.notify_one();
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut worker => panic!("refresh worker must remain available"),
|
||||||
|
result = update => result.expect("tier mutation task should join"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("tier mutation should finish with refresh running");
|
||||||
|
result.expect("saved tier mutation must publish successfully on the first attempt");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert_eq!(manager.read().await.tiers.contains_key("COLD-A"), adding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tier_add_succeeds_with_refresh_during_coordinator_commit() {
|
||||||
|
assert_coordinator_commit_refresh_succeeds(TierCandidateMutation::Add(build_rustfs_tier("COLD-A"), true)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tier_remove_succeeds_with_refresh_during_coordinator_commit() {
|
||||||
|
assert_coordinator_commit_refresh_succeeds(TierCandidateMutation::Remove("COLD-A".to_string(), true)).await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn committed_refresh_fixture(fail_cleanup: bool) -> (Arc<RwLock<TierConfigMgr>>, Arc<CasConfigStore>, uuid::Uuid) {
|
async fn committed_refresh_fixture(fail_cleanup: bool) -> (Arc<RwLock<TierConfigMgr>>, Arc<CasConfigStore>, uuid::Uuid) {
|
||||||
let manager = TierConfigMgr::new();
|
let manager = TierConfigMgr::new();
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user