mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 12:09:12 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8cecf6462 | |||
| 980f3abbd3 | |||
| e36650827b | |||
| 09c8e10d5e | |||
| 0ff03c596c | |||
| a74919db8e | |||
| e77c6f0ca5 | |||
| 3149411943 |
@@ -1,2 +0,0 @@
|
|||||||
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
|
|
||||||
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=53b05ac745905809d3828c6994bdd8ecf9d20b2b61a8a9d80fe15eb62f932193
|
sha256-darwin=85ee9b9e66e916dbf51857298637b6bcd1c23c2b066aaa4c171ce961c2d002b5
|
||||||
sha256-linux=7c892afa4b9d1591b46bd79c976b647109a277284fddb3b98edced4b0297eda2
|
sha256-linux=a2933d83dfe74ffa03410a0959333a1c48288b8469ca9f17273d449d7510c24b
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=5db88c6fec94d4f269c7d9cfc128bd2adc27b3d7021127e2fa0b1daccc5f900f
|
sha256=a2542dc86bbff56b2177efc621785c56fa7e8d813b209b7d935e1e41a9f0ad15
|
||||||
|
|||||||
@@ -40,21 +40,6 @@
|
|||||||
"invariant": "corrupt-part-arrays",
|
"invariant": "corrupt-part-arrays",
|
||||||
"suite": "rustfs-filemeta",
|
"suite": "rustfs-filemeta",
|
||||||
"name": "filemeta::test::crc_valid_but_part_arrays_corrupt_into_fileinfo_errors_not_panics"
|
"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": [
|
"fixtures": [
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
.NOTPARALLEL: pre-commit pre-pr dev-check
|
.NOTPARALLEL: pre-commit pre-pr dev-check
|
||||||
|
|
||||||
.PHONY: setup-hooks
|
.PHONY: setup-hooks
|
||||||
setup-hooks: ## Install the configured pre-commit hooks
|
setup-hooks: ## Set up git hooks
|
||||||
@echo "🔧 Setting up git hooks..."
|
@echo "🔧 Setting up git hooks..."
|
||||||
pre-commit validate-config
|
chmod +x .git/hooks/pre-commit
|
||||||
pre-commit install
|
|
||||||
@echo "✅ Git hooks setup complete!"
|
@echo "✅ Git hooks setup complete!"
|
||||||
|
|
||||||
.PHONY: doc-paths-check
|
.PHONY: doc-paths-check
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ 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
|
||||||
@@ -39,10 +38,10 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_python_bin.sh
|
./scripts/test_python_bin.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_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_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,11 +1,10 @@
|
|||||||
# 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, with the
|
# The floor equals the exact count of rustfs-ecstore --lib tests matching the
|
||||||
# test-util feature enabled, matching the gate filter (name substrings:
|
# gate filter (name substrings: data_movement, rebalance, decommission,
|
||||||
# data_movement, rebalance, decommission, source_cleanup, delete_marker) at
|
# source_cleanup, delete_marker) at the time this file was last updated.
|
||||||
# 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.
|
||||||
946
|
571
|
||||||
|
|||||||
@@ -183,13 +183,6 @@ 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.
|
||||||
@@ -533,27 +526,6 @@ 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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -614,10 +586,6 @@ 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
|
||||||
@@ -639,7 +607,6 @@ 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)::/)
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -94,17 +94,13 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: ./scripts/check_embedded_secrets.sh
|
run: ./scripts/check_embedded_secrets.sh
|
||||||
|
|
||||||
- name: Run script contract tests
|
|
||||||
shell: bash
|
|
||||||
run: make script-tests
|
|
||||||
|
|
||||||
- name: Check test wiring
|
- name: Check test wiring
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
python3 ./scripts/check_test_wiring.py --self-test
|
||||||
|
python3 ./scripts/test_e2e_binary.py
|
||||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||||
python3 ./scripts/test_security_workflow.py
|
python3 ./scripts/test_security_workflow.py
|
||||||
python3 ./scripts/test_nightly_candidate.py
|
|
||||||
python3 ./scripts/check_test_wiring.py
|
python3 ./scripts/check_test_wiring.py
|
||||||
|
|
||||||
- name: Check no planning docs committed
|
- name: Check no planning docs committed
|
||||||
|
|||||||
@@ -4,11 +4,6 @@
|
|||||||
{ "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 },
|
||||||
|
|||||||
+14
-15
@@ -582,13 +582,15 @@ jobs:
|
|||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build debug binary
|
- name: Build debug binary
|
||||||
run: cargo build -p rustfs --bins --features e2e-test-hooks
|
run: python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||||
|
|
||||||
- name: Upload debug binary
|
- name: Upload debug binary
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-debug-binary
|
name: rustfs-debug-binary
|
||||||
path: target/debug/rustfs
|
path: |
|
||||||
|
target/debug/rustfs
|
||||||
|
target/debug/rustfs.e2e.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
@@ -620,13 +622,15 @@ jobs:
|
|||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build debug binary with rio-v2
|
- name: Build debug binary with rio-v2
|
||||||
run: cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
run: python3 scripts/e2e_binary.py build --bins --features rio-v2,e2e-test-hooks
|
||||||
|
|
||||||
- name: Upload debug binary
|
- name: Upload debug binary
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-debug-binary-rio-v2
|
name: rustfs-debug-binary-rio-v2
|
||||||
path: target/debug/rustfs
|
path: |
|
||||||
|
target/debug/rustfs
|
||||||
|
target/debug/rustfs.e2e.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
@@ -775,7 +779,7 @@ jobs:
|
|||||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||||
--status-level all --final-status-level all --failure-output final
|
--status-level all --final-status-level all --failure-output final
|
||||||
|
|
||||||
- name: Upload e2e smoke diagnostics
|
- name: Upload e2e smoke diagnostics
|
||||||
@@ -811,7 +815,7 @@ jobs:
|
|||||||
RUSTFS_TEST_PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
RUSTFS_TEST_PORT="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
||||||
RUSTFS_TEST_PORT="${RUSTFS_TEST_PORT}" \
|
RUSTFS_TEST_PORT="${RUSTFS_TEST_PORT}" \
|
||||||
RUSTFS_TEST_LOG="${RUN_ROOT}/rustfs.log" \
|
RUSTFS_TEST_LOG="${RUN_ROOT}/rustfs.log" \
|
||||||
./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs "${RUN_ROOT}/data"
|
||||||
|
|
||||||
- name: Upload test logs
|
- name: Upload test logs
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -850,11 +854,6 @@ 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:
|
||||||
@@ -918,7 +917,7 @@ jobs:
|
|||||||
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
|
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
|
||||||
# debug binary; each test spawns its own rustfs server on a random port.
|
# debug binary; each test spawns its own rustfs server on a random port.
|
||||||
- name: Run e2e full suite
|
- name: Run e2e full suite
|
||||||
run: cargo nextest run --profile e2e-full -p e2e_test
|
run: python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||||
|
|
||||||
- name: Upload junit
|
- name: Upload junit
|
||||||
if: always()
|
if: always()
|
||||||
@@ -979,7 +978,7 @@ jobs:
|
|||||||
- name: Run end-to-end tests
|
- name: Run end-to-end tests
|
||||||
run: |
|
run: |
|
||||||
s3s-e2e --version
|
s3s-e2e --version
|
||||||
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
python3 scripts/e2e_binary.py run --features rio-v2,e2e-test-hooks -- ./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs
|
||||||
|
|
||||||
- name: Upload test logs
|
- name: Upload test logs
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -1022,7 +1021,7 @@ jobs:
|
|||||||
S3_PORT="${S3_PORT}" \
|
S3_PORT="${S3_PORT}" \
|
||||||
DATA_ROOT="${RUN_ROOT}" \
|
DATA_ROOT="${RUN_ROOT}" \
|
||||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||||
./scripts/s3-tests/run.sh
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
||||||
|
|
||||||
- name: Upload s3 test artifacts
|
- name: Upload s3 test artifacts
|
||||||
if: always()
|
if: always()
|
||||||
@@ -1104,7 +1103,7 @@ jobs:
|
|||||||
S3_PORT="${S3_PORT}" \
|
S3_PORT="${S3_PORT}" \
|
||||||
DATA_ROOT="${RUN_ROOT}" \
|
DATA_ROOT="${RUN_ROOT}" \
|
||||||
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
S3TESTS_CONF=artifacts/s3tests-single/s3tests.conf \
|
||||||
./scripts/s3-tests/run.sh
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- ./scripts/s3-tests/run.sh
|
||||||
|
|
||||||
- name: Upload s3 test artifacts
|
- name: Upload s3 test artifacts
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
# Copyright 2024 RustFS Team
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
# 4-node 4-disk distributed e2e lane.
|
|
||||||
#
|
|
||||||
# Each selected test starts a real localhost cluster via
|
|
||||||
# `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the
|
|
||||||
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
|
|
||||||
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
|
|
||||||
# nightly runs, and manual dispatches all execute the same fail-closed suite.
|
|
||||||
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
|
|
||||||
#
|
|
||||||
# Isolated pool filesystems: expand/decommission/rebalance cases require
|
|
||||||
# independent `statfs` capacity. This job runs on GitHub-hosted
|
|
||||||
# `ubuntu-latest` because the self-hosted `sm-standard-4` ARC pods cannot
|
|
||||||
# create filesystems: `mount -o loop` fails with ENOENT (no
|
|
||||||
# `/dev/loop-control`), and `mount -t tmpfs` fails with "cannot mount tmpfs
|
|
||||||
# read-only" (no `CAP_SYS_ADMIN` in the initial namespace). The same reason
|
|
||||||
# `uring-integration` and `e2e-s3tests.yml` left that label. The prepare
|
|
||||||
# step mounts four 1 GiB tmpfs instances and exports `RUSTFS_E2E_POOL_ROOTS`.
|
|
||||||
|
|
||||||
name: e2e-distributed
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "Cargo.lock"
|
|
||||||
- "Cargo.toml"
|
|
||||||
- ".config/nextest.toml"
|
|
||||||
- ".github/workflows/e2e-distributed.yml"
|
|
||||||
- "crates/audit/**"
|
|
||||||
- "crates/common/**"
|
|
||||||
- "crates/config/**"
|
|
||||||
- "crates/e2e_test/**"
|
|
||||||
- "crates/ecstore/**"
|
|
||||||
- "crates/filemeta/**"
|
|
||||||
- "crates/heal/**"
|
|
||||||
- "crates/iam/**"
|
|
||||||
- "crates/lock/**"
|
|
||||||
- "crates/madmin/**"
|
|
||||||
- "crates/notify/**"
|
|
||||||
- "crates/replication/**"
|
|
||||||
- "crates/s3-client/**"
|
|
||||||
- "crates/s3-ops/**"
|
|
||||||
- "crates/s3-types/**"
|
|
||||||
- "crates/scanner/**"
|
|
||||||
- "crates/storage-api/**"
|
|
||||||
- "crates/utils/**"
|
|
||||||
- "rustfs/**"
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
filter:
|
|
||||||
description: "Optional nextest -E filter (default: the whole e2e-distributed profile)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
schedule:
|
|
||||||
# 05:53 UTC nightly — clear of e2e-nightly (04:29) and ODM interop (05:23).
|
|
||||||
- cron: "53 5 * * *"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
distributed:
|
|
||||||
name: Distributed 4-node 4-disk e2e
|
|
||||||
# GitHub-hosted VM: loop and tmpfs mounts work here. sm-standard-4 is an
|
|
||||||
# ARC pod and rejects both (`mount -o loop` ENOENT, tmpfs "read-only").
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 180
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
||||||
NO_PROXY: 127.0.0.1,localhost
|
|
||||||
HTTP_PROXY: ""
|
|
||||||
HTTPS_PROXY: ""
|
|
||||||
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
|
|
||||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
|
||||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
|
||||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup Rust environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
|
||||||
rust-version: stable
|
|
||||||
# Dedicated key: ubuntu-latest and sm-standard-4 share runner.os, so
|
|
||||||
# a shared key would mix VM and ARC pod target/ artifacts.
|
|
||||||
cache-shared-key: ci-e2e-distributed-hosted
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
|
||||||
install-build-packaging-tools: 'false'
|
|
||||||
|
|
||||||
- name: Prepare isolated filesystems for pool movement
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
|
||||||
mkdir -p "${mount_base}"
|
|
||||||
roots=()
|
|
||||||
for pool in 0 1 2 3; do
|
|
||||||
mountpoint="${mount_base}/pool-${pool}"
|
|
||||||
mkdir -p "${mountpoint}"
|
|
||||||
# Sized tmpfs reports a distinct st_dev and independent 1G
|
|
||||||
# statfs capacity. Requires a VM runner (ubuntu-latest).
|
|
||||||
if ! sudo mount -t tmpfs -o size=1G,nosuid,nodev,mode=1777 tmpfs "${mountpoint}"; then
|
|
||||||
echo "tmpfs mount failed on $(uname -a)" >&2
|
|
||||||
findmnt || true
|
|
||||||
grep Cap /proc/self/status || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sudo chmod 1777 "${mountpoint}"
|
|
||||||
roots+=("${mountpoint}")
|
|
||||||
done
|
|
||||||
printf -v joined_roots '%s:' "${roots[@]}"
|
|
||||||
echo "RUSTFS_E2E_POOL_ROOTS=${joined_roots%:}" >> "${GITHUB_ENV}"
|
|
||||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[0]}"
|
|
||||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[1]}"
|
|
||||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[2]}"
|
|
||||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[3]}"
|
|
||||||
|
|
||||||
- name: Download pinned previous release
|
|
||||||
env:
|
|
||||||
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p "$SOURCE_DIR"
|
|
||||||
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
|
|
||||||
curl --fail --location --retry 3 --output "$archive" \
|
|
||||||
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
|
||||||
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
|
||||||
unzip -q "$archive" -d "$SOURCE_DIR"
|
|
||||||
chmod +x "$SOURCE_DIR/rustfs"
|
|
||||||
test -x "$SOURCE_DIR/rustfs"
|
|
||||||
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Build rustfs binary
|
|
||||||
run: |
|
|
||||||
cargo build -p rustfs --bins
|
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Verify distributed e2e membership
|
|
||||||
env:
|
|
||||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
|
||||||
run: |
|
|
||||||
cargo nextest list --profile e2e-distributed -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
|
||||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-distributed "${NEXTEST_LISTING}"
|
|
||||||
|
|
||||||
- name: Run distributed 4-node e2e suite
|
|
||||||
env:
|
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
|
|
||||||
FILTER: ${{ inputs.filter }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -n "${FILTER}" ]; then
|
|
||||||
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
|
||||||
else
|
|
||||||
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload distributed e2e diagnostics
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
||||||
with:
|
|
||||||
name: e2e-distributed-${{ github.run_number }}
|
|
||||||
path: |
|
|
||||||
target/nextest/e2e-distributed/junit.xml
|
|
||||||
${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
|
||||||
${{ runner.temp }}/rustfs-e2e-distributed-logs/
|
|
||||||
retention-days: 7
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Unmount isolated pool filesystems
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
|
||||||
for pool in 0 1 2 3; do
|
|
||||||
mountpoint="${mount_base}/pool-${pool}"
|
|
||||||
if mountpoint --quiet "${mountpoint}"; then
|
|
||||||
sudo umount "${mountpoint}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
alert-on-failure:
|
|
||||||
name: Alert on scheduled failure
|
|
||||||
needs: [distributed]
|
|
||||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Open or update failure-tracking issue
|
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -89,14 +89,10 @@ jobs:
|
|||||||
- name: Verify awscurl
|
- name: Verify awscurl
|
||||||
run: test -x "$AWSCURL_PATH"
|
run: test -x "$AWSCURL_PATH"
|
||||||
|
|
||||||
# Build the rustfs binary once up front. The e2e tests spawn it as a
|
# Build once and carry its source/binary identity into the test invocation.
|
||||||
# child process (crates/e2e_test/src/common.rs) and will build it on
|
|
||||||
# demand otherwise, but a single explicit build avoids several parallel
|
|
||||||
# nextest test processes racing to build it at once.
|
|
||||||
- name: Build rustfs binary
|
- name: Build rustfs binary
|
||||||
run: |
|
run: |
|
||||||
cargo build -p rustfs --bins
|
python3 scripts/e2e_binary.py build --bins
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Verify replication e2e membership
|
- name: Verify replication e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -108,7 +104,7 @@ jobs:
|
|||||||
- name: Run replication e2e nightly suite
|
- name: Run replication e2e nightly suite
|
||||||
env:
|
env:
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
||||||
run: cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
|
|
||||||
- name: Upload nextest junit report
|
- name: Upload nextest junit report
|
||||||
if: always()
|
if: always()
|
||||||
@@ -144,8 +140,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build rustfs binary
|
- name: Build rustfs binary
|
||||||
run: |
|
run: |
|
||||||
cargo build -p rustfs --bins --features e2e-test-hooks
|
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Verify cluster fault e2e membership
|
- name: Verify cluster fault e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -157,7 +152,7 @@ jobs:
|
|||||||
- name: Run cluster fault e2e nightly suite
|
- name: Run cluster fault e2e nightly suite
|
||||||
env:
|
env:
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
||||||
run: cargo nextest run --profile e2e-nightly -p e2e_test
|
run: python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||||
|
|
||||||
- name: Upload cluster fault diagnostics
|
- name: Upload cluster fault diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
@@ -198,6 +193,9 @@ jobs:
|
|||||||
sudo apt-get install -y -qq iproute2
|
sudo apt-get install -y -qq iproute2
|
||||||
ss -tn state CLOSE-WAIT >/dev/null
|
ss -tn state CLOSE-WAIT >/dev/null
|
||||||
|
|
||||||
|
- name: Build protocol server
|
||||||
|
run: python3 scripts/e2e_binary.py build --features "$RUSTFS_BUILD_FEATURES"
|
||||||
|
|
||||||
# The suite owns fixed protocol ports and serializes its internal cases.
|
# The suite owns fixed protocol ports and serializes its internal cases.
|
||||||
- name: Verify protocol e2e membership
|
- name: Verify protocol e2e membership
|
||||||
env:
|
env:
|
||||||
@@ -210,7 +208,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
||||||
run: >-
|
run: >-
|
||||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
python3 scripts/e2e_binary.py run --features "$RUSTFS_BUILD_FEATURES" -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||||
|
|
||||||
- name: Upload protocol diagnostics
|
- name: Upload protocol diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ 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/**"
|
||||||
@@ -46,9 +44,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.5
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||||
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
|
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upgrade:
|
upgrade:
|
||||||
@@ -57,31 +55,14 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# The two `_from_rc2_` tests keep their names: they assert
|
- name: Direct upgrade from rc.2
|
||||||
# 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 the previous release
|
- name: Mixed-version rolling upgrade from rc.2
|
||||||
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:
|
||||||
@@ -117,12 +98,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Build current RustFS binary
|
- name: Build current RustFS binary
|
||||||
run: |
|
run: |
|
||||||
cargo build --locked -p rustfs --bin rustfs
|
python3 scripts/e2e_binary.py build
|
||||||
: > target/debug/rustfs.features
|
|
||||||
|
|
||||||
- name: Run upgrade compatibility test
|
- name: Run upgrade compatibility test
|
||||||
run: |
|
run: |
|
||||||
cargo test --locked -p e2e_test \
|
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test \
|
||||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||||
-- --ignored --exact --nocapture
|
-- --ignored --exact --nocapture
|
||||||
|
|
||||||
|
|||||||
@@ -166,9 +166,8 @@ 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
|
||||||
id: publish
|
if: env.R2_ACCESS_KEY_ID != ''
|
||||||
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 }}
|
||||||
@@ -183,70 +182,28 @@ 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"
|
||||||
|
|
||||||
SOURCE_SHA="$(git rev-parse HEAD)"
|
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
||||||
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_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
aws 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_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
aws 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
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
||||||
|
|
||||||
- name: Build the RustFS binary under test
|
- name: Build the RustFS binary under test
|
||||||
run: cargo build --locked -p rustfs --bins
|
run: python3 scripts/e2e_binary.py build --bins
|
||||||
|
|
||||||
# The lane selects tests by module, so a rename would quietly shrink it.
|
# The lane selects tests by module, so a rename would quietly shrink it.
|
||||||
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
||||||
|
|
||||||
- name: Run the interop cases against MinIO
|
- name: Run the interop cases against MinIO
|
||||||
run: cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
||||||
|
|
||||||
- name: Build the MinIO interop report
|
- name: Build the MinIO interop report
|
||||||
if: always()
|
if: always()
|
||||||
@@ -251,7 +251,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build the RustFS binary under test
|
- name: Build the RustFS binary under test
|
||||||
if: steps.credentials.outputs.present == 'true'
|
if: steps.credentials.outputs.present == 'true'
|
||||||
run: cargo build --locked -p rustfs --bins
|
run: python3 scripts/e2e_binary.py build --bins
|
||||||
|
|
||||||
# A filterset that matches nothing is valid, so the count is asserted
|
# A filterset that matches nothing is valid, so the count is asserted
|
||||||
# rather than inferred from a green run.
|
# rather than inferred from a green run.
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
- name: Run the three-case minimum
|
- name: Run the three-case minimum
|
||||||
if: steps.credentials.outputs.present == 'true'
|
if: steps.credentials.outputs.present == 'true'
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
||||||
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
||||||
|
|
||||||
- name: Build the ${{ matrix.provider }} interop report
|
- name: Build the ${{ matrix.provider }} interop report
|
||||||
|
|||||||
@@ -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 -> performance). Each suite attempts the next handoff even
|
# replication, with performance on its own runner in parallel) and guarantees
|
||||||
# when its tests fail.
|
# the chain keeps moving even when individual suites 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,3 +59,16 @@ 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,26 +54,14 @@ 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)
|
||||||
@@ -129,7 +117,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[@]}" --log-file "${LOG_FILE}"
|
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Preflight checks
|
- name: Preflight checks
|
||||||
run: |
|
run: |
|
||||||
@@ -139,7 +127,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[@]}" --log-file "${LOG_FILE}"
|
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Run heal test (write -> outage -> heal -> verify)
|
- name: Run heal test (write -> outage -> heal -> verify)
|
||||||
id: test
|
id: test
|
||||||
@@ -149,10 +137,13 @@ 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 "${LOG_FILE}"
|
--log-file /tmp/rustfs-heal-test.log
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
|
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 }}'
|
||||||
@@ -161,9 +152,8 @@ jobs:
|
|||||||
else
|
else
|
||||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
fi
|
fi
|
||||||
STEPS_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/steps.md"
|
STEPS_TABLE="/tmp/rustfs-heal-steps.md"
|
||||||
CASE_RESULT=success
|
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY'
|
||||||
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY' || CASE_RESULT=failure
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -175,7 +165,6 @@ 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
|
||||||
@@ -189,15 +178,14 @@ 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)
|
||||||
if n not in steps or status_rank[status] > status_rank[steps[n][1]]:
|
steps[n] = (desc, status) # later lines win (fail after pass)
|
||||||
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 and verdict != 'FAIL':
|
if m:
|
||||||
verdict, verdict_detail = m.group(1), m.group(2)
|
verdict, verdict_detail = m.group(1), m.group(2)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
@@ -217,43 +205,30 @@ 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: ${RESULT}"
|
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "${RESULT}" = "success" ]; then
|
cat "${STEPS_TABLE}" || true
|
||||||
cat "${STEPS_TABLE}"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}"
|
tail -n 200 "${LOG_FILE}" || true
|
||||||
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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
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
|
||||||
@@ -263,32 +238,28 @@ 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"
|
||||||
# Base64-encode the report into a temp file and feed it to jq via
|
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
# --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}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
'{message:$msg, content:$content, 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}" --rawfile content "${B64_FILE}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
'{message:$msg, content:$content}' \
|
||||||
| 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
|
||||||
@@ -316,16 +287,14 @@ 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 [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
if [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
elif [ -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
|
||||||
@@ -341,16 +310,14 @@ 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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-heal-test-${{ github.run_id }}-${{ github.run_attempt }}
|
name: rustfs-heal-test-${{ github.run_id }}
|
||||||
path: |
|
path: |
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
/tmp/rustfs-heal-test*.log
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
/tmp/rustfs-warp.*.log
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/warp.log
|
if-no-files-found: warn
|
||||||
${{ 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,28 +49,10 @@ 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)
|
||||||
@@ -127,6 +109,9 @@ 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
|
||||||
@@ -156,7 +141,10 @@ jobs:
|
|||||||
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
|
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 }}'
|
||||||
@@ -168,43 +156,79 @@ jobs:
|
|||||||
else
|
else
|
||||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
CASE_TABLE="/tmp/rustfs-kms-cases.md"
|
||||||
CASE_RESULT=success
|
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
import re
|
||||||
RESULT=failure
|
import sys
|
||||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
|
||||||
RESULT=success
|
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||||
fi
|
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||||
|
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
|
||||||
|
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
|
||||||
|
|
||||||
|
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: ${RESULT}"
|
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "${RESULT}" = "success" ]; then
|
cat "${CASE_TABLE}" || true
|
||||||
cat "${CASE_TABLE}"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}"
|
tail -n 200 "${LOG_FILE}" || true
|
||||||
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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
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
|
||||||
@@ -214,32 +238,28 @@ 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"
|
||||||
# Base64-encode the report into a temp file and feed it to jq via
|
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
# --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}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
'{message:$msg, content:$content, 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}" --rawfile content "${B64_FILE}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
'{message:$msg, content:$content}' \
|
||||||
| 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
|
||||||
@@ -267,16 +287,14 @@ 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 [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
if [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
elif [ -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
|
||||||
@@ -292,15 +310,14 @@ 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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-kms-test-${{ github.run_id }}-${{ github.run_attempt }}
|
name: rustfs-kms-test-${{ github.run_id }}
|
||||||
path: |
|
path: |
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
/tmp/rustfs-kms.log
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
/tmp/rustfs-kms-report.md
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
if-no-files-found: warn
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -49,16 +49,17 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
repository_dispatch:
|
repository_dispatch:
|
||||||
# Chain handoff: dispatched when the replication suite finishes.
|
# Chain entry: dispatched by rustfs-functional-chain.yml (runs on its own
|
||||||
|
# pf-testing runner, in parallel with the shared-VM chain).
|
||||||
types: [rustfs-chain-performance]
|
types: [rustfs-chain-performance]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
# The default performance nodes overlap the other suites' remote VMs, even
|
# Dedicated pf-testing runner/environment: own concurrency group so perf runs
|
||||||
# though the runner differs. Hold the shared lock through cleanup as well.
|
# never block (or are blocked by) the pool-expansion / heal tests.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: rustfs-shared-functional-tests
|
group: rustfs-performance-test
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
@@ -75,33 +76,22 @@ 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)
|
||||||
@@ -133,7 +123,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 --log-file "${LOG_FILE:-/dev/null}"
|
./auto-testing/rustfs_performance_test.sh --step 1 -y
|
||||||
|
|
||||||
- name: Install RustFS package & start cluster (4x4)
|
- name: Install RustFS package & start cluster (4x4)
|
||||||
run: |
|
run: |
|
||||||
@@ -143,7 +133,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[@]}" --log-file "${LOG_FILE}"
|
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Preflight checks
|
- name: Preflight checks
|
||||||
run: |
|
run: |
|
||||||
@@ -153,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[@]}" --log-file "${LOG_FILE}"
|
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Run benchmark (GET/PUT/MIXED)
|
- name: Run benchmark (GET/PUT/MIXED)
|
||||||
id: benchmark
|
id: benchmark
|
||||||
@@ -166,15 +156,17 @@ 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 "${LOG_FILE}"
|
--log-file /tmp/rustfs-perf-test.log
|
||||||
|
|
||||||
- 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 --log-file "${LOG_FILE:-/dev/null}"
|
./auto-testing/rustfs_performance_test.sh --step 6 -y
|
||||||
|
|
||||||
- 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}"
|
||||||
@@ -194,6 +186,7 @@ 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
|
||||||
@@ -201,7 +194,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
SUMMARY="${RESULT_DIR}/summary.md"
|
SUMMARY="${RESULT_DIR}/summary.md"
|
||||||
[ -s "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
[ -f "${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"
|
||||||
{
|
{
|
||||||
@@ -209,8 +202,6 @@ 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 ""
|
||||||
@@ -220,8 +211,8 @@ jobs:
|
|||||||
echo '```text'
|
echo '```text'
|
||||||
cat "${VERSION_FILE}"
|
cat "${VERSION_FILE}"
|
||||||
echo '```'
|
echo '```'
|
||||||
} > "${REPORT_FILE}"
|
} > /tmp/rustfs-perf-report.md
|
||||||
CONTENT="$(python3 -c 'import base64,sys; print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
CONTENT="$(python3 -c 'import base64; print(base64.b64encode(open("/tmp/rustfs-perf-report.md","rb").read()).decode())')"
|
||||||
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}" \
|
||||||
@@ -240,10 +231,11 @@ 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
|
||||||
@@ -271,16 +263,14 @@ 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 [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
if [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
elif [ -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
|
||||||
@@ -296,26 +286,20 @@ 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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-perf-test-${{ github.run_id }}-${{ github.run_attempt }}
|
name: rustfs-perf-test-${{ github.run_id }}
|
||||||
path: |
|
path: |
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
/tmp/rustfs-perf-test*.log
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
/tmp/rustfs-perf-results/**
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/version.txt
|
/tmp/rustfs-version.txt
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/master.log
|
if-no-files-found: warn
|
||||||
${{ 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 --log-file "${LOG_FILE:-/dev/null}"
|
./auto-testing/rustfs_performance_test.sh --step 7 -y
|
||||||
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ 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:
|
||||||
@@ -539,22 +542,17 @@ 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"
|
||||||
# Base64-encode the report into a temp file and feed it to jq via
|
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
# --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}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
'{message:$msg, content:$content, 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}" --rawfile content "${B64_FILE}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
'{message:$msg, content:$content}' \
|
||||||
| 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,7 +34,8 @@ on:
|
|||||||
- site
|
- site
|
||||||
default: all
|
default: all
|
||||||
repository_dispatch:
|
repository_dispatch:
|
||||||
# Chain handoff: dispatched when the security suite finishes.
|
# Chain handoff: dispatched when the security suite finishes. This is the
|
||||||
|
# last link of the functional chain.
|
||||||
types: [rustfs-chain-replication]
|
types: [rustfs-chain-replication]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -61,28 +62,12 @@ 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)
|
||||||
@@ -131,6 +116,9 @@ 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
|
||||||
@@ -153,7 +141,10 @@ jobs:
|
|||||||
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
|
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 }}'
|
||||||
@@ -175,44 +166,80 @@ jobs:
|
|||||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
CASE_TABLE="/tmp/rustfs-replication-cases.md"
|
||||||
CASE_RESULT=success
|
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
import re
|
||||||
RESULT=failure
|
import sys
|
||||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
|
||||||
RESULT=success
|
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||||
fi
|
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||||
|
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||||
|
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
index = {}
|
||||||
|
try:
|
||||||
|
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||||
|
for raw in fh:
|
||||||
|
line = ansi.sub('', raw).strip()
|
||||||
|
m = start_re.match(line)
|
||||||
|
if m:
|
||||||
|
case_id, name = m.group(1), m.group(2)
|
||||||
|
if case_id not in index:
|
||||||
|
index[case_id] = len(rows)
|
||||||
|
rows.append([case_id, name, 'RUNNING'])
|
||||||
|
continue
|
||||||
|
m = done_re.match(line)
|
||||||
|
if m:
|
||||||
|
status, case_id = m.group(1), m.group(2)
|
||||||
|
if case_id in index:
|
||||||
|
rows[index[case_id]][2] = status
|
||||||
|
else:
|
||||||
|
rows.append([case_id, case_id, status])
|
||||||
|
index[case_id] = len(rows) - 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||||
|
for _, _, status in rows:
|
||||||
|
counts[status] = counts.get(status, 0) + 1
|
||||||
|
|
||||||
|
with open(out_file, 'w', encoding='utf-8') as out:
|
||||||
|
out.write('## Case Summary\n\n')
|
||||||
|
out.write(f"- Total: {len(rows)}\\n")
|
||||||
|
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||||
|
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||||
|
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||||
|
out.write('\\n')
|
||||||
|
out.write('| Case | Name | Status |\\n')
|
||||||
|
out.write('| --- | --- | --- |\\n')
|
||||||
|
for case_id, name, status in rows:
|
||||||
|
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||||
|
PY
|
||||||
{
|
{
|
||||||
echo "# RustFS replication test report"
|
echo "# 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: ${RESULT}"
|
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "${RESULT}" = "success" ]; then
|
cat "${CASE_TABLE}" || true
|
||||||
cat "${CASE_TABLE}"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}"
|
tail -n 200 "${LOG_FILE}" || true
|
||||||
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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
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
|
||||||
@@ -222,32 +249,28 @@ 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"
|
||||||
# Base64-encode the report into a temp file and feed it to jq via
|
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
# --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}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
'{message:$msg, content:$content, 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}" --rawfile content "${B64_FILE}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
'{message:$msg, content:$content}' \
|
||||||
| 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
|
||||||
@@ -275,16 +298,14 @@ 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 [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
if [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
elif [ -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
|
||||||
@@ -300,15 +321,14 @@ 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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-replication-${{ github.run_id }}-${{ github.run_attempt }}
|
name: rustfs-replication-${{ github.run_id }}
|
||||||
path: |
|
path: |
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
/tmp/rustfs-replication.log
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
/tmp/rustfs-replication-report.md
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
if-no-files-found: warn
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
@@ -329,50 +349,13 @@ jobs:
|
|||||||
'
|
'
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: "Continue functional chain (next: Performance)"
|
- name: Chain complete
|
||||||
|
# Replication is the last link of the functional chain: nothing to
|
||||||
|
# dispatch after it. This step just records that the chain finished.
|
||||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
|
||||||
run: |
|
run: |
|
||||||
set -uo pipefail
|
echo "Functional chain complete: replication (final suite) finished."
|
||||||
if [ -z "${GH_TOKEN:-}" ]; then
|
echo "from_suite=security trigger=${{ github.event_name }} outcome=${{ steps.test.outcome }}"
|
||||||
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,28 +37,10 @@ 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)
|
||||||
@@ -106,6 +88,9 @@ 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
|
||||||
@@ -122,7 +107,10 @@ 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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
|
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 }}'
|
||||||
@@ -144,44 +132,83 @@ jobs:
|
|||||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
CASE_TABLE="/tmp/rustfs-s3-compat-cases.md"
|
||||||
CASE_RESULT=success
|
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
import re
|
||||||
RESULT=failure
|
import sys
|
||||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
|
||||||
RESULT=success
|
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||||
fi
|
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||||
|
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||||
|
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
index = {}
|
||||||
|
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: ${RESULT}"
|
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "${RESULT}" = "success" ]; then
|
cat "${CASE_TABLE}" || true
|
||||||
cat "${CASE_TABLE}"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}"
|
tail -n 200 "${LOG_FILE}" || true
|
||||||
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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
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
|
||||||
@@ -191,32 +218,28 @@ 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"
|
||||||
# Base64-encode the report into a temp file and feed it to jq via
|
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
# --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}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
'{message:$msg, content:$content, 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}" --rawfile content "${B64_FILE}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
'{message:$msg, content:$content}' \
|
||||||
| 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
|
||||||
@@ -244,16 +267,14 @@ 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 [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
if [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
elif [ -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
|
||||||
@@ -269,15 +290,14 @@ 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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-s3-compat-${{ github.run_id }}-${{ github.run_attempt }}
|
name: rustfs-s3-compat-${{ github.run_id }}
|
||||||
path: |
|
path: |
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
/tmp/rustfs-s3-compat.log
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
/tmp/rustfs-s3-compat-report.md
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
if-no-files-found: warn
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -77,14 +77,10 @@ jobs:
|
|||||||
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)
|
- name: Checkout repository (for the OIDC live gate script)
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
path: rustfs-repo
|
|
||||||
|
|
||||||
- name: Initialize security evidence
|
- name: Initialize security evidence
|
||||||
id: evidence
|
id: evidence
|
||||||
@@ -92,7 +88,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
mkdir -- "${SECURITY_ARTIFACTS_DIR}" "${SECURITY_ARTIFACTS_DIR}-scratch"
|
mkdir -- "${SECURITY_ARTIFACTS_DIR}"
|
||||||
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
|
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
|
||||||
@@ -148,8 +144,8 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||||
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}-scratch
|
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}
|
||||||
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/rustfs-repo/scripts/test/oidc_keycloak_live.sh
|
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/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
|
||||||
@@ -172,7 +168,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
fi
|
fi
|
||||||
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}" 2>&1 | tee "${SECURITY_ARTIFACTS_DIR}/suite.log"
|
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
id: report
|
id: report
|
||||||
@@ -223,22 +219,17 @@ 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"
|
||||||
# Base64-encode the report into a temp file and feed it to jq via
|
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
# --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}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
'{message:$msg, content:$content, 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}" --rawfile content "${B64_FILE}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
'{message:$msg, content:$content}' \
|
||||||
| 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') }}
|
||||||
@@ -305,10 +296,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
|
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: ${{ env.SECURITY_ARTIFACTS_DIR }}/
|
||||||
${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
|
||||||
${{ env.SECURITY_ARTIFACTS_DIR }}/suite.log
|
|
||||||
${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
|||||||
@@ -46,28 +46,10 @@ 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)
|
||||||
@@ -115,6 +97,9 @@ 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
|
||||||
@@ -137,7 +122,10 @@ jobs:
|
|||||||
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
|
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 }}'
|
||||||
@@ -159,44 +147,83 @@ jobs:
|
|||||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
CASE_TABLE="/tmp/rustfs-storage-cases.md"
|
||||||
CASE_RESULT=success
|
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
import re
|
||||||
RESULT=failure
|
import sys
|
||||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
|
||||||
RESULT=success
|
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||||
fi
|
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||||
|
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||||
|
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
index = {}
|
||||||
|
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: ${RESULT}"
|
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "${RESULT}" = "success" ]; then
|
cat "${CASE_TABLE}" || true
|
||||||
cat "${CASE_TABLE}"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}"
|
tail -n 200 "${LOG_FILE}" || true
|
||||||
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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
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
|
||||||
@@ -206,32 +233,28 @@ 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"
|
||||||
# Base64-encode the report into a temp file and feed it to jq via
|
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
# --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}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
'{message:$msg, content:$content, 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}" --rawfile content "${B64_FILE}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
'{message:$msg, content:$content}' \
|
||||||
| 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
|
||||||
@@ -259,16 +282,14 @@ 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 [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
if [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
elif [ -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
|
||||||
@@ -284,15 +305,14 @@ 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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-storage-${{ github.run_id }}-${{ github.run_attempt }}
|
name: rustfs-storage-${{ github.run_id }}
|
||||||
path: |
|
path: |
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
/tmp/rustfs-storage.log
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
/tmp/rustfs-storage-report.md
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
if-no-files-found: warn
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Cleanup environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ 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:
|
||||||
@@ -377,22 +380,17 @@ 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"
|
||||||
# Base64-encode the report into a temp file and feed it to jq via
|
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
# --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}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
'{message:$msg, content:$content, 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}" --rawfile content "${B64_FILE}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
'{message:$msg, content:$content}' \
|
||||||
| 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
|
||||||
|
|||||||
@@ -79,28 +79,10 @@ 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)
|
||||||
@@ -160,7 +142,9 @@ 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 }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -218,7 +202,10 @@ jobs:
|
|||||||
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Generate report
|
- name: Generate report
|
||||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
|
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 }}'
|
||||||
@@ -239,47 +226,103 @@ jobs:
|
|||||||
else
|
else
|
||||||
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
fi
|
fi
|
||||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
CASE_TABLE="/tmp/rustfs-upgrade-cases.md"
|
||||||
MATRIX_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/matrix.md"
|
MATRIX_TABLE="/tmp/rustfs-upgrade-matrix.md"
|
||||||
CASE_RESULT=success
|
python3 - "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" <<'PY'
|
||||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" || CASE_RESULT=failure
|
import re
|
||||||
RESULT=failure
|
import sys
|
||||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
|
||||||
RESULT=success
|
log_file, out_file, matrix_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
fi
|
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||||
|
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
|
||||||
|
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
|
||||||
|
topo_re = re.compile(
|
||||||
|
r'^\[UPG-TOPO\]\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+PASS=(\d+)\s+FAIL=(\d+)\s*$')
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
index = {}
|
||||||
|
topo_rows = []
|
||||||
|
try:
|
||||||
|
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||||
|
for raw in fh:
|
||||||
|
line = ansi.sub('', raw).strip()
|
||||||
|
m = 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: ${RESULT}"
|
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "${RESULT}" = "success" ]; then
|
cat "${MATRIX_TABLE}" || true
|
||||||
cat "${MATRIX_TABLE}"
|
|
||||||
echo ""
|
echo ""
|
||||||
cat "${CASE_TABLE}"
|
cat "${CASE_TABLE}" || true
|
||||||
echo ""
|
echo ""
|
||||||
echo "## Log tail"
|
echo "## Log tail"
|
||||||
echo '```text'
|
echo '```text'
|
||||||
tail -n 200 "${LOG_FILE}"
|
tail -n 200 "${LOG_FILE}" || true
|
||||||
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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
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
|
||||||
@@ -289,32 +332,28 @@ 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"
|
||||||
# Base64-encode the report into a temp file and feed it to jq via
|
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||||
# --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}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
'{message:$msg, content:$content, 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}" --rawfile content "${B64_FILE}" \
|
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
'{message:$msg, content:$content}' \
|
||||||
| 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
|
||||||
@@ -342,16 +381,14 @@ 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 [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
if [ -s "${REPORT_FILE}" ]; then
|
||||||
redact < "${REPORT_FILE}"
|
redact < "${REPORT_FILE}"
|
||||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
elif [ -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
|
||||||
@@ -367,16 +404,14 @@ 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() && steps.evidence.outcome == 'success' }}
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
|
name: rustfs-upgrade-test-${{ github.run_id }}
|
||||||
path: |
|
path: |
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
/tmp/rustfs-upgrade-report.md
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
/tmp/rustfs-upgrade.*/*
|
||||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
if-no-files-found: ignore
|
||||||
${{ 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)
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ on:
|
|||||||
- "Continuous Integration"
|
- "Continuous Integration"
|
||||||
- "coverage"
|
- "coverage"
|
||||||
- "e2e-nightly"
|
- "e2e-nightly"
|
||||||
- "e2e-distributed"
|
|
||||||
- "e2e-s3tests"
|
- "e2e-s3tests"
|
||||||
- "Fuzz"
|
- "Fuzz"
|
||||||
- "mint"
|
- "mint"
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: rustfs-fmt-check
|
- id: rustfs-dev-check
|
||||||
name: Rust formatting
|
name: rustfs dev-check
|
||||||
entry: cargo fmt --all --check
|
entry: make dev-check
|
||||||
language: system
|
language: system
|
||||||
types: [rust]
|
types: [rust]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|||||||
+1
-4
@@ -18,10 +18,7 @@ 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
|
||||||
- Listings: `ListObjects` v1 remains local with ordinary key markers. `ListObjectsV2` can merge source objects when `policy.list_through = true`; this is off by default
|
- 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
|
||||||
- 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
|
||||||
|
|||||||
+37
-11
@@ -109,17 +109,24 @@ affected boundaries and risks. CI still runs its configured repository gates.
|
|||||||
|
|
||||||
### 🔒 Git Pre-commit Hooks (optional)
|
### 🔒 Git Pre-commit Hooks (optional)
|
||||||
|
|
||||||
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:
|
Git hooks are **not** versioned in this repository, so a fresh clone has no
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Or manually:
|
||||||
|
|
||||||
`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.
|
```bash
|
||||||
|
chmod +x .git/hooks/pre-commit
|
||||||
|
```
|
||||||
|
|
||||||
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.
|
With or without a hook, follow the verification tiers in `AGENTS.md`. Run the
|
||||||
|
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
|
||||||
|
|
||||||
@@ -131,11 +138,31 @@ 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. **Select relevant checks** using the validation tier in `AGENTS.md`; use `make pre-commit` when its broader fast gate adds useful coverage
|
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
|
||||||
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
|
||||||
@@ -179,12 +206,11 @@ Configure your IDE to:
|
|||||||
#### Pre-commit hook not running?
|
#### Pre-commit hook not running?
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pre-commit validate-config
|
# Check if hook is executable
|
||||||
pre-commit run --all-files
|
ls -la .git/hooks/pre-commit
|
||||||
# Inspect any configured hook manager; do not overwrite it.
|
|
||||||
git config --get core.hooksPath
|
# Make it executable if needed
|
||||||
# Install if no separate hook manager is configured.
|
chmod +x .git/hooks/pre-commit
|
||||||
make setup-hooks
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Formatting issues?
|
#### Formatting issues?
|
||||||
|
|||||||
Generated
+65
-99
@@ -315,7 +315,7 @@ dependencies = [
|
|||||||
"strum",
|
"strum",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zstd 0.13.3",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -508,7 +508,7 @@ dependencies = [
|
|||||||
"arrow-select",
|
"arrow-select",
|
||||||
"flatbuffers",
|
"flatbuffers",
|
||||||
"lz4_flex",
|
"lz4_flex",
|
||||||
"zstd 0.13.3",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1679,7 +1679,7 @@ version = "0.10.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.9",
|
"generic-array 0.14.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1698,7 +1698,7 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.9",
|
"generic-array 0.14.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2110,7 +2110,7 @@ version = "0.4.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common 0.1.6",
|
"crypto-common 0.1.7",
|
||||||
"inout 0.1.4",
|
"inout 0.1.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2249,8 +2249,8 @@ dependencies = [
|
|||||||
"liblzma",
|
"liblzma",
|
||||||
"lz4",
|
"lz4",
|
||||||
"memchr",
|
"memchr",
|
||||||
"zstd 0.13.3",
|
"zstd",
|
||||||
"zstd-safe 7.3.0",
|
"zstd-safe",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2527,18 +2527,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.17"
|
version = "0.5.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-deque"
|
name = "crossbeam-deque"
|
||||||
version = "0.8.8"
|
version = "0.8.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-epoch",
|
"crossbeam-epoch",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
@@ -2546,27 +2546,27 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.21"
|
version = "0.9.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.14"
|
version = "0.3.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
|
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.23"
|
version = "0.8.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crunchy"
|
name = "crunchy"
|
||||||
@@ -2580,7 +2580,7 @@ version = "0.5.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.9",
|
"generic-array 0.14.7",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -2605,11 +2605,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.9",
|
"generic-array 0.14.7",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3673,9 +3673,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.8.2"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
|
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid 0.10.2",
|
"const-oid 0.10.2",
|
||||||
"pem-rfc7468 1.0.0",
|
"pem-rfc7468 1.0.0",
|
||||||
@@ -3901,7 +3901,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
"const-oid 0.9.6",
|
"const-oid 0.9.6",
|
||||||
"crypto-common 0.1.6",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3946,7 +3946,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.2",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4067,7 +4067,7 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"zip",
|
"zip",
|
||||||
"zstd 0.14.0",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4090,7 +4090,7 @@ version = "0.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
|
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der 0.8.2",
|
"der 0.8.1",
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
"elliptic-curve 0.14.1",
|
"elliptic-curve 0.14.1",
|
||||||
"rfc6979 0.6.0",
|
"rfc6979 0.6.0",
|
||||||
@@ -4166,7 +4166,7 @@ dependencies = [
|
|||||||
"crypto-bigint 0.5.5",
|
"crypto-bigint 0.5.5",
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
"ff 0.13.1",
|
"ff 0.13.1",
|
||||||
"generic-array 0.14.9",
|
"generic-array 0.14.7",
|
||||||
"group 0.13.0",
|
"group 0.13.0",
|
||||||
"hkdf 0.12.4",
|
"hkdf 0.12.4",
|
||||||
"pem-rfc7468 0.7.0",
|
"pem-rfc7468 0.7.0",
|
||||||
@@ -4295,7 +4295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4499,7 +4499,7 @@ checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"io-lifetimes 2.0.4",
|
"io-lifetimes 2.0.4",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4622,9 +4622,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.9"
|
version = "0.14.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
@@ -4637,7 +4637,7 @@ version = "1.4.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
|
checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array 0.14.9",
|
"generic-array 0.14.7",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
@@ -5319,9 +5319,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath-macros"
|
name = "hotpath-macros"
|
||||||
version = "0.25.1"
|
version = "0.25.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "846bde0d9600d98434e1aac376977d7718bfe3d2f5312a041b7c59a6a466c51a"
|
checksum = "929b2285d2cd21b2733a7fb6ebc843bb4f83dbd1db0122f5f9ebb9567b1e2613"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -5660,7 +5660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-padding 0.3.3",
|
"block-padding 0.3.3",
|
||||||
"generic-array 0.14.9",
|
"generic-array 0.14.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5693,7 +5693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
|
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"io-lifetimes 3.0.1",
|
"io-lifetimes 3.0.1",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5734,9 +5734,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.2"
|
version = "2.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -5758,7 +5758,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"hermit-abi",
|
"hermit-abi",
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5971,7 +5971,7 @@ dependencies = [
|
|||||||
"lz4",
|
"lz4",
|
||||||
"snap",
|
"snap",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zstd 0.13.3",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6955,7 +6955,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7115,7 +7115,7 @@ version = "5.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.21.7",
|
||||||
"chrono",
|
"chrono",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
@@ -7658,7 +7658,7 @@ dependencies = [
|
|||||||
"snap",
|
"snap",
|
||||||
"tokio",
|
"tokio",
|
||||||
"twox-hash",
|
"twox-hash",
|
||||||
"zstd 0.13.3",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7933,7 +7933,7 @@ version = "0.8.0-rc.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
|
checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der 0.8.2",
|
"der 0.8.1",
|
||||||
"spki 0.8.0",
|
"spki 0.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7976,7 +7976,7 @@ dependencies = [
|
|||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"cbc 0.2.1",
|
"cbc 0.2.1",
|
||||||
"der 0.8.2",
|
"der 0.8.1",
|
||||||
"pbkdf2 0.13.0",
|
"pbkdf2 0.13.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"scrypt 0.12.0",
|
"scrypt 0.12.0",
|
||||||
@@ -8000,7 +8000,7 @@ version = "0.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"der 0.8.2",
|
"der 0.8.1",
|
||||||
"pkcs5 0.8.1",
|
"pkcs5 0.8.1",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"spki 0.8.0",
|
"spki 0.8.0",
|
||||||
@@ -8706,7 +8706,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8942,9 +8942,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "1.7.0"
|
version = "1.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
|
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"arcstr",
|
"arcstr",
|
||||||
@@ -9326,7 +9326,7 @@ dependencies = [
|
|||||||
"curve25519-dalek 5.0.0",
|
"curve25519-dalek 5.0.0",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"delegate",
|
"delegate",
|
||||||
"der 0.8.2",
|
"der 0.8.1",
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
"ecdsa 0.17.0",
|
"ecdsa 0.17.0",
|
||||||
"ed25519-dalek 3.0.0",
|
"ed25519-dalek 3.0.0",
|
||||||
@@ -9493,8 +9493,6 @@ dependencies = [
|
|||||||
"atomic_enum",
|
"atomic_enum",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"aws-smithy-runtime-api",
|
|
||||||
"aws-smithy-types",
|
|
||||||
"axum",
|
"axum",
|
||||||
"base64-simd",
|
"base64-simd",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -9503,13 +9501,11 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"const-str",
|
"const-str",
|
||||||
"datafusion",
|
"datafusion",
|
||||||
"faster-hex",
|
|
||||||
"flatbuffers",
|
"flatbuffers",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"google-cloud-auth",
|
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"hex-simd",
|
"hex-simd",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
@@ -9528,7 +9524,6 @@ dependencies = [
|
|||||||
"metrics",
|
"metrics",
|
||||||
"metrics-util",
|
"metrics-util",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"moka",
|
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
"p256 0.14.0",
|
"p256 0.14.0",
|
||||||
@@ -9621,10 +9616,9 @@ dependencies = [
|
|||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"x509-parser",
|
"x509-parser",
|
||||||
"xxhash-rust",
|
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zip",
|
"zip",
|
||||||
"zstd 0.14.0",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10234,7 +10228,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"zip",
|
"zip",
|
||||||
"zstd 0.14.0",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10401,7 +10395,7 @@ dependencies = [
|
|||||||
"tracing-opentelemetry",
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
"zstd 0.14.0",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10745,7 +10739,6 @@ dependencies = [
|
|||||||
"rustfs-data-usage",
|
"rustfs-data-usage",
|
||||||
"rustfs-ecstore",
|
"rustfs-ecstore",
|
||||||
"rustfs-filemeta",
|
"rustfs-filemeta",
|
||||||
"rustfs-heal",
|
|
||||||
"rustfs-heal-contracts",
|
"rustfs-heal-contracts",
|
||||||
"rustfs-lifecycle",
|
"rustfs-lifecycle",
|
||||||
"rustfs-lock",
|
"rustfs-lock",
|
||||||
@@ -10992,7 +10985,7 @@ dependencies = [
|
|||||||
"transform-stream",
|
"transform-stream",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows",
|
||||||
"zstd 0.14.0",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -11066,7 +11059,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -11149,7 +11142,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -11406,7 +11399,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct 0.2.0",
|
"base16ct 0.2.0",
|
||||||
"der 0.7.10",
|
"der 0.7.10",
|
||||||
"generic-array 0.14.9",
|
"generic-array 0.14.7",
|
||||||
"pkcs8 0.10.2",
|
"pkcs8 0.10.2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -11420,7 +11413,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base16ct 1.0.0",
|
"base16ct 1.0.0",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
"der 0.8.2",
|
"der 0.8.1",
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -11994,7 +11987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
|
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"der 0.8.2",
|
"der 0.8.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -12409,7 +12402,7 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -13528,7 +13521,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -13658,15 +13651,6 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.59.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets 0.52.6",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -13839,7 +13823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -14111,7 +14095,7 @@ dependencies = [
|
|||||||
"typed-path",
|
"typed-path",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zopfli",
|
"zopfli",
|
||||||
"zstd 0.13.3",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -14144,16 +14128,7 @@ version = "0.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zstd-safe 7.3.0",
|
"zstd-safe",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zstd"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e"
|
|
||||||
dependencies = [
|
|
||||||
"zstd-safe 8.0.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -14165,15 +14140,6 @@ dependencies = [
|
|||||||
"zstd-sys",
|
"zstd-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zstd-safe"
|
|
||||||
version = "8.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab"
|
|
||||||
dependencies = [
|
|
||||||
"zstd-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zstd-sys"
|
name = "zstd-sys"
|
||||||
version = "2.1.0+zstd.1.5.7"
|
version = "2.1.0+zstd.1.5.7"
|
||||||
|
|||||||
+11
-12
@@ -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" }
|
||||||
@@ -256,10 +256,10 @@ clap = { version = "4.6.6" }
|
|||||||
const-str = { version = "1.1.0" }
|
const-str = { version = "1.1.0" }
|
||||||
convert_case = "0.12.0"
|
convert_case = "0.12.0"
|
||||||
criterion = { version = "0.8" }
|
criterion = { version = "0.8" }
|
||||||
crossbeam-queue = "0.3.14"
|
crossbeam-queue = "0.3.13"
|
||||||
crossbeam-channel = "0.5.17"
|
crossbeam-channel = "0.5.16"
|
||||||
crossbeam-deque = "0.8.8"
|
crossbeam-deque = "0.8.7"
|
||||||
crossbeam-utils = "0.8.23"
|
crossbeam-utils = "0.8.22"
|
||||||
datafusion = { default-features = false, version = "55.0.0" }
|
datafusion = { default-features = false, version = "55.0.0" }
|
||||||
derive_builder = "0.20.2"
|
derive_builder = "0.20.2"
|
||||||
enumset = "1.1.14"
|
enumset = "1.1.14"
|
||||||
@@ -306,7 +306,7 @@ rustfs-erasure-codec = { version = "8.0.2" }
|
|||||||
reed-solomon-simd = "3.1.0"
|
reed-solomon-simd = "3.1.0"
|
||||||
regex = { version = "1.13.1" }
|
regex = { version = "1.13.1" }
|
||||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||||
redis = { version = "1.7.0" }
|
redis = { version = "1.6.0" }
|
||||||
rustify = { version = "0.7", default-features = false }
|
rustify = { version = "0.7", default-features = false }
|
||||||
rustix = { version = "1.1.4" }
|
rustix = { version = "1.1.4" }
|
||||||
rust-embed = { version = "8.12.0" }
|
rust-embed = { version = "8.12.0" }
|
||||||
@@ -343,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.14.0"
|
zstd = "0.13.3"
|
||||||
|
|
||||||
# Observability and Metrics
|
# Observability and Metrics
|
||||||
metrics = "0.24.6"
|
metrics = "0.24.6"
|
||||||
@@ -371,8 +371,7 @@ 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" }
|
||||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
hotpath = { version = "0.25.0", default-features = false }
|
||||||
hotpath = { version = "=0.25.0", default-features = false }
|
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
|
|
||||||
|
|||||||
@@ -422,9 +422,9 @@ fn unix_now_ms() -> u64 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy, unverified repair notice. Its identity lacks kind, set scope,
|
/// A repair the MRF consumer landed, fanned out so retry ledgers can drop
|
||||||
/// bucket incarnation and responsibility generation. Consumers must not use
|
/// entries the journal no longer tracks (backlog#1894 axis B). The payload
|
||||||
/// it to discharge persisted repair responsibility.
|
/// mirrors the intent identity so consumers match without re-parsing.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct MrfRepairedEvent {
|
pub struct MrfRepairedEvent {
|
||||||
pub bucket: Arc<str>,
|
pub bucket: Arc<str>,
|
||||||
@@ -439,8 +439,8 @@ const MRF_REPAIRED_EVENT_CAP: usize = 4096;
|
|||||||
|
|
||||||
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
||||||
|
|
||||||
/// Record a legacy notification for compatibility. This is not an
|
/// Record that the MRF consumer landed a repair. Never blocks: the critical
|
||||||
/// acknowledgement of storage verification or durable repair completion.
|
/// section is a deque push under a std mutex.
|
||||||
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
||||||
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||||
let Ok(mut events) = registry.lock() else {
|
let Ok(mut events) = registry.lock() else {
|
||||||
@@ -515,9 +515,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
coalescer_release(&key, Some(lease));
|
coalescer_release(&key, Some(lease));
|
||||||
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
|
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
|
||||||
assert_ne!(lease, retry_lease);
|
|
||||||
coalescer_release(&key, Some(lease));
|
|
||||||
assert_eq!(coalescer_admit(key.clone()), Err(MrfIngressResult::Coalesced));
|
|
||||||
coalescer_release(&key, Some(retry_lease));
|
coalescer_release(&key, Some(retry_lease));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,21 +130,6 @@ 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`
|
||||||
@@ -172,14 +157,6 @@ Drive timeout profile preset:
|
|||||||
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
||||||
- Then the profile-derived default (`default` or `high_latency`).
|
- Then the profile-derived default (`default` or `high_latency`).
|
||||||
|
|
||||||
## Admin peer probe timeout
|
|
||||||
|
|
||||||
- `RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS`
|
|
||||||
- total per-peer budget for the `server_info`/`storage_info` admin probe round; `server_info` may reconnect once and `storage_info` remains a single attempt.
|
|
||||||
- default is `10` seconds, preserving the previous two-attempt worst-case budget.
|
|
||||||
- values must be positive; `0` or an invalid value falls back to the default, and values above `60` are clamped to `60`.
|
|
||||||
- the setting is read by the aggregating node only; it does not change the internode RPC wire contract. Any retry shares one round deadline rather than receiving a fresh timeout.
|
|
||||||
|
|
||||||
## Startup filesystem boundary policy
|
## Startup filesystem boundary policy
|
||||||
|
|
||||||
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
|
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
|
||||||
|
|||||||
@@ -39,15 +39,6 @@ pub const DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS: u64 = 20;
|
|||||||
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
|
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
|
||||||
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 30;
|
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
/// Total budget for one admin peer probe round, including any reconnect retry.
|
|
||||||
///
|
|
||||||
/// This is intentionally separate from the transport-level RPC timeout: admin
|
|
||||||
/// probes may retry once, but the retry must consume the same round budget.
|
|
||||||
pub const ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS: &str = "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS";
|
|
||||||
pub const DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 10;
|
|
||||||
pub const MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 60;
|
|
||||||
const _: () = assert!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS <= MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS);
|
|
||||||
|
|
||||||
// ── Client-side internode gRPC channel tuning (P0) ──
|
// ── Client-side internode gRPC channel tuning (P0) ──
|
||||||
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
|
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
|
||||||
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
|
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
|
||||||
@@ -321,7 +312,6 @@ mod tests {
|
|||||||
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5);
|
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5);
|
||||||
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 20);
|
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 20);
|
||||||
assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 30);
|
assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 30);
|
||||||
assert_eq!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS, 10);
|
|
||||||
assert_eq!(DEFAULT_INTERNODE_HTTP_TUNING_PROFILE, "legacy");
|
assert_eq!(DEFAULT_INTERNODE_HTTP_TUNING_PROFILE, "legacy");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +412,6 @@ mod tests {
|
|||||||
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
|
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
|
||||||
);
|
);
|
||||||
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
|
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
|
||||||
assert_eq!(ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS, "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS");
|
|
||||||
assert_eq!(ENV_INTERNODE_HTTP_TUNING_PROFILE, "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE");
|
assert_eq!(ENV_INTERNODE_HTTP_TUNING_PROFILE, "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE");
|
||||||
assert_eq!(ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST, "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST");
|
assert_eq!(ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST, "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST");
|
||||||
assert_eq!(ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS, "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS");
|
assert_eq!(ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS, "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS");
|
||||||
|
|||||||
@@ -137,28 +137,6 @@ 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.
|
||||||
///
|
///
|
||||||
@@ -834,16 +812,6 @@ 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");
|
||||||
|
|||||||
+33
-48
@@ -1,7 +1,7 @@
|
|||||||
# e2e_test
|
# e2e_test
|
||||||
|
|
||||||
End-to-end test suite for RustFS. Each test spawns a **real `rustfs` binary**
|
End-to-end test suite for RustFS. Each test spawns a **real `rustfs` binary**
|
||||||
(built on demand from the workspace) and drives it over the network with the
|
(built and identified before the test invocation) and drives it over the network with the
|
||||||
AWS SDK (`aws-sdk-s3`), raw HTTP (`reqwest` / `awscurl`), or a protocol client
|
AWS SDK (`aws-sdk-s3`), raw HTTP (`reqwest` / `awscurl`), or a protocol client
|
||||||
(FTPS / WebDAV / SFTP). This is the black-box integration layer: exhaustive
|
(FTPS / WebDAV / SFTP). This is the black-box integration layer: exhaustive
|
||||||
end-to-end behavior lives here, unit behavior stays in the source crates
|
end-to-end behavior lives here, unit behavior stays in the source crates
|
||||||
@@ -26,38 +26,33 @@ 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 |
|
||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
All commands assume repo root. `cargo test` triggers an on-demand build of the
|
All commands assume repo root and Python 3.9 or newer on Linux or macOS. Build the server once through the provenance entry point, then run the test command through the same script:
|
||||||
`rustfs` binary from [`src/common.rs`](src/common.rs) (`rustfs_binary_path`) on
|
|
||||||
first use — the first invocation is slow, later ones reuse the binary.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Whole crate (default = ignored tests skipped)
|
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||||
cargo nextest run -p e2e_test
|
|
||||||
|
# Whole crate (ignored tests remain skipped)
|
||||||
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test
|
||||||
|
|
||||||
# One module
|
# One module
|
||||||
cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run -p e2e_test -E 'test(list_objects_v2_pagination_test)'
|
||||||
|
|
||||||
# PR smoke subset (see "CI smoke subset" below)
|
|
||||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
|
||||||
|
|
||||||
# ILM serial lane — ignored lifecycle tests, single-threaded (mirrors CI)
|
|
||||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
|
||||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
|
||||||
|
|
||||||
|
# PR smoke subset
|
||||||
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||||
```
|
```
|
||||||
|
|
||||||
The protocols suite has its own contract (fixed bind ports 9022–9301,
|
`build` records the source contents, HEAD, resolved Cargo features, profile, toolchain, and binary SHA-256 beside the executable in `rustfs.e2e.json`. `run` validates that identity before and after the command, preserves command failures, and removes its temporary run receipt on completion. The Rust harness checks that receipt before starting each server; it never compiles a server inside a test process. Source or binary changes during a run invalidate the result, even when the test command succeeds. Use an isolated worktree and keep it unchanged until the command finishes.
|
||||||
single-worker execution, feature-gated scheduling) documented in
|
|
||||||
[`src/protocols/README.md`](src/protocols/README.md). `RUSTFS_BUILD_FEATURES`
|
The additional `--features` arguments must match between `build` and `run`; Cargo defaults remain enabled. The wrapper supplies `RUSTFS_BUILD_FEATURES` from Cargo's resolved feature list, including features enabled by `full`. Protocol helpers require a subset of that list. `CARGO_TARGET_DIR` and `--profile release` are supported. An in-workspace target directory must be Git-ignored; tracked files are always included in the source identity. `build --bins` preserves CI lanes that compile all RustFS binary targets. For a downloaded artifact, copy both the executable and its sidecar, then use `run`; do not generate a new identity for an arbitrary prebuilt binary. `CARGO_BIN_EXE_rustfs` cannot override the verified executable.
|
||||||
selects which features the spawned binary is built with; leave it unset to run
|
|
||||||
every protocol entry. Use the exact profile command under
|
Each build/run holds an exclusive `rustfs.e2e.lock` marker beside the binary; concurrent wrappers fail immediately. Use a private target directory and do not run ordinary Cargo builds against it while tests are active: Cargo does not honor this marker. Interrupted runs fail and terminate their command group. After an uncatchable kill, inspect the PID recorded in a leftover marker and remove it only after confirming its owner has stopped. Embedded file symlinks are hashed through their target; embedded directory symlinks are rejected because their contents cannot be enumerated safely by this entry point.
|
||||||
[Troubleshooting](#troubleshooting) for CI-equivalent execution.
|
|
||||||
|
The protocols suite has its own fixed-port and single-worker contract in [`src/protocols/README.md`](src/protocols/README.md). Use its command under [Troubleshooting](#troubleshooting).
|
||||||
|
|
||||||
### `#[ignore]` semantics
|
### `#[ignore]` semantics
|
||||||
|
|
||||||
@@ -123,7 +118,7 @@ via `create_s3_client(idx)` / `create_all_clients()`. See
|
|||||||
| `wait_for_server_ready` | Poll readiness before issuing requests |
|
| `wait_for_server_ready` | Poll readiness before issuing requests |
|
||||||
| `create_s3_client` / `create_test_bucket` / `delete_test_bucket` | aws-sdk-s3 client + bucket lifecycle |
|
| `create_s3_client` / `create_test_bucket` / `delete_test_bucket` | aws-sdk-s3 client + bucket lifecycle |
|
||||||
| `find_available_port` | Random free port (isolation primitive) |
|
| `find_available_port` | Random free port (isolation primitive) |
|
||||||
| `rustfs_binary_path` / `_with_features` | Locate/build the binary; honors `RUSTFS_BUILD_FEATURES` |
|
| `rustfs_binary_path` / `_with_features` | Verify this run's binary receipt and required feature subset |
|
||||||
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
|
| `requested_rustfs_build_features` / `rustfs_build_feature_enabled` | Feature-gate a test to what the binary was built with |
|
||||||
| `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl`; missing binaries are test failures |
|
| `execute_awscurl` / `awscurl_post` / `_get` / `_put` / `_delete` / `awscurl_post_sts_form_urlencoded` | Admin/STS API calls via `awscurl`; missing binaries are test failures |
|
||||||
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
|
| `replication_fast_env` | Env vars that shrink replication timers (from repl-4); pass to `start_rustfs_server_with_env` |
|
||||||
@@ -172,7 +167,6 @@ 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) |
|
||||||
@@ -187,35 +181,26 @@ the wiring source of truth. Committed test-ID digests under
|
|||||||
**Reproduce a CI failure locally** — run the exact profile/lane:
|
**Reproduce a CI failure locally** — run the exact profile/lane:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Smoke (e2e-tests job) — includes the 20 fast replication tests
|
# Smoke, full, and cluster lanes share a server with fault-test hooks.
|
||||||
cargo nextest run --profile e2e-smoke -p e2e_test
|
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||||
# Full single-node merge/main lane
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke -p e2e_test
|
||||||
cargo nextest run --profile e2e-full -p e2e_test
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
|
||||||
# Cluster fault nightly lane
|
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- 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)
|
# Replication nightly uses the default server; awscurl is required for STS.
|
||||||
# Upgrade cases need RUSTFS_UPGRADE_SOURCE_BINARY; without it they fail closed.
|
python3 scripts/e2e_binary.py build
|
||||||
cargo nextest run --profile e2e-distributed -p e2e_test
|
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
# Replication nightly lane; awscurl is required for STS paths
|
|
||||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
# Protocol nightly owns fixed ports.
|
||||||
# Fixed-port protocol nightly lane
|
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
||||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp \
|
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||||
cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
|
||||||
# ILM serial lane
|
# The ILM serial lane does not use this server harness.
|
||||||
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
cargo nextest run -j1 --run-ignored ignored-only -p rustfs-scanner -p rustfs \
|
||||||
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
-E 'binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))'
|
||||||
# s3s-e2e black box
|
|
||||||
./scripts/e2e-run.sh ./target/debug/rustfs /tmp/rustfs-e2e-data
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Stale binary.** Tests build the `rustfs` binary once and reuse it. To avoid
|
**Stale or unverified binary.** Re-run the matching `build` command after changing source or features, then invoke tests through `run`. A missing receipt, copied old executable, or mismatched build identity is a prerequisite failure. Bare Cargo invocations that start a server deliberately fail; unit tests that do not start a server can still run directly.
|
||||||
rebuilding while iterating on tests, `common.rs` reuses an existing binary when
|
|
||||||
running *inside* the e2e test process even if sources changed
|
|
||||||
(`can_reuse_inside_e2e`, [`src/common.rs`](src/common.rs) line 98). Downside: if
|
|
||||||
you changed **server** code, force a rebuild with
|
|
||||||
`cargo build -p rustfs` (or `touch` a source file outside the reuse window)
|
|
||||||
before re-running, or CI's freshly built artifact will diverge from your local
|
|
||||||
one.
|
|
||||||
|
|
||||||
**Port already in use / orphan processes.** A hard-killed run can leak a
|
**Port already in use / orphan processes.** A hard-killed run can leak a
|
||||||
`rustfs` child holding its port. Find and kill it:
|
`rustfs` child holding its port. Find and kill it:
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
// 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,20 +55,18 @@ 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, serde::Serialize)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
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, serde::Serialize)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub(crate) struct PartShardFingerprint {
|
pub(crate) struct PartShardFingerprint {
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
@@ -90,15 +88,13 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
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()
|
||||||
}
|
}
|
||||||
@@ -317,8 +313,6 @@ 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,
|
||||||
@@ -366,8 +360,6 @@ 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,
|
||||||
@@ -421,8 +413,6 @@ 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,
|
||||||
|
|||||||
+121
-210
@@ -31,7 +31,6 @@ use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
|||||||
use rustfs_signer::sign_v4;
|
use rustfs_signer::sign_v4;
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use std::ffi::OsStr;
|
|
||||||
use std::fs as stdfs;
|
use std::fs as stdfs;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -44,7 +43,6 @@ use tokio::net::TcpStream;
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use walkdir::WalkDir;
|
|
||||||
|
|
||||||
// Common constants for all E2E tests
|
// Common constants for all E2E tests
|
||||||
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
pub const DEFAULT_ACCESS_KEY: &str = "rustfsadmin";
|
||||||
@@ -365,59 +363,75 @@ fn resolve_rustfs_binary_path(workspace: &Path, configured_target_dir: Option<&P
|
|||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the RustFS binary relative to the workspace, optionally requesting build features.
|
/// Resolve the server verified by `scripts/e2e_binary.py run` for this test invocation.
|
||||||
|
/// Requested features are a required subset of the server's resolved Cargo features.
|
||||||
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
pub fn rustfs_binary_path_with_features(requested_features: Option<&str>) -> PathBuf {
|
||||||
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rustfs") {
|
|
||||||
return PathBuf::from(path);
|
|
||||||
}
|
|
||||||
let requested_features = requested_features.and_then(normalize_rustfs_build_features);
|
|
||||||
|
|
||||||
let workspace = workspace_root();
|
let workspace = workspace_root();
|
||||||
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
let configured_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from);
|
||||||
let binary_path = resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref());
|
let binary_path = std::env::var_os("CARGO_BIN_EXE_rustfs")
|
||||||
|
.map(PathBuf::from)
|
||||||
let features_match = binary_features_match(&binary_path, requested_features.as_deref());
|
.unwrap_or_else(|| resolve_rustfs_binary_path(&workspace, configured_target_dir.as_deref()));
|
||||||
let source_is_newer = workspace_sources_newer_than_binary(&binary_path);
|
let receipt_path = std::env::var_os("RUSTFS_E2E_BINARY_RECEIPT").map(PathBuf::from);
|
||||||
let can_reuse_inside_e2e = running_inside_e2e_test_binary() && requested_features.is_none() && features_match;
|
receipt_path
|
||||||
if binary_path.is_file() && features_match && (!source_is_newer || can_reuse_inside_e2e) {
|
.ok_or_else(|| std::io::Error::new(ErrorKind::NotFound, "missing E2E run receipt"))
|
||||||
if source_is_newer {
|
.and_then(|receipt| verify_e2e_binary_receipt(&receipt, &workspace, &binary_path, requested_features))
|
||||||
warn!(
|
.unwrap_or_else(|error| {
|
||||||
"RustFS binary at {:?} appears older than workspace sources; reusing it inside cargo test to avoid nested builds",
|
panic!(
|
||||||
binary_path
|
"E2E server prerequisite failed: {error}. Build with `python3 scripts/e2e_binary.py build --features <features>` and run tests with `python3 scripts/e2e_binary.py run --features <features> -- cargo nextest run ...`"
|
||||||
);
|
)
|
||||||
}
|
})
|
||||||
info!("Using existing RustFS binary at {:?}", binary_path);
|
|
||||||
return binary_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Building RustFS binary to ensure it's up to date...");
|
|
||||||
build_rustfs_binary(requested_features.as_deref(), &binary_path);
|
|
||||||
|
|
||||||
info!("Using RustFS binary at {:?}", binary_path);
|
|
||||||
binary_path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_sources_newer_than_binary(binary_path: &PathBuf) -> bool {
|
#[derive(serde::Deserialize)]
|
||||||
let Ok(binary_meta) = std::fs::metadata(binary_path) else {
|
#[serde(deny_unknown_fields)]
|
||||||
return true;
|
struct E2eBinaryReceipt {
|
||||||
};
|
schema: u32,
|
||||||
let Ok(binary_modified) = binary_meta.modified() else {
|
workspace: PathBuf,
|
||||||
return true;
|
binary: PathBuf,
|
||||||
};
|
size: u64,
|
||||||
|
modified_ns: u128,
|
||||||
let workspace = workspace_root();
|
features: Vec<String>,
|
||||||
let watch_roots = [
|
|
||||||
workspace.join("Cargo.toml"),
|
|
||||||
workspace.join("Cargo.lock"),
|
|
||||||
workspace.join("rustfs"),
|
|
||||||
workspace.join("crates"),
|
|
||||||
];
|
|
||||||
|
|
||||||
watch_roots.iter().any(|path| path_is_newer_than(binary_modified, path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn running_inside_e2e_test_binary() -> bool {
|
fn verify_e2e_binary_receipt(
|
||||||
std::env::var("CARGO_PKG_NAME").is_ok_and(|value| value == "e2e_test")
|
receipt_path: &Path,
|
||||||
|
workspace: &Path,
|
||||||
|
binary_path: &Path,
|
||||||
|
requested_features: Option<&str>,
|
||||||
|
) -> std::io::Result<PathBuf> {
|
||||||
|
let receipt: E2eBinaryReceipt = serde_json::from_slice(&stdfs::read(receipt_path)?)?;
|
||||||
|
let binary = binary_path.canonicalize()?;
|
||||||
|
let metadata = binary.metadata()?;
|
||||||
|
let modified_ns = metadata
|
||||||
|
.modified()?
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_err(std::io::Error::other)?
|
||||||
|
.as_nanos();
|
||||||
|
// The runner hashes source and binary before/after the entire suite. Each
|
||||||
|
// nextest process checks only this invocation's path, features, and file stat.
|
||||||
|
if receipt.schema != 1
|
||||||
|
|| receipt.workspace != workspace.canonicalize()?
|
||||||
|
|| receipt.binary != binary
|
||||||
|
|| !metadata.is_file()
|
||||||
|
|| receipt.size != metadata.len()
|
||||||
|
|| receipt.modified_ns != modified_ns
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"E2E server differs from this run's verified binary",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(requested) = requested_features.and_then(normalize_rustfs_build_features)
|
||||||
|
&& requested
|
||||||
|
.split(',')
|
||||||
|
.any(|feature| !receipt.features.iter().any(|actual| actual == feature))
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
"E2E server is missing a requested build feature",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn requested_rustfs_build_features() -> Option<String> {
|
pub fn requested_rustfs_build_features() -> Option<String> {
|
||||||
@@ -447,96 +461,6 @@ pub fn rustfs_build_feature_enabled(requested_features: Option<&str>, required_f
|
|||||||
.any(|feature| feature.eq_ignore_ascii_case(RUSTFS_FULL_FEATURE) || feature.eq_ignore_ascii_case(required_feature))
|
.any(|feature| feature.eq_ignore_ascii_case(RUSTFS_FULL_FEATURE) || feature.eq_ignore_ascii_case(required_feature))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rustfs_binary_features_stamp_path(binary_path: &Path) -> PathBuf {
|
|
||||||
binary_path.with_extension("features")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn binary_features_match(binary_path: &Path, requested_features: Option<&str>) -> bool {
|
|
||||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
|
||||||
let recorded = stdfs::read_to_string(stamp_path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| normalize_rustfs_build_features(&value));
|
|
||||||
let requested = requested_features.and_then(normalize_rustfs_build_features);
|
|
||||||
|
|
||||||
match requested.as_deref() {
|
|
||||||
Some(features) => recorded.as_deref() == Some(features),
|
|
||||||
None => recorded.is_none(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn path_is_newer_than(binary_modified: std::time::SystemTime, path: &Path) -> bool {
|
|
||||||
if path.is_file() {
|
|
||||||
return std::fs::metadata(path)
|
|
||||||
.and_then(|meta| meta.modified())
|
|
||||||
.map(|modified| modified > binary_modified)
|
|
||||||
.unwrap_or(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !path.is_dir() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
WalkDir::new(path)
|
|
||||||
.into_iter()
|
|
||||||
.filter_entry(|entry| {
|
|
||||||
let name = entry.file_name();
|
|
||||||
name != OsStr::new("target") && name != OsStr::new(".git")
|
|
||||||
})
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.filter(|entry| entry.file_type().is_file())
|
|
||||||
.any(|entry| {
|
|
||||||
std::fs::metadata(entry.path())
|
|
||||||
.and_then(|meta| meta.modified())
|
|
||||||
.map(|modified| modified > binary_modified)
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the RustFS binary using cargo
|
|
||||||
fn build_rustfs_binary(requested_features: Option<&str>, binary_path: &Path) {
|
|
||||||
let workspace = workspace_root();
|
|
||||||
info!("Building RustFS binary from workspace: {:?}", workspace);
|
|
||||||
|
|
||||||
let _profile = if cfg!(debug_assertions) {
|
|
||||||
info!("Building in debug mode");
|
|
||||||
"dev"
|
|
||||||
} else {
|
|
||||||
info!("Building in release mode");
|
|
||||||
"release"
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut cmd = Command::new("cargo");
|
|
||||||
cmd.current_dir(&workspace).args(["build", "--bin", "rustfs"]);
|
|
||||||
|
|
||||||
if let Some(features) = requested_features {
|
|
||||||
cmd.arg("--features").arg(features);
|
|
||||||
info!("Building with features: {}", features);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cfg!(debug_assertions) {
|
|
||||||
cmd.arg("--release");
|
|
||||||
}
|
|
||||||
|
|
||||||
info!(
|
|
||||||
"Executing: cargo build --bin rustfs {}",
|
|
||||||
if cfg!(debug_assertions) { "" } else { "--release" }
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = cmd.output().expect("Failed to execute cargo build command");
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
panic!("Failed to build RustFS binary. Error: {stderr}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let stamp_path = rustfs_binary_features_stamp_path(binary_path);
|
|
||||||
if let Err(err) = stdfs::write(&stamp_path, requested_features.unwrap_or_default()) {
|
|
||||||
warn!("Failed to write RustFS feature stamp {:?}: {}", stamp_path, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("✅ RustFS binary built successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn awscurl_binary_path() -> PathBuf {
|
fn awscurl_binary_path() -> PathBuf {
|
||||||
std::env::var_os("AWSCURL_PATH")
|
std::env::var_os("AWSCURL_PATH")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
@@ -1700,69 +1624,6 @@ 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
|
||||||
@@ -2136,16 +1997,66 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn binary_feature_stamp_matching_uses_normalized_features() {
|
fn explicit_binary_without_run_receipt_is_rejected() {
|
||||||
let binary_path = std::env::temp_dir().join(format!("rustfs-feature-stamp-test-{}", Uuid::new_v4()));
|
const CHILD_ENV: &str = "RUSTFS_E2E_RECEIPT_TEST_CHILD";
|
||||||
let stamp_path = rustfs_binary_features_stamp_path(&binary_path);
|
if std::env::var_os(CHILD_ENV).is_some() {
|
||||||
|
rustfs_binary_path_with_features(None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let executable = std::env::current_exe().expect("locate isolated test process");
|
||||||
|
let output = Command::new(&executable)
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"common::tests::explicit_binary_without_run_receipt_is_rejected",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env(CHILD_ENV, "1")
|
||||||
|
.env("CARGO_BIN_EXE_rustfs", &executable)
|
||||||
|
.env_remove("RUSTFS_E2E_BINARY_RECEIPT")
|
||||||
|
.output()
|
||||||
|
.expect("run the missing-receipt scenario with isolated environment variables");
|
||||||
|
assert!(!output.status.success(), "an explicit binary must not bypass run verification");
|
||||||
|
assert!(String::from_utf8_lossy(&output.stderr).contains("missing E2E run receipt"));
|
||||||
|
}
|
||||||
|
|
||||||
stdfs::write(&stamp_path, " SFTP, ftps ").expect("write feature stamp");
|
#[test]
|
||||||
assert!(binary_features_match(&binary_path, Some("sftp,ftps")));
|
fn e2e_run_receipt_rejects_replaced_binary_and_missing_features() {
|
||||||
assert!(binary_features_match(&binary_path, Some(" SFTP, FTPS ")));
|
let directory = std::env::temp_dir().join(format!("rustfs-e2e-receipt-test-{}", Uuid::new_v4()));
|
||||||
assert!(!binary_features_match(&binary_path, Some("sftp")));
|
stdfs::create_dir(&directory).expect("create receipt fixture");
|
||||||
|
let binary = directory.join("rustfs");
|
||||||
stdfs::remove_file(stamp_path).ok();
|
let receipt = directory.join("receipt.json");
|
||||||
|
stdfs::write(&binary, "server").expect("write fixture binary");
|
||||||
|
let metadata = binary.metadata().expect("stat fixture binary");
|
||||||
|
let record = serde_json::json!({
|
||||||
|
"schema": 1,
|
||||||
|
"workspace": directory.canonicalize().expect("canonical workspace"),
|
||||||
|
"binary": binary.canonicalize().expect("canonical binary"),
|
||||||
|
"size": metadata.len(),
|
||||||
|
"modified_ns": metadata.modified().expect("modified time").duration_since(std::time::UNIX_EPOCH).expect("positive timestamp").as_nanos(),
|
||||||
|
"features": ["default", "full", "ftps", "webdav", "sftp"]
|
||||||
|
});
|
||||||
|
stdfs::write(&receipt, serde_json::to_vec(&record).expect("serialize receipt")).expect("write receipt");
|
||||||
|
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("sftp,webdav")).expect("resolved feature subset");
|
||||||
|
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("full")).expect("full was actually requested");
|
||||||
|
assert_eq!(
|
||||||
|
verify_e2e_binary_receipt(&receipt, &directory, &binary, Some("rio-v2"))
|
||||||
|
.expect_err("full does not enable rio-v2")
|
||||||
|
.kind(),
|
||||||
|
ErrorKind::InvalidInput
|
||||||
|
);
|
||||||
|
let other = directory.join("old-server");
|
||||||
|
stdfs::write(&other, "server").expect("write alternate binary");
|
||||||
|
assert!(verify_e2e_binary_receipt(&receipt, &directory, &other, None).is_err());
|
||||||
|
stdfs::write(&binary, "different server").expect("replace fixture binary");
|
||||||
|
assert!(verify_e2e_binary_receipt(&receipt, &directory, &binary, None).is_err());
|
||||||
|
stdfs::remove_file(&receipt).expect("remove expired receipt");
|
||||||
|
assert_eq!(
|
||||||
|
verify_e2e_binary_receipt(&receipt, &directory, &binary, None)
|
||||||
|
.expect_err("expired receipt")
|
||||||
|
.kind(),
|
||||||
|
ErrorKind::NotFound
|
||||||
|
);
|
||||||
|
stdfs::remove_dir_all(directory).expect("remove receipt fixture");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a cluster environment struct in-memory (no ports, no processes) so
|
/// Build a cluster environment struct in-memory (no ports, no processes) so
|
||||||
|
|||||||
@@ -35,15 +35,11 @@ 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..90 {
|
for _ in 0..45 {
|
||||||
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.is_complete_bucket_usage_snapshot()
|
if usage.buckets_usage.contains_key(bucket) && predicate(&usage) {
|
||||||
&& 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;
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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(())
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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(())
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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(())
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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(())
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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
@@ -1,35 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
//! 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;
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
use 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(())
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
use 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(())
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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(())
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
use 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(())
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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(())
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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(())
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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(())
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
// Copyright 2026 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/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(())
|
|
||||||
}
|
|
||||||
@@ -451,40 +451,10 @@ impl JournaledHeaders {
|
|||||||
struct ControlState {
|
struct ControlState {
|
||||||
scripts: HashMap<Operation, VecDeque<FaultAction>>,
|
scripts: HashMap<Operation, VecDeque<FaultAction>>,
|
||||||
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
|
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
|
||||||
held_get: Option<HeldGetObject>,
|
|
||||||
requests: VecDeque<RequestRecord>,
|
requests: VecDeque<RequestRecord>,
|
||||||
next_sequence: u64,
|
next_sequence: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct HeldGetObject {
|
|
||||||
bucket: String,
|
|
||||||
key: String,
|
|
||||||
entered: watch::Sender<usize>,
|
|
||||||
released: watch::Receiver<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Holds every GET of one object, including retries, until this guard is dropped.
|
|
||||||
#[must_use = "dropping the guard releases the held GET requests"]
|
|
||||||
pub struct GetObjectGate {
|
|
||||||
control: Arc<Mutex<ControlState>>,
|
|
||||||
entered: watch::Receiver<usize>,
|
|
||||||
released: watch::Sender<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GetObjectGate {
|
|
||||||
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
|
|
||||||
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for GetObjectGate {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
lock(&self.control).held_get = None;
|
|
||||||
self.released.send_replace(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct StoreState {
|
struct StoreState {
|
||||||
assign_own_version_ids: bool,
|
assign_own_version_ids: bool,
|
||||||
@@ -966,30 +936,6 @@ impl FakeS3Target {
|
|||||||
.extend(std::iter::repeat_n(action, times));
|
.extend(std::iter::repeat_n(action, times));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hold one exact bucket/key before any GET response can reach the client.
|
|
||||||
/// The fixture supports one live gate; request and connection deadlines still apply.
|
|
||||||
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
|
|
||||||
assert!(
|
|
||||||
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
|
|
||||||
"held GET identifiers exceed the fixture limit"
|
|
||||||
);
|
|
||||||
let mut state = lock(&self.control);
|
|
||||||
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
|
|
||||||
let (entered, entered_rx) = watch::channel(0);
|
|
||||||
let (released, released_rx) = watch::channel(false);
|
|
||||||
state.held_get = Some(HeldGetObject {
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
key: key.to_string(),
|
|
||||||
entered,
|
|
||||||
released: released_rx,
|
|
||||||
});
|
|
||||||
GetObjectGate {
|
|
||||||
control: Arc::clone(&self.control),
|
|
||||||
entered: entered_rx,
|
|
||||||
released,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_faults(&self) {
|
pub fn clear_faults(&self) {
|
||||||
let mut state = lock(&self.control);
|
let mut state = lock(&self.control);
|
||||||
state.scripts.clear();
|
state.scripts.clear();
|
||||||
@@ -2297,17 +2243,6 @@ impl S3 for FakeBackend {
|
|||||||
let fault = request_fault(&req);
|
let fault = request_fault(&req);
|
||||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||||
let input = req.input;
|
let input = req.input;
|
||||||
let held_get = lock(&self.control)
|
|
||||||
.held_get
|
|
||||||
.as_ref()
|
|
||||||
.filter(|held| held.bucket == input.bucket && held.key == input.key)
|
|
||||||
.cloned();
|
|
||||||
if let Some(mut held) = held_get {
|
|
||||||
held.entered.send_modify(|count| *count += 1);
|
|
||||||
// Keep the gate installed when a request is cancelled or times out:
|
|
||||||
// a retry must cross the same boundary before returning any bytes.
|
|
||||||
let _ = held.released.wait_for(|released| *released).await;
|
|
||||||
}
|
|
||||||
let (version, versioned) = {
|
let (version, versioned) = {
|
||||||
let state = lock(&self.store);
|
let state = lock(&self.store);
|
||||||
(
|
(
|
||||||
@@ -2959,81 +2894,6 @@ mod tests {
|
|||||||
aws_sdk_s3::primitives::DateTime::from_secs(4_102_444_800)
|
aws_sdk_s3::primitives::DateTime::from_secs(4_102_444_800)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_object_gate_holds_retries_and_releases_on_drop() -> Result<(), BoxError> {
|
|
||||||
let target = FakeS3Target::start().await?;
|
|
||||||
let bucket = "gated-target";
|
|
||||||
target.create_bucket(bucket);
|
|
||||||
for key in ["held", "unrelated"] {
|
|
||||||
target.put_seed_object(bucket, key, Bytes::from_static(b"payload"), &SeedMetadata::default());
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let gate = target.hold_get_object(bucket, "held");
|
|
||||||
let request = || S3Request {
|
|
||||||
input: GetObjectInput {
|
|
||||||
bucket: bucket.to_string(),
|
|
||||||
key: "held".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
method: Method::GET,
|
|
||||||
uri: Uri::from_static("/gated-target/held"),
|
|
||||||
headers: HeaderMap::new(),
|
|
||||||
extensions: http::Extensions::new(),
|
|
||||||
credentials: None,
|
|
||||||
region: None,
|
|
||||||
service: None,
|
|
||||||
trailing_headers: None,
|
|
||||||
};
|
|
||||||
// Without a fault, only the gate can suspend this backend method.
|
|
||||||
let mut first = target.backend.get_object(request());
|
|
||||||
assert!(futures::poll!(first.as_mut()).is_pending(), "the first GET must wait at the gate");
|
|
||||||
drop(first);
|
|
||||||
let mut retry = target.backend.get_object(request());
|
|
||||||
assert!(futures::poll!(retry.as_mut()).is_pending(), "a cancelled GET must not consume the gate");
|
|
||||||
drop(gate);
|
|
||||||
let std::task::Poll::Ready(response) = futures::poll!(retry.as_mut()) else {
|
|
||||||
panic!("dropping the gate must release the waiting GET");
|
|
||||||
};
|
|
||||||
let mut body = response?.output.body.expect("released GET body");
|
|
||||||
assert_eq!(body.next().await.transpose()?, Some(Bytes::from_static(b"payload")));
|
|
||||||
assert!(body.next().await.is_none(), "released GET body must be complete");
|
|
||||||
}
|
|
||||||
let client = client(&target);
|
|
||||||
let mut gate = target.hold_get_object(bucket, "held");
|
|
||||||
let mut requests = tokio::task::JoinSet::new();
|
|
||||||
let first = client.clone();
|
|
||||||
requests.spawn(async move { get_bytes(&first, bucket, "held", None).await });
|
|
||||||
timeout(Duration::from_secs(2), gate.wait_until_entered()).await??;
|
|
||||||
requests.abort_all();
|
|
||||||
assert!(
|
|
||||||
requests
|
|
||||||
.join_next()
|
|
||||||
.await
|
|
||||||
.expect("first GET task")
|
|
||||||
.expect_err("cancel the first GET attempt")
|
|
||||||
.is_cancelled()
|
|
||||||
);
|
|
||||||
|
|
||||||
let retry = client.clone();
|
|
||||||
requests.spawn(async move { get_bytes(&retry, bucket, "held", None).await });
|
|
||||||
timeout(Duration::from_secs(2), gate.entered.wait_for(|count| *count == 2)).await??;
|
|
||||||
assert_eq!(
|
|
||||||
timeout(Duration::from_secs(2), get_bytes(&client, bucket, "unrelated", None)).await??,
|
|
||||||
Bytes::from_static(b"payload")
|
|
||||||
);
|
|
||||||
assert!(requests.try_join_next().is_none(), "the retry must remain behind the gate");
|
|
||||||
drop(gate);
|
|
||||||
assert_eq!(
|
|
||||||
timeout(Duration::from_secs(2), requests.join_next())
|
|
||||||
.await?
|
|
||||||
.expect("retried GET task")??,
|
|
||||||
Bytes::from_static(b"payload")
|
|
||||||
);
|
|
||||||
assert_eq!(get_bytes(&client, bucket, "held", None).await?, Bytes::from_static(b"payload"));
|
|
||||||
assert_eq!(target.count_requests(Operation::GetObject, "held"), 3);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
|
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
|||||||
@@ -16,18 +16,15 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
|
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, 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;
|
||||||
@@ -37,76 +34,6 @@ 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,
|
||||||
@@ -268,9 +195,8 @@ 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<Vec<Vec<String>>, Box<dyn Error + Send + Sync>> {
|
) -> Result<(), 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;
|
||||||
@@ -317,10 +243,8 @@ 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(node_listings)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn heal_task_status_diagnostic(body: &str) -> String {
|
fn heal_task_status_diagnostic(body: &str) -> String {
|
||||||
@@ -884,13 +808,6 @@ 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"),
|
||||||
@@ -938,7 +855,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_with_binary(&server_binary).await?;
|
cluster.start().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";
|
||||||
@@ -1079,7 +996,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cluster.start_node_from_binary(1, &server_binary).await?;
|
cluster.start_node(1).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);
|
||||||
@@ -1357,7 +1274,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cluster.start_node_from_binary(interruption_node, &server_binary).await?;
|
cluster.start_node(interruption_node).await?;
|
||||||
if interruption_node == 0 {
|
if interruption_node == 0 {
|
||||||
let target = cluster.nodes[1]
|
let target = cluster.nodes[1]
|
||||||
.process
|
.process
|
||||||
@@ -1456,7 +1373,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()));
|
||||||
let node_listings = assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
|
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 {
|
||||||
@@ -1464,31 +1381,11 @@ 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 {
|
||||||
@@ -1535,31 +1432,6 @@ 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,11 +378,6 @@ 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,10 +20,9 @@
|
|||||||
//! 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::{FaultAction, Operation};
|
use crate::fake_s3_target::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>;
|
||||||
@@ -146,38 +145,14 @@ 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);
|
||||||
|
|
||||||
// Bound source connections below the fixture's limit while still
|
let responses: Vec<RawResponse> = futures::future::try_join_all(
|
||||||
// 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));
|
||||||
@@ -193,15 +168,6 @@ 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!(
|
||||||
@@ -209,6 +175,9 @@ 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_eq!(dropped, queue_full, "only overflowed keys remain without a background GET");
|
assert!(
|
||||||
|
dropped > 0,
|
||||||
|
"the overflowed keys are the ones with no backfill GET, but every key got one"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
//! local object and what the source was asked for.
|
//! local object and what the source was asked for.
|
||||||
|
|
||||||
use super::common::{
|
use super::common::{
|
||||||
ALLOW_LOOPBACK_SOURCE_ENV, AdminResponse, BackfillOp, BackfillRequest, BoxError, ODM_MODULE_SWITCH_ENV, ODM_SERVER_ENV,
|
AdminResponse, BoxError, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env,
|
||||||
OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env, start_configured_env_with,
|
start_configured_env_with,
|
||||||
};
|
};
|
||||||
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
|
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
|
||||||
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
|
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
|
||||||
@@ -32,7 +32,7 @@ use aws_sdk_s3::error::ProvideErrorMetadata;
|
|||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
|
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
|
||||||
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault,
|
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault,
|
||||||
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Tag, Tagging, VersioningConfiguration,
|
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, VersioningConfiguration,
|
||||||
};
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use local_ip_address::local_ip;
|
use local_ip_address::local_ip;
|
||||||
@@ -733,242 +733,6 @@ async fn test_odm_disable_keeps_pulled_objects_and_stops_source_traffic() -> Tes
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The process switch preserves configured buckets and unfinished jobs while
|
|
||||||
/// restoring local-only S3 behavior, including after an ordinary metadata write.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_odm_global_disable_preserves_data_config_and_backfill_across_restarts() -> TestResult {
|
|
||||||
let bucket = "odm-global-disable";
|
|
||||||
let mut env = start_configured_env(bucket, SOURCE_BUCKET, |spec| spec.policy.list_through = true).await?;
|
|
||||||
let pulled_key = "migrated/pulled.bin";
|
|
||||||
let remote_key = "remote/untouched.bin";
|
|
||||||
let pending_key = "backfill/pending.bin";
|
|
||||||
let local_key = "local/kept.bin";
|
|
||||||
let source_body = Bytes::from_static(b"source payload");
|
|
||||||
let local_body = Bytes::from_static(b"client payload");
|
|
||||||
env.seed_source(
|
|
||||||
SOURCE_BUCKET,
|
|
||||||
&[
|
|
||||||
SeedObject::new(pulled_key, source_body.clone()),
|
|
||||||
SeedObject::new(remote_key, source_body.clone()),
|
|
||||||
SeedObject::new(pending_key, source_body.clone()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
env.client
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(local_key)
|
|
||||||
.body(local_body.clone().into())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let pulled = env.raw_get(bucket, pulled_key).await?;
|
|
||||||
assert_eq!(pulled.status, 200);
|
|
||||||
assert_eq!(pulled.header(ODM_RESPONSE_HEADER), Some("source"));
|
|
||||||
assert_eq!(pulled.body, source_body);
|
|
||||||
let stored = env.raw_get(bucket, pulled_key).await?;
|
|
||||||
assert_eq!(stored.status, 200);
|
|
||||||
assert_eq!(stored.header(ODM_RESPONSE_HEADER), None, "the inline pull has committed locally");
|
|
||||||
assert_eq!(stored.body, source_body);
|
|
||||||
let config = env.get_config(bucket).await?;
|
|
||||||
assert_eq!(config.status, 200, "{}", config.body);
|
|
||||||
let config = config.json()?;
|
|
||||||
|
|
||||||
// Hold every attempt until the process has exited, so retries cannot commit
|
|
||||||
// the only backfill object before the crash. The start checkpoint exists.
|
|
||||||
let mut pending_get = env.source.hold_get_object(SOURCE_BUCKET, pending_key);
|
|
||||||
let started = env
|
|
||||||
.start_backfill(
|
|
||||||
bucket,
|
|
||||||
BackfillRequest {
|
|
||||||
prefix: Some("backfill/".to_string()),
|
|
||||||
..BackfillRequest::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(started.status, 200, "{}", started.body);
|
|
||||||
let job_id = started.json()?["job"]["job_id"].as_str().ok_or("missing job ID")?.to_string();
|
|
||||||
tokio::time::timeout(Duration::from_secs(10), pending_get.wait_until_entered())
|
|
||||||
.await
|
|
||||||
.expect("backfill never reached the held source GET")?;
|
|
||||||
let process = env.rustfs.process.as_mut().ok_or("missing RustFS process before crash")?;
|
|
||||||
assert!(process.try_wait()?.is_none(), "RustFS exited before the controlled crash");
|
|
||||||
process.kill()?;
|
|
||||||
let stopped = process.wait()?;
|
|
||||||
assert!(!stopped.success(), "the interrupted process must exit after being killed");
|
|
||||||
drop(env.rustfs.process.take());
|
|
||||||
drop(pending_get);
|
|
||||||
env.source.take_requests();
|
|
||||||
env.rustfs
|
|
||||||
.restart_server_preserving_data(vec![], &[(ODM_MODULE_SWITCH_ENV, "false"), (ALLOW_LOOPBACK_SOURCE_ENV, "true")])
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let off_config = env.get_config(bucket).await?;
|
|
||||||
assert_eq!(off_config.status, 200, "{}", off_config.body);
|
|
||||||
assert_eq!(off_config.json()?, config, "the saved configuration and timestamp survive disabling");
|
|
||||||
let status = env.status_json(bucket).await?;
|
|
||||||
assert_eq!(status["configured"], true, "{status}");
|
|
||||||
assert_eq!(status["enabled"], true, "the bucket remains configured as enabled: {status}");
|
|
||||||
assert_eq!(status["module_enabled"], false, "{status}");
|
|
||||||
assert_eq!(status["counters"], Value::Null, "no bucket runtime is installed: {status}");
|
|
||||||
let checkpoint = env.backfill_job(bucket).await?.ok_or("disabled module lost the checkpoint")?;
|
|
||||||
assert_eq!(checkpoint["job_id"], job_id);
|
|
||||||
assert_eq!(checkpoint["state"], "running", "the interrupted job is retained: {checkpoint}");
|
|
||||||
|
|
||||||
for (key, body) in [(local_key, &local_body), (pulled_key, &source_body)] {
|
|
||||||
let get = env.raw_get(bucket, key).await?;
|
|
||||||
assert_eq!(get.status, 200);
|
|
||||||
assert_eq!(&get.body, body);
|
|
||||||
assert_eq!(get.header(ODM_RESPONSE_HEADER), None);
|
|
||||||
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
|
|
||||||
assert_eq!(head.content_length(), Some(i64::try_from(body.len())?));
|
|
||||||
}
|
|
||||||
for key in [remote_key, pending_key] {
|
|
||||||
let get = env.raw_get(bucket, key).await?;
|
|
||||||
assert_eq!(get.status, 404, "disabled source GET {key}: {}", String::from_utf8_lossy(&get.body));
|
|
||||||
let head = env.client.head_object().bucket(bucket).key(key).send().await;
|
|
||||||
let err = head.expect_err("a source-only object must remain absent locally");
|
|
||||||
assert_eq!(err.raw_response().map(|response| response.status().as_u16()), Some(404));
|
|
||||||
}
|
|
||||||
|
|
||||||
let replacement = Bytes::from_static(b"written while the module is off");
|
|
||||||
for key in [local_key, "local/deleted.bin"] {
|
|
||||||
env.client
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(key)
|
|
||||||
.body(replacement.clone().into())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
env.client
|
|
||||||
.delete_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key("local/deleted.bin")
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(env.raw_get(bucket, "local/deleted.bin").await?.status, 404);
|
|
||||||
assert_eq!(env.raw_get(bucket, local_key).await?.body, replacement);
|
|
||||||
|
|
||||||
// Both wire protocols must finish their local pages even though the saved
|
|
||||||
// configuration still requests list-through.
|
|
||||||
for use_v2 in [false, true] {
|
|
||||||
let mut cursor = None;
|
|
||||||
let mut listed = Vec::new();
|
|
||||||
for page_number in 0..2 {
|
|
||||||
let (keys, truncated, next) = if use_v2 {
|
|
||||||
let page = env
|
|
||||||
.client
|
|
||||||
.list_objects_v2()
|
|
||||||
.bucket(bucket)
|
|
||||||
.max_keys(1)
|
|
||||||
.set_continuation_token(cursor)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
(
|
|
||||||
page.contents()
|
|
||||||
.iter()
|
|
||||||
.map(|object| object.key().expect("listed key").to_string())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
page.is_truncated(),
|
|
||||||
page.next_continuation_token().map(str::to_string),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let page = env
|
|
||||||
.client
|
|
||||||
.list_objects()
|
|
||||||
.bucket(bucket)
|
|
||||||
.max_keys(1)
|
|
||||||
.set_marker(cursor)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
// V1 may omit NextMarker without a delimiter; clients then
|
|
||||||
// continue from the last returned key.
|
|
||||||
let next = page.next_marker().or_else(|| {
|
|
||||||
if page.is_truncated() == Some(true) {
|
|
||||||
page.contents().last().and_then(|object| object.key())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
(
|
|
||||||
page.contents()
|
|
||||||
.iter()
|
|
||||||
.map(|object| object.key().expect("listed key").to_string())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
page.is_truncated(),
|
|
||||||
next.map(str::to_string),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
assert_eq!(keys.len(), 1, "one local key per page, V2={use_v2}");
|
|
||||||
assert_eq!(truncated, Some(page_number == 0), "local pagination must terminate, V2={use_v2}");
|
|
||||||
if page_number == 0 {
|
|
||||||
assert!(next.as_ref().is_some_and(|value| !value.is_empty()), "missing local cursor, V2={use_v2}");
|
|
||||||
}
|
|
||||||
cursor = next;
|
|
||||||
listed.extend(keys);
|
|
||||||
}
|
|
||||||
assert_eq!(listed, [local_key, pulled_key], "source-only keys must stay absent, V2={use_v2}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let spec = env.fake_source_spec(SOURCE_BUCKET);
|
|
||||||
for response in [
|
|
||||||
env.configure_source(bucket, &spec).await?,
|
|
||||||
env.validate_source(bucket, &spec).await?,
|
|
||||||
env.backfill(bucket, BackfillOp::Start(BackfillRequest::default())).await?,
|
|
||||||
] {
|
|
||||||
assert_eq!(response.status, 400, "{}", response.body);
|
|
||||||
assert!(response.body.contains("OnDemandMigrationDisabled"), "{}", response.body);
|
|
||||||
}
|
|
||||||
let tagging = Tagging::builder()
|
|
||||||
.tag_set(Tag::builder().key("module").value("disabled").build()?)
|
|
||||||
.build()?;
|
|
||||||
env.client
|
|
||||||
.put_bucket_tagging()
|
|
||||||
.bucket(bucket)
|
|
||||||
.tagging(tagging.clone())
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
assert_eq!(env.get_config(bucket).await?.json()?, config, "an unrelated metadata write preserves ODM");
|
|
||||||
assert_eq!(
|
|
||||||
env.backfill_job(bucket).await?,
|
|
||||||
Some(checkpoint),
|
|
||||||
"no recovery or checkpoint update while disabled"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
env.source.requests().is_empty(),
|
|
||||||
"disabled startup and all requests must leave the source untouched"
|
|
||||||
);
|
|
||||||
|
|
||||||
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
|
||||||
env.wait_until_source_consulted(bucket).await?;
|
|
||||||
assert_eq!(
|
|
||||||
env.get_config(bucket).await?.json()?,
|
|
||||||
config,
|
|
||||||
"reenabling uses the persisted configuration"
|
|
||||||
);
|
|
||||||
let tags = env.client.get_bucket_tagging().bucket(bucket).send().await?;
|
|
||||||
assert_eq!(tags.tag_set(), tagging.tag_set(), "the ordinary metadata write also persists");
|
|
||||||
let resumed = env.raw_get(bucket, remote_key).await?;
|
|
||||||
assert_eq!(resumed.status, 200);
|
|
||||||
assert_eq!(resumed.header(ODM_RESPONSE_HEADER), Some("source"));
|
|
||||||
assert_eq!(resumed.body, source_body, "stored credentials still authenticate without reconfiguration");
|
|
||||||
let completed = env
|
|
||||||
.wait_for_backfill(bucket, SETTLE, |job| job["state"] == "completed")
|
|
||||||
.await?;
|
|
||||||
assert_eq!(completed["job_id"], job_id, "the interrupted job resumes without a new start");
|
|
||||||
assert_eq!(completed["failed"], 0, "{completed}");
|
|
||||||
for (key, body) in [
|
|
||||||
(local_key, &replacement),
|
|
||||||
(pulled_key, &source_body),
|
|
||||||
(pending_key, &source_body),
|
|
||||||
] {
|
|
||||||
let get = env.raw_get(bucket, key).await?;
|
|
||||||
assert_eq!(get.status, 200);
|
|
||||||
assert_eq!(&get.body, body);
|
|
||||||
assert_eq!(get.header(ODM_RESPONSE_HEADER), None, "{key} remains stored locally");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Case 19: the admin surface an operator sees — the configuration read back
|
/// Case 19: the admin surface an operator sees — the configuration read back
|
||||||
/// without its secret, and a status document whose counters match the source
|
/// without its secret, and a status document whose counters match the source
|
||||||
/// journal exactly.
|
/// journal exactly.
|
||||||
|
|||||||
@@ -265,13 +265,16 @@ 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\":3").as_bytes());
|
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":2").as_bytes());
|
||||||
assert_ne!(tampered, token, "the test must change the token version");
|
let rejected = env
|
||||||
let query = serde_urlencoded::to_string([("continuation-token", tampered.as_str())])?;
|
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}"))
|
||||||
let rejected = env.raw_list_objects_v2(bucket, &query).await?;
|
.await?;
|
||||||
let error_body = String::from_utf8_lossy(&rejected.body);
|
assert_eq!(
|
||||||
assert_eq!(rejected.status, 400, "a bumped token version is a client error: {}", error_body);
|
rejected.status,
|
||||||
assert!(error_body.contains("<Code>InvalidArgument</Code>"), "{error_body}");
|
400,
|
||||||
|
"a bumped token version is a client error: {}",
|
||||||
|
String::from_utf8_lossy(&rejected.body)
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,15 +17,13 @@ Use the canonical CI-equivalent protocol command in the parent
|
|||||||
For targeted debugging of the core suite only:
|
For targeted debugging of the core suite only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RUSTFS_BUILD_FEATURES=ftps,webdav,sftp cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
python3 scripts/e2e_binary.py build --features ftps,webdav,sftp
|
||||||
|
python3 scripts/e2e_binary.py run --features ftps,webdav,sftp -- cargo test --package e2e_test test_protocol_core_suite -- --test-threads=1 --nocapture
|
||||||
```
|
```
|
||||||
|
|
||||||
This targeted command does not cover the full `e2e-protocols` profile.
|
This targeted command does not cover the full `e2e-protocols` profile.
|
||||||
|
|
||||||
`RUSTFS_BUILD_FEATURES` controls which features the test rustfs binary is
|
`e2e_binary.py` supplies `RUSTFS_BUILD_FEATURES` from the verified server's resolved Cargo features. The protocol runner schedules only entries present in that feature list; helpers check that their required features are available without rebuilding the server.
|
||||||
built with. When this variable is set, the protocol test runner schedules
|
|
||||||
only entries whose protocol is present in the requested feature list. Leave
|
|
||||||
it unset to run every protocol entry.
|
|
||||||
`--test-threads=1` is required because every entry spawns a rustfs server
|
`--test-threads=1` is required because every entry spawns a rustfs server
|
||||||
on fixed bind ports.
|
on fixed bind ports.
|
||||||
|
|
||||||
|
|||||||
@@ -12,34 +12,21 @@
|
|||||||
// 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::{
|
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||||
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::{
|
||||||
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, DefaultRetention,
|
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ServerSideEncryption, VersioningConfiguration,
|
||||||
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";
|
||||||
@@ -53,32 +40,6 @@ 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)
|
||||||
@@ -279,93 +240,6 @@ 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 {
|
||||||
@@ -555,653 +429,3 @@ 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,7 +31,6 @@ 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
|
||||||
@@ -213,8 +212,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, optional = true }
|
google-cloud-storage = { workspace = true }
|
||||||
google-cloud-auth = { workspace = true, optional = true }
|
google-cloud-auth = { workspace = 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"] }
|
||||||
|
|||||||
@@ -69,13 +69,6 @@ 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,
|
||||||
@@ -96,9 +89,8 @@ 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_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate,
|
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate,
|
||||||
ObjectOpts, RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time,
|
TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time, object_opts_from_object_info,
|
||||||
object_opts_from_object_info,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,25 +146,67 @@ pub mod bucket {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod metadata_sys {
|
pub mod on_demand_migration {
|
||||||
pub use crate::bucket::metadata_sys::{
|
pub use crate::bucket::on_demand_migration::{
|
||||||
BUCKET_CONFIG_PUBLISH_HOOK, BucketConfigPublishHook, BucketMetadataMutationGuard, BucketMetadataSys,
|
ApplyOutcome, BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION,
|
||||||
ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
Breaker, BreakerState, BreakerTransition, BreakerVerdict, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, GaugeGuard,
|
||||||
acquire_bucket_metadata_transaction_lock_for_incarnation, acquire_scanner_bucket_incarnation_fence,
|
LastSourceError, LatencyBucketSnapshot, NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache, OdmBucketSnapshot, OdmLookup,
|
||||||
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, delete_if_incarnation_at,
|
OdmOp, OdmOutcome, OdmStateError, OdmStats, OdmStatsSnapshot, OnDemandMigrationSys, PullError, PullFailureReason,
|
||||||
delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy, get_bucket_policy_raw,
|
PullFollower, PullLeader, PullOutcome, PullPath, PullResult, PullSlot, SOURCE_LATENCY_BUCKET_BOUNDS_MS,
|
||||||
get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
SourceLatencySnapshot, source_client_spec,
|
||||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
|
||||||
get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
|
|
||||||
get_on_demand_migration_config_in, get_public_access_block_config, get_quota_config, get_replication_config,
|
|
||||||
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
|
||||||
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
|
||||||
update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
|
||||||
update_if_incarnation_at, update_quota_if_incarnation, update_quota_if_incarnation_at, update_under_transaction_lock,
|
|
||||||
update_under_transaction_lock_at,
|
|
||||||
};
|
};
|
||||||
|
pub use crate::bucket::on_demand_migration::{
|
||||||
|
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
|
||||||
|
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy,
|
||||||
|
SourceConfig, SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
|
||||||
|
};
|
||||||
|
pub use crate::bucket::on_demand_migration::{
|
||||||
|
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
|
||||||
|
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody,
|
||||||
|
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
|
||||||
|
idle_guarded_body,
|
||||||
|
};
|
||||||
|
pub use crate::bucket::on_demand_migration::{
|
||||||
|
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
|
||||||
|
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
|
||||||
|
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
|
||||||
|
};
|
||||||
|
pub mod backfill {
|
||||||
|
pub use crate::bucket::on_demand_migration::backfill::{
|
||||||
|
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
|
||||||
|
BACKFILL_LEASE_LOCK_PREFIX, BACKFILL_LIST_PAGE_SIZE, BACKFILL_RECOVERY_INTERVAL, BACKFILL_SAVE_EVERY_KEYS,
|
||||||
|
BACKFILL_SAVE_INTERVAL, BackfillCheckpoint, BackfillContext, BackfillContextFactory, BackfillError,
|
||||||
|
BackfillLastError, BackfillOwner, BackfillRecoveryStats, BackfillRequest, BackfillRunner, BackfillState,
|
||||||
|
BucketBackfillContext, LocalBackfillObject, PriorityPullPermits, PullPermit, PullPriority, SkipExisting,
|
||||||
|
StoredCheckpoint, SysBackfillContexts, global_backfill_runner, install_global_backfill_runner, key_hash,
|
||||||
|
read_checkpoint, run_backfill_recovery_loop, spawn_backfill_recovery_loop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pub mod source_client {
|
||||||
|
pub use crate::bucket::on_demand_migration::source_client::{
|
||||||
|
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
|
||||||
|
SourceProbe, SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
|
||||||
|
resolve_path_style,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod metadata_sys {
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
pub use crate::bucket::metadata_sys::{ConfigWriteLockProbe, test_support};
|
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
|
||||||
|
pub use crate::bucket::metadata_sys::{
|
||||||
|
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
|
||||||
|
acquire_bucket_metadata_transaction_lock_for_incarnation, acquire_scanner_bucket_incarnation_fence,
|
||||||
|
capture_bucket_metadata_incarnation, delete, delete_if_incarnation, delete_under_transaction_lock, get,
|
||||||
|
get_accelerate_config, get_bucket_policy, get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk,
|
||||||
|
get_cors_config, get_durability_config, get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config,
|
||||||
|
get_notification_config, get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
|
||||||
|
get_public_access_block_config, get_quota_config, get_replication_config, get_request_payment_config, get_sse_config,
|
||||||
|
get_tagging_config, get_versioning_config, get_website_config, init_bucket_metadata_sys, list_bucket_targets,
|
||||||
|
reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata, update,
|
||||||
|
update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation, update_quota_if_incarnation,
|
||||||
|
update_under_transaction_lock,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod migration {
|
pub mod migration {
|
||||||
@@ -215,7 +249,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,
|
||||||
build_remote_s3_config, validate_remote_endpoint, validate_target_ca_pem,
|
validate_remote_endpoint,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,9 +495,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, WriteCompletion, get_object_body_cache_plaintext_len,
|
ScannerPublicationCommitState, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
|
||||||
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
|
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
|
||||||
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
unregister_object_mutation_hook,
|
||||||
};
|
};
|
||||||
pub use crate::store::{
|
pub use crate::store::{
|
||||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||||
@@ -527,12 +561,6 @@ 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -794,24 +794,17 @@ impl BucketTargetSys {
|
|||||||
) -> Result<BucketTargets, BucketTargetError> {
|
) -> Result<BucketTargets, BucketTargetError> {
|
||||||
self.validate_target(bucket, target).await?;
|
self.validate_target(bucket, target).await?;
|
||||||
|
|
||||||
let mut bucket_targets = self.targets_base_for_write(bucket).await?;
|
let mut bucket_targets = match self.list_bucket_targets(bucket).await {
|
||||||
|
Ok(targets) => targets,
|
||||||
|
Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => BucketTargets::default(),
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
Self::upsert_target_entry(&mut bucket_targets.targets, target, update)?;
|
Self::upsert_target_entry(&mut bucket_targets.targets, target, update)?;
|
||||||
|
|
||||||
Ok(bucket_targets)
|
Ok(bucket_targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ordinary writes must not turn an unreadable cached snapshot into an
|
|
||||||
/// empty configuration. Explicit repair belongs to the metadata transaction
|
|
||||||
/// that can inspect the current persisted state.
|
|
||||||
async fn targets_base_for_write(&self, bucket: &str) -> Result<BucketTargets, BucketTargetError> {
|
|
||||||
match self.list_bucket_targets(bucket).await {
|
|
||||||
Ok(targets) => Ok(targets),
|
|
||||||
Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => Ok(BucketTargets::default()),
|
|
||||||
Err(err) => Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn validate_target(&self, bucket: &str, target: &BucketTarget) -> Result<(), BucketTargetError> {
|
pub async fn validate_target(&self, bucket: &str, target: &BucketTarget) -> Result<(), BucketTargetError> {
|
||||||
if !target.target_type.is_valid() {
|
if !target.target_type.is_valid() {
|
||||||
return Err(BucketTargetError::BucketRemoteArnTypeInvalid {
|
return Err(BucketTargetError::BucketRemoteArnTypeInvalid {
|
||||||
@@ -870,9 +863,7 @@ impl BucketTargetSys {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge a validated target into a caller-owned snapshot. The caller must
|
fn upsert_target_entry(
|
||||||
/// protect that snapshot through persistence.
|
|
||||||
pub fn upsert_target_entry(
|
|
||||||
bucket_targets: &mut Vec<BucketTarget>,
|
bucket_targets: &mut Vec<BucketTarget>,
|
||||||
target: &BucketTarget,
|
target: &BucketTarget,
|
||||||
update: bool,
|
update: bool,
|
||||||
@@ -1236,13 +1227,9 @@ impl BucketTargetSys {
|
|||||||
return (String::new(), false);
|
return (String::new(), false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
{
|
||||||
let targets_map = self.targets_map.read().await;
|
let targets_map = self.targets_map.read().await;
|
||||||
let targets = targets_map.get(bucket).map(Vec::as_slice).unwrap_or_default();
|
if let Some(targets) = targets_map.get(bucket) {
|
||||||
Self::remote_arn_for_targets(targets, target, depl_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve create idempotency against the snapshot the caller will persist.
|
|
||||||
pub fn remote_arn_for_targets(targets: &[BucketTarget], target: &BucketTarget, depl_id: &str) -> (String, bool) {
|
|
||||||
for tgt in targets {
|
for tgt in targets {
|
||||||
if tgt.target_type == target.target_type
|
if tgt.target_type == target.target_type
|
||||||
&& tgt.target_bucket == target.target_bucket
|
&& tgt.target_bucket == target.target_bucket
|
||||||
@@ -1259,6 +1246,8 @@ impl BucketTargetSys {
|
|||||||
return (tgt.arn.clone(), true);
|
return (tgt.arn.clone(), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !target.target_type.is_valid() {
|
if !target.target_type.is_valid() {
|
||||||
return (String::new(), false);
|
return (String::new(), false);
|
||||||
@@ -4324,41 +4313,4 @@ mod tests {
|
|||||||
let window = LastMinuteLatency::new();
|
let window = LastMinuteLatency::new();
|
||||||
assert_eq!(window.get_total().avg, Duration::from_secs(0));
|
assert_eq!(window.get_total().avg, Duration::from_secs(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn repair_target(bucket: &str, id: &str) -> BucketTarget {
|
|
||||||
BucketTarget {
|
|
||||||
source_bucket: bucket.to_string(),
|
|
||||||
endpoint: "remote.example.com".to_string(),
|
|
||||||
target_bucket: "remote".to_string(),
|
|
||||||
arn: format!("arn:rustfs:replication:us-east-1:{bucket}:{id}"),
|
|
||||||
target_type: BucketTargetType::ReplicationService,
|
|
||||||
region: "us-east-1".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn an_unreadable_target_set_refuses_cached_writes() {
|
|
||||||
let sys = BucketTargetSys::default();
|
|
||||||
let bucket = "targets-repair-opt-in";
|
|
||||||
sys.mark_targets_unreadable(bucket).await;
|
|
||||||
assert!(matches!(
|
|
||||||
sys.targets_base_for_write(bucket).await,
|
|
||||||
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_readable_target_set_remains_the_write_base() {
|
|
||||||
let sys = BucketTargetSys::default();
|
|
||||||
let bucket = "targets-repair-readable";
|
|
||||||
let existing = repair_target(bucket, "keep");
|
|
||||||
sys.targets_map
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(bucket.to_string(), vec![existing.clone()]);
|
|
||||||
let base = sys.targets_base_for_write(bucket).await.expect("read targets");
|
|
||||||
assert_eq!(base.targets.len(), 1);
|
|
||||||
assert_eq!(base.targets[0].arn, existing.arn);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,18 +150,6 @@ static XXHASH_SEED: u64 = 0;
|
|||||||
static TIER_FREE_VERSION_RECOVERY_STARTED: OnceLock<()> = OnceLock::new();
|
static TIER_FREE_VERSION_RECOVERY_STARTED: OnceLock<()> = OnceLock::new();
|
||||||
static MANUAL_TRANSITION_JOB_RECOVERY_STARTED: OnceLock<()> = OnceLock::new();
|
static MANUAL_TRANSITION_JOB_RECOVERY_STARTED: OnceLock<()> = OnceLock::new();
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Default)]
|
|
||||||
struct FreeVersionPostRemoteDeleteTestBarrier {
|
|
||||||
arrived: Notify,
|
|
||||||
release: Notify,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
tokio::task_local! {
|
|
||||||
static FREE_VERSION_POST_REMOTE_DELETE_TEST_BARRIER: Arc<FreeVersionPostRemoteDeleteTestBarrier>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const AMZ_OBJECT_TAGGING: &str = "X-Amz-Tagging";
|
pub const AMZ_OBJECT_TAGGING: &str = "X-Amz-Tagging";
|
||||||
#[allow(
|
#[allow(
|
||||||
dead_code,
|
dead_code,
|
||||||
@@ -922,11 +910,6 @@ async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel:
|
|||||||
})??;
|
})??;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
|
||||||
if let Ok(barrier) = FREE_VERSION_POST_REMOTE_DELETE_TEST_BARRIER.try_with(Arc::clone) {
|
|
||||||
barrier.arrived.notify_one();
|
|
||||||
barrier.release.notified().await;
|
|
||||||
}
|
|
||||||
if !free_version_cleanup_fences_current(&topology_generation, &api, &bucket_guard, &object_guards, &lease, cancel, deadline) {
|
if !free_version_cleanup_fences_current(&topology_generation, &api, &bucket_guard, &object_guards, &lease, cancel, deadline) {
|
||||||
// Remote DELETE is idempotent, but a changed fence makes the local
|
// Remote DELETE is idempotent, but a changed fence makes the local
|
||||||
// outcome ambiguous. Keep every marker for a fully fenced retry.
|
// outcome ambiguous. Keep every marker for a fully fenced retry.
|
||||||
@@ -5848,7 +5831,7 @@ mod tests {
|
|||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
use crate::services::tier::test_util::register_mock_tier;
|
use crate::services::tier::test_util::register_mock_tier;
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
use crate::services::tier::tier::{TIER_DRIVER_TEST_FACTORY, TierConfigMgr, TierDriverTestFactory};
|
use crate::services::tier::tier::TierConfigMgr;
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
use crate::services::tier::warm_backend::{TransitionCandidateProbe, WarmBackend as _};
|
use crate::services::tier::warm_backend::{TransitionCandidateProbe, WarmBackend as _};
|
||||||
use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause};
|
use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause};
|
||||||
@@ -7847,119 +7830,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn tier_remove_waits_for_inflight_free_version_local_commit() {
|
|
||||||
let (disk_paths, ecstore) = setup_test_env().await;
|
|
||||||
let bucket = format!("tier-remove-free-version-{}", Uuid::new_v4());
|
|
||||||
let object = "free-version";
|
|
||||||
create_test_bucket(&ecstore, &bucket).await;
|
|
||||||
let (backend, identity_hex) = register_recovery_mock_tier(&ecstore).await;
|
|
||||||
let tier_manager = ecstore.tier_config_mgr();
|
|
||||||
{
|
|
||||||
let manager = tier_manager.read().await;
|
|
||||||
manager
|
|
||||||
.save_tiering_config(Arc::clone(&ecstore))
|
|
||||||
.await
|
|
||||||
.expect("mock tier configuration should persist before removal");
|
|
||||||
}
|
|
||||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, Some(identity_hex)).await;
|
|
||||||
let page = list_tier_free_versions(Arc::clone(&ecstore), 1, None, None, CancellationToken::new())
|
|
||||||
.await
|
|
||||||
.expect("seeded free version should be listed");
|
|
||||||
let oi = page
|
|
||||||
.items
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.expect("seeded free version should be recoverable");
|
|
||||||
|
|
||||||
backend
|
|
||||||
.set_put_remote_version(Some(oi.transitioned_object.version_id.clone()))
|
|
||||||
.await;
|
|
||||||
let seed_lease = TierConfigMgr::acquire_operation_lease(&tier_manager, "WARM")
|
|
||||||
.await
|
|
||||||
.expect("mock tier lease should be available");
|
|
||||||
seed_lease
|
|
||||||
.put(&oi.transitioned_object.name, ReaderImpl::Body(Bytes::from_static(b"body")), 4)
|
|
||||||
.await
|
|
||||||
.expect("remote free-version tuple should be seeded");
|
|
||||||
drop(seed_lease);
|
|
||||||
|
|
||||||
let barrier = Arc::new(super::FreeVersionPostRemoteDeleteTestBarrier::default());
|
|
||||||
let cleanup_barrier = Arc::clone(&barrier);
|
|
||||||
let cleanup_store = Arc::clone(&ecstore);
|
|
||||||
let cleanup_oi = oi.clone();
|
|
||||||
let cleanup = tokio::spawn(async move {
|
|
||||||
super::FREE_VERSION_POST_REMOTE_DELETE_TEST_BARRIER
|
|
||||||
.scope(cleanup_barrier, async move {
|
|
||||||
super::cleanup_free_version_exact(cleanup_store, &cleanup_oi, &CancellationToken::new()).await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
tokio::time::timeout(StdDuration::from_secs(30), barrier.arrived.notified())
|
|
||||||
.await
|
|
||||||
.expect("free-version cleanup should pause after the remote delete");
|
|
||||||
assert!(!backend.contains(&oi.transitioned_object.name).await);
|
|
||||||
|
|
||||||
let remove_manager = Arc::clone(&tier_manager);
|
|
||||||
let remove_store = Arc::clone(&ecstore);
|
|
||||||
let remove_backend = backend.clone();
|
|
||||||
let remove_driver_factory: TierDriverTestFactory = Arc::new(move |_| Ok(Box::new(remove_backend.clone())));
|
|
||||||
let mut remove = tokio::spawn(async move {
|
|
||||||
TIER_DRIVER_TEST_FACTORY
|
|
||||||
.scope(
|
|
||||||
remove_driver_factory,
|
|
||||||
TierConfigMgr::remove_and_save(&remove_manager, remove_store, "WARM", true),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
let prepared = tokio::time::timeout(StdDuration::from_secs(30), async {
|
|
||||||
loop {
|
|
||||||
match TierConfigMgr::acquire_operation_lease(&tier_manager, "WARM").await {
|
|
||||||
Ok(lease) => drop(lease),
|
|
||||||
Err(err) if TierConfigMgr::operation_lease_blocked_by_mutation(&err) => break,
|
|
||||||
Err(err) => panic!("tier remove should only block new operations while cleanup is paused: {err}"),
|
|
||||||
}
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
tokio::select! {
|
|
||||||
prepared = prepared => {
|
|
||||||
prepared.expect("tier remove should install its prepared admission fence");
|
|
||||||
}
|
|
||||||
result = &mut remove => {
|
|
||||||
panic!("tier remove finished before installing its prepared admission fence: {result:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(!remove.is_finished(), "tier remove must wait for the leased local cleanup commit");
|
|
||||||
|
|
||||||
barrier.release.notify_one();
|
|
||||||
tokio::time::timeout(StdDuration::from_secs(30), cleanup)
|
|
||||||
.await
|
|
||||||
.expect("free-version cleanup should finish after release")
|
|
||||||
.expect("free-version cleanup task should join")
|
|
||||||
.expect("free-version cleanup should keep its generation current");
|
|
||||||
tokio::time::timeout(StdDuration::from_secs(30), remove)
|
|
||||||
.await
|
|
||||||
.expect("tier remove should finish after local cleanup")
|
|
||||||
.expect("tier remove task should join")
|
|
||||||
.expect("tier remove should pass its fresh authoritative proof");
|
|
||||||
|
|
||||||
assert!(!tier_manager.read().await.is_tier_valid("WARM"));
|
|
||||||
for disk_path in &disk_paths {
|
|
||||||
assert!(
|
|
||||||
!fs::try_exists(disk_path.join(&bucket).join(object))
|
|
||||||
.await
|
|
||||||
.expect("post-removal free-version path check should succeed")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ecstore
|
|
||||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("empty free-version test bucket should be removed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
|
|||||||
@@ -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_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate, ObjectOpts,
|
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate, TRANSITION_COMPLETE,
|
||||||
RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due,
|
TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due, expected_expiry_time,
|
||||||
expected_expiry_time, expiration_action_has_valid_target,
|
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,7 +22,7 @@ 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, recovery_control, tier_delete_journal, transition_transaction,
|
manual_transition_job, tier_delete_journal, transition_transaction,
|
||||||
};
|
};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::services::tier::tier_probe_intent;
|
use crate::services::tier::tier_probe_intent;
|
||||||
@@ -41,7 +41,6 @@ pub(crate) enum DurableIlmRecordKind {
|
|||||||
ManualTransitionScope,
|
ManualTransitionScope,
|
||||||
ManualTransitionTask,
|
ManualTransitionTask,
|
||||||
ManualTransitionWorkerResult,
|
ManualTransitionWorkerResult,
|
||||||
RecoveryControl,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -106,14 +105,8 @@ 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; 10] = [
|
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 9] = [
|
||||||
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,
|
||||||
@@ -123,7 +116,6 @@ pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 10] = [
|
|||||||
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)]
|
||||||
@@ -249,18 +241,6 @@ 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 {
|
||||||
@@ -274,8 +254,7 @@ impl DurableIlmRecordCheckpoint {
|
|||||||
| 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 }
|
| Self::ManualTransitionWorkerResult { content_sha256 } => content_sha256,
|
||||||
| Self::RecoveryControl { content_sha256, .. } => content_sha256,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,51 +528,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -619,14 +553,6 @@ impl DurableIlmRecordCheckpoint {
|
|||||||
{
|
{
|
||||||
return false;
|
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;
|
||||||
}
|
}
|
||||||
@@ -726,32 +652,6 @@ impl DurableIlmRecordCheckpoint {
|
|||||||
.is_some_and(|distance| tier_probe_state_reaches(*previous_state, *terminal_state, distance))
|
.is_some_and(|distance| tier_probe_state_reaches(*previous_state, *terminal_state, distance))
|
||||||
&& (!previous_remote_version_known || previous_remote_version == terminal_remote_version)
|
&& (!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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1319,35 +1219,6 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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()))?;
|
||||||
@@ -1541,94 +1412,6 @@ mod tests {
|
|||||||
.checkpoint
|
.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]
|
#[test]
|
||||||
fn tier_probe_intent_checkpoint_tracks_exact_monotonic_generations() {
|
fn tier_probe_intent_checkpoint_tracks_exact_monotonic_generations() {
|
||||||
let initial_intent = tier_probe_intent_fixture();
|
let initial_intent = tier_probe_intent_fixture();
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ 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
@@ -35,10 +35,6 @@ use crate::bucket::lifecycle::config_boundary;
|
|||||||
use crate::bucket::lifecycle::durable_namespace::{
|
use crate::bucket::lifecycle::durable_namespace::{
|
||||||
TIER_DELETE_JOURNAL_NAMESPACE, TIER_DELETE_JOURNAL_V6_NAMESPACE, validate_durable_ilm_record,
|
TIER_DELETE_JOURNAL_NAMESPACE, TIER_DELETE_JOURNAL_V6_NAMESPACE, validate_durable_ilm_record,
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::recovery_control::{
|
|
||||||
IlmRecoveryClassification, IlmRecoveryControl, IlmRecoveryControlIdentity, IlmRecoveryErrorCode, IlmRecoveryProtocol,
|
|
||||||
load_recovery_control, observe_recovery_source, save_recovery_control_if_absent,
|
|
||||||
};
|
|
||||||
use crate::bucket::lifecycle::runtime_boundary;
|
use crate::bucket::lifecycle::runtime_boundary;
|
||||||
use crate::bucket::lifecycle::tier_sweeper::{
|
use crate::bucket::lifecycle::tier_sweeper::{
|
||||||
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||||
@@ -82,13 +78,6 @@ const TIER_DELETE_DISPATCH_MEMBER_DELETE_CONCURRENCY: usize = 32;
|
|||||||
const TIER_DELETE_DISPATCH_PREPARE_CONCURRENCY: usize = 16;
|
const TIER_DELETE_DISPATCH_PREPARE_CONCURRENCY: usize = 16;
|
||||||
const TIER_DELETE_DISPATCH_CAS_CONCURRENCY: usize = 32;
|
const TIER_DELETE_DISPATCH_CAS_CONCURRENCY: usize = 32;
|
||||||
const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
|
const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
|
||||||
const TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA: &str = "rustfs-tier-delete-journal-v1";
|
|
||||||
const TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA: &str = "rustfs-tier-delete-journal-v2";
|
|
||||||
const TIER_DELETE_JOURNAL_UNKNOWN_RECOVERY_SCHEMA: &str = "rustfs-tier-delete-journal-unknown";
|
|
||||||
const TIER_DELETE_JOURNAL_V1_RECOVERY_CLASS: &str = "tier_delete_journal_v1";
|
|
||||||
const TIER_DELETE_JOURNAL_V2_RECOVERY_CLASS: &str = "tier_delete_journal_v2";
|
|
||||||
const TIER_DELETE_JOURNAL_CORRUPT_RECOVERY_CLASS: &str = "tier_delete_journal_corrupt";
|
|
||||||
const CORRUPT_TIER_DELETE_JOURNAL_IDENTITY: &str = "corrupt";
|
|
||||||
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
|
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
|
||||||
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
|
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
|
||||||
const TIER_DELETE_JOURNAL_TRANSACTION_VERSION: u8 = 5;
|
const TIER_DELETE_JOURNAL_TRANSACTION_VERSION: u8 = 5;
|
||||||
@@ -5520,125 +5509,6 @@ enum TierDeleteJournalEntryRecoveryOutcome {
|
|||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonical_legacy_tier_delete_journal_identity(object_name: &str) -> Option<&str> {
|
|
||||||
let identity = object_name
|
|
||||||
.strip_prefix(TIER_DELETE_JOURNAL_LEGACY_PREFIX)?
|
|
||||||
.strip_suffix(".json")?;
|
|
||||||
(rustfs_utils::crypto::is_sha256_checksum(identity)
|
|
||||||
&& !identity
|
|
||||||
.bytes()
|
|
||||||
.any(|byte| byte.is_ascii_hexdigit() && byte.is_ascii_uppercase()))
|
|
||||||
.then_some(identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn legacy_tier_delete_recovery_descriptor(entry: &Jentry) -> Option<(&'static str, &'static str)> {
|
|
||||||
match entry.persisted_version {
|
|
||||||
1 => Some((TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V1_RECOVERY_CLASS)),
|
|
||||||
TIER_DELETE_JOURNAL_VERSION => Some((TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_CLASS)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn legacy_tier_delete_control_matches(
|
|
||||||
control: &IlmRecoveryControl,
|
|
||||||
identity: &IlmRecoveryControlIdentity,
|
|
||||||
generation: &crate::bucket::lifecycle::recovery_control::IlmRecoverySourceGeneration,
|
|
||||||
classification: IlmRecoveryClassification,
|
|
||||||
error_code: IlmRecoveryErrorCode,
|
|
||||||
) -> bool {
|
|
||||||
control.identity == *identity
|
|
||||||
&& control.observed_source_generation == *generation
|
|
||||||
&& control.classification == classification
|
|
||||||
&& control.last_error_code == error_code
|
|
||||||
&& control.owner.is_none()
|
|
||||||
&& control.attempt_count == 0
|
|
||||||
&& control.consecutive_failure_count == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn legacy_tier_delete_control_is_scheduler_fence(control: &IlmRecoveryControl, identity: &IlmRecoveryControlIdentity) -> bool {
|
|
||||||
control.identity == *identity && control.owner.is_none() && !control.classification.permits_automatic_attempt()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn persist_legacy_tier_delete_recovery_control(
|
|
||||||
api: Arc<ECStore>,
|
|
||||||
object_name: &str,
|
|
||||||
observed_data: &[u8],
|
|
||||||
stable_operation_identity: String,
|
|
||||||
(source_schema, record_class): (&'static str, &'static str),
|
|
||||||
intended_classification: IlmRecoveryClassification,
|
|
||||||
intended_error_code: IlmRecoveryErrorCode,
|
|
||||||
) -> Result<()> {
|
|
||||||
let identity = IlmRecoveryControlIdentity {
|
|
||||||
protocol: IlmRecoveryProtocol::TierDeleteJournal,
|
|
||||||
canonical_source_path: object_name.to_string(),
|
|
||||||
stable_operation_identity,
|
|
||||||
record_class: record_class.to_string(),
|
|
||||||
};
|
|
||||||
let control_id = identity.source_operation_digest().map_err(Error::other)?;
|
|
||||||
match load_recovery_control(api.clone(), IlmRecoveryProtocol::TierDeleteJournal, &control_id).await {
|
|
||||||
Ok(observed) if legacy_tier_delete_control_is_scheduler_fence(&observed.control, &identity) => return Ok(()),
|
|
||||||
Ok(_) => return Err(Error::PreconditionFailed),
|
|
||||||
Err(Error::ConfigNotFound) => {}
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
}
|
|
||||||
|
|
||||||
let source = observe_recovery_source(api.clone(), object_name, source_schema).await?;
|
|
||||||
let exact_source = source.is_consistent() && source.canonical_data.as_deref() == Some(observed_data);
|
|
||||||
let (classification, error_code) = if exact_source {
|
|
||||||
(intended_classification, intended_error_code)
|
|
||||||
} else {
|
|
||||||
(IlmRecoveryClassification::Corrupt, IlmRecoveryErrorCode::SourceDivergent)
|
|
||||||
};
|
|
||||||
let candidate = IlmRecoveryControl::new(
|
|
||||||
identity.clone(),
|
|
||||||
source.generation.clone(),
|
|
||||||
classification,
|
|
||||||
i64::try_from(time::OffsetDateTime::now_utc().unix_timestamp_nanos())
|
|
||||||
.map_err(|_| Error::other("tier delete journal recovery timestamp does not fit i64"))?,
|
|
||||||
error_code,
|
|
||||||
)
|
|
||||||
.map_err(Error::other)?;
|
|
||||||
|
|
||||||
match save_recovery_control_if_absent(api.clone(), &candidate).await {
|
|
||||||
Ok(()) | Err(Error::PreconditionFailed) => {}
|
|
||||||
Err(save_error) => match load_recovery_control(api.clone(), IlmRecoveryProtocol::TierDeleteJournal, &control_id).await {
|
|
||||||
Ok(observed)
|
|
||||||
if legacy_tier_delete_control_matches(
|
|
||||||
&observed.control,
|
|
||||||
&identity,
|
|
||||||
&source.generation,
|
|
||||||
classification,
|
|
||||||
error_code,
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Ok(_) | Err(_) => return Err(save_error),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
let observed = load_recovery_control(api, IlmRecoveryProtocol::TierDeleteJournal, &control_id).await?;
|
|
||||||
if !legacy_tier_delete_control_matches(&observed.control, &identity, &source.generation, classification, error_code) {
|
|
||||||
return Err(Error::PreconditionFailed);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn retain_corrupt_legacy_tier_delete_journal(api: Arc<ECStore>, object_name: &str, data: &[u8]) -> Result<()> {
|
|
||||||
canonical_legacy_tier_delete_journal_identity(object_name)
|
|
||||||
.ok_or_else(|| Error::other("tier delete journal path is not canonical"))?;
|
|
||||||
persist_legacy_tier_delete_recovery_control(
|
|
||||||
api,
|
|
||||||
object_name,
|
|
||||||
data,
|
|
||||||
CORRUPT_TIER_DELETE_JOURNAL_IDENTITY.to_string(),
|
|
||||||
(TIER_DELETE_JOURNAL_UNKNOWN_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_CORRUPT_RECOVERY_CLASS),
|
|
||||||
IlmRecoveryClassification::Corrupt,
|
|
||||||
IlmRecoveryErrorCode::SourceCorrupt,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: String) -> TierDeleteJournalEntryRecoveryOutcome {
|
async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: String) -> TierDeleteJournalEntryRecoveryOutcome {
|
||||||
let data = match config_boundary::read_config(api.clone(), &object_name).await {
|
let data = match config_boundary::read_config(api.clone(), &object_name).await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
@@ -5659,22 +5529,6 @@ async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: Strin
|
|||||||
let je = match decode_tier_delete_journal_entry(&data) {
|
let je = match decode_tier_delete_journal_entry(&data) {
|
||||||
Ok(je) => je,
|
Ok(je) => je,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if canonical_legacy_tier_delete_journal_identity(&object_name).is_some() {
|
|
||||||
return match retain_corrupt_legacy_tier_delete_journal(api, &object_name, &data).await {
|
|
||||||
Ok(()) => TierDeleteJournalEntryRecoveryOutcome::Retained,
|
|
||||||
Err(control_error) => {
|
|
||||||
warn!(
|
|
||||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
|
||||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
|
||||||
journal_object = %object_name,
|
|
||||||
error = ?control_error,
|
|
||||||
"Failed to retain corrupt tier delete journal recovery control"
|
|
||||||
);
|
|
||||||
TierDeleteJournalEntryRecoveryOutcome::Failed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
warn!(
|
warn!(
|
||||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -5688,22 +5542,6 @@ async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: Strin
|
|||||||
};
|
};
|
||||||
|
|
||||||
if tier_delete_journal_object_name(&je) != object_name {
|
if tier_delete_journal_object_name(&je) != object_name {
|
||||||
if canonical_legacy_tier_delete_journal_identity(&object_name).is_some() {
|
|
||||||
return match retain_corrupt_legacy_tier_delete_journal(api, &object_name, &data).await {
|
|
||||||
Ok(()) => TierDeleteJournalEntryRecoveryOutcome::Retained,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
|
||||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
|
||||||
journal_object = %object_name,
|
|
||||||
error = ?err,
|
|
||||||
"Failed to retain mismatched tier delete journal recovery control"
|
|
||||||
);
|
|
||||||
TierDeleteJournalEntryRecoveryOutcome::Failed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
warn!(
|
warn!(
|
||||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -5714,36 +5552,6 @@ async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: Strin
|
|||||||
return TierDeleteJournalEntryRecoveryOutcome::Failed;
|
return TierDeleteJournalEntryRecoveryOutcome::Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((source_schema, record_class)) = legacy_tier_delete_recovery_descriptor(&je) {
|
|
||||||
let stable_operation_identity = canonical_legacy_tier_delete_journal_identity(&object_name)
|
|
||||||
.expect("decoded legacy journal path was validated against its canonical object name")
|
|
||||||
.to_string();
|
|
||||||
return match persist_legacy_tier_delete_recovery_control(
|
|
||||||
api,
|
|
||||||
&object_name,
|
|
||||||
&data,
|
|
||||||
stable_operation_identity,
|
|
||||||
(source_schema, record_class),
|
|
||||||
IlmRecoveryClassification::RetainedAmbiguous,
|
|
||||||
IlmRecoveryErrorCode::RemoteVersionUnknown,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => TierDeleteJournalEntryRecoveryOutcome::Retained,
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
|
||||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
|
||||||
journal_object = %object_name,
|
|
||||||
error = ?err,
|
|
||||||
"Failed to retain legacy tier delete journal recovery control"
|
|
||||||
);
|
|
||||||
TierDeleteJournalEntryRecoveryOutcome::Failed
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
match api
|
match api
|
||||||
.durable_ilm_terminal_receipt_covers_active_source(&object_name, &data)
|
.durable_ilm_terminal_receipt_covers_active_source(&object_name, &data)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -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(all(test, feature = "test-util"))]
|
#[cfg(test)]
|
||||||
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,16 +706,15 @@ 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_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
delete_confirmed_transition_candidate_exact_with_manager_and_identity, delete_object_from_remote_tier_idempotent,
|
||||||
is_remote_tier_not_found_error, is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook,
|
delete_object_from_remote_tier_idempotent_with_manager_and_identity, is_remote_tier_not_found_error,
|
||||||
should_record_remote_delete_failure, transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
|
is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook, should_record_remote_delete_failure,
|
||||||
|
transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
|
||||||
};
|
};
|
||||||
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
use crate::storage_api_contracts::lifecycle::TransitionedObject;
|
||||||
use rustfs_filemeta::TransitionVersionState;
|
use rustfs_filemeta::TransitionVersionState;
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ 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,
|
||||||
@@ -49,7 +44,6 @@ 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;
|
||||||
@@ -743,11 +737,9 @@ pub enum TransitionTransactionRecoveryOutcome {
|
|||||||
RemoteCandidateDeleted,
|
RemoteCandidateDeleted,
|
||||||
RecordDeleted,
|
RecordDeleted,
|
||||||
Retained,
|
Retained,
|
||||||
RetainedAmbiguous(IlmRecoveryErrorCode),
|
|
||||||
OperatorRequired(IlmRecoveryErrorCode),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "test-util"))]
|
#[cfg(test)]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct TransitionRecoveryClaimBarrierState {
|
struct TransitionRecoveryClaimBarrierState {
|
||||||
transaction_id: Uuid,
|
transaction_id: Uuid,
|
||||||
@@ -755,17 +747,17 @@ struct TransitionRecoveryClaimBarrierState {
|
|||||||
release: tokio::sync::Notify,
|
release: tokio::sync::Notify,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "test-util"))]
|
#[cfg(test)]
|
||||||
pub(crate) struct TransitionRecoveryClaimBarrier {
|
pub(crate) struct TransitionRecoveryClaimBarrier {
|
||||||
state: Arc<TransitionRecoveryClaimBarrierState>,
|
state: Arc<TransitionRecoveryClaimBarrierState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "test-util"))]
|
#[cfg(test)]
|
||||||
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(all(test, feature = "test-util"))]
|
#[cfg(test)]
|
||||||
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 {
|
||||||
@@ -796,7 +788,7 @@ impl TransitionRecoveryClaimBarrier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "test-util"))]
|
#[cfg(test)]
|
||||||
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();
|
||||||
@@ -810,7 +802,7 @@ impl Drop for TransitionRecoveryClaimBarrier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "test-util"))]
|
#[cfg(test)]
|
||||||
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))
|
||||||
@@ -825,80 +817,6 @@ 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 {
|
||||||
@@ -1102,35 +1020,17 @@ 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 mut parts = suffix.split('/');
|
let file_name = suffix
|
||||||
let shard_a = parts
|
.rsplit('/')
|
||||||
.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
|
if transaction_key.len() != 32 || !transaction_key.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||||
|| !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)
|
Uuid::parse_str(transaction_key).map_err(|_| TransitionTransactionError::Corrupt("transaction record path has invalid uuid"))
|
||||||
.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(
|
||||||
@@ -1155,27 +1055,6 @@ 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.
|
||||||
@@ -1194,40 +1073,11 @@ async fn process_transition_transaction_record_at(
|
|||||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut recovery_control = if transition_state_needs_recovery_control(¤t, now_unix_nanos) {
|
match current.state {
|
||||||
if cleanup_terminal_transition_recovery_control(
|
|
||||||
api.clone(),
|
|
||||||
¤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 => {
|
TransitionTransactionState::Uploaded => {
|
||||||
if transition_transaction_ownership_is_active(¤t, i128::from(now_unix_nanos)) {
|
if transition_transaction_ownership_is_active(¤t, now_unix_nanos) {
|
||||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||||
} else {
|
}
|
||||||
let mut cleanup = current.clone();
|
let mut cleanup = current.clone();
|
||||||
cleanup
|
cleanup
|
||||||
.mark_cleanup_pending(
|
.mark_cleanup_pending(
|
||||||
@@ -1242,352 +1092,36 @@ async fn process_transition_transaction_record_at(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(transition_transaction_store_error)?;
|
.map_err(transition_transaction_store_error)?;
|
||||||
#[cfg(all(test, feature = "test-util"))]
|
#[cfg(test)]
|
||||||
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.clone(), &cleanup).await,
|
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
|
||||||
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => {
|
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
|
||||||
}
|
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
TransitionTransactionState::CleanupPending => recover_cleanup_pending(api, ¤t).await,
|
||||||
TransitionTransactionState::CleanupPending => recover_cleanup_pending(api.clone(), ¤t).await,
|
|
||||||
TransitionTransactionState::LocalCommitStarted => match local_commit_matches_transaction(api.clone(), ¤t).await {
|
TransitionTransactionState::LocalCommitStarted => match local_commit_matches_transaction(api.clone(), ¤t).await {
|
||||||
Ok(true) => Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
Ok(true) => {
|
||||||
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::OperatorRequired(
|
delete_transition_transaction_record(api, ¤t).await?;
|
||||||
IlmRecoveryErrorCode::LocalCommitAmbiguous,
|
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||||
)),
|
}
|
||||||
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::OperatorRequired(
|
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||||
IlmRecoveryErrorCode::LocalCommitAmbiguous,
|
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||||
)),
|
|
||||||
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, i128::from(now_unix_nanos)) {
|
if transition_transaction_ownership_is_active(¤t, now_unix_nanos) {
|
||||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||||
} else {
|
} else {
|
||||||
recover_unknown_upload_outcome(api.clone(), ¤t).await
|
recover_unknown_upload_outcome(api, ¤t).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TransitionTransactionState::UploadStarted => {
|
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1600,7 +1134,10 @@ 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(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
Ok(true) => {
|
||||||
|
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),
|
||||||
@@ -1620,6 +1157,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1640,26 +1178,24 @@ async fn recover_unknown_upload_outcome(
|
|||||||
.await
|
.await
|
||||||
.map_err(Error::other)?
|
.map_err(Error::other)?
|
||||||
{
|
{
|
||||||
TransitionCandidateProbe::Missing => Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
TransitionCandidateProbe::Missing => {
|
||||||
|
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::RetainedAmbiguous(
|
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||||
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 => Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
|
TransitionCandidateProbe::Ambiguous | TransitionCandidateProbe::Unsupported => {
|
||||||
IlmRecoveryErrorCode::RemoteProbeAmbiguous,
|
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||||
)),
|
}
|
||||||
TransitionCandidateProbe::Unsupported => Ok(TransitionTransactionRecoveryOutcome::RetainedAmbiguous(
|
|
||||||
IlmRecoveryErrorCode::RemoteProbeUnsupported,
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1753,7 +1289,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(feature = "test-util")]
|
#[cfg(any(test, 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,
|
||||||
@@ -1787,11 +1323,6 @@ 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,
|
||||||
@@ -1850,11 +1381,7 @@ async fn recover_transition_transaction_records_with_now(
|
|||||||
) => {
|
) => {
|
||||||
stats.recovered += 1;
|
stats.recovered += 1;
|
||||||
}
|
}
|
||||||
Ok(
|
Ok(TransitionTransactionRecoveryOutcome::Retained) => {
|
||||||
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,
|
||||||
@@ -1982,74 +1509,11 @@ 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>>,
|
||||||
@@ -2504,19 +1968,5 @@ 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(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -489,17 +489,28 @@ impl BucketMetadata {
|
|||||||
!self.bucket_targets_config_json.is_empty() && self.bucket_target_config.is_none()
|
!self.bucket_targets_config_json.is_empty() && self.bucket_target_config.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opaque application-owned configuration with its persisted update time.
|
/// Parsed per-bucket durability override, if a valid one is stored.
|
||||||
/// Empty bytes mean absent or cleared; decoding belongs to the consumer.
|
///
|
||||||
pub fn on_demand_migration_config(&self) -> Option<(&[u8], OffsetDateTime)> {
|
/// Absent/empty/unparsable payloads all mean "no override" (the bucket
|
||||||
(!self.on_demand_migration_config_json.is_empty()).then_some((
|
/// follows the global durability mode); a parse failure is logged so a
|
||||||
self.on_demand_migration_config_json.as_slice(),
|
/// corrupted entry cannot silently change fsync behavior.
|
||||||
self.on_demand_migration_config_updated_at,
|
/// Parsed on-demand migration config, if one is stored.
|
||||||
))
|
///
|
||||||
|
/// `Ok(None)` means no config (absent or cleared). A stored payload that
|
||||||
|
/// does not parse is an error, never a default: the runtime must not
|
||||||
|
/// pull from a source it cannot describe.
|
||||||
|
pub fn on_demand_migration_config(
|
||||||
|
&self,
|
||||||
|
) -> std::result::Result<
|
||||||
|
Option<super::on_demand_migration::OnDemandMigrationConfig>,
|
||||||
|
super::on_demand_migration::OnDemandMigrationConfigError,
|
||||||
|
> {
|
||||||
|
if self.on_demand_migration_config_json.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
super::on_demand_migration::OnDemandMigrationConfig::from_json(&self.on_demand_migration_config_json).map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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;
|
||||||
@@ -791,22 +802,9 @@ impl BucketMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace one config payload and stamp its `*_config_updated_at` with the
|
|
||||||
/// local clock. This is the entry for edits that originate here: the
|
|
||||||
/// local write time is the edit's source time.
|
|
||||||
pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||||
self.update_config_at(config_file, data, OffsetDateTime::now_utc())
|
let updated = OffsetDateTime::now_utc();
|
||||||
}
|
|
||||||
|
|
||||||
/// [`Self::update_config`] with an explicit `updated_at` stamp.
|
|
||||||
///
|
|
||||||
/// For a config replicated from another site the edit's source time is
|
|
||||||
/// the peer's `updated_at`, not the moment it lands here: staleness of
|
|
||||||
/// the next incoming item is judged against the stored stamp, so stamping
|
|
||||||
/// the local apply time would reject a newer source edit that was merely
|
|
||||||
/// delivered late (backlog#2292). Only replication receivers should pass
|
|
||||||
/// a foreign time; local edits keep [`Self::update_config`].
|
|
||||||
pub fn update_config_at(&mut self, config_file: &str, data: Vec<u8>, updated: OffsetDateTime) -> Result<OffsetDateTime> {
|
|
||||||
match config_file {
|
match config_file {
|
||||||
BUCKET_POLICY_CONFIG => {
|
BUCKET_POLICY_CONFIG => {
|
||||||
self.policy_config_json = data;
|
self.policy_config_json = data;
|
||||||
@@ -918,6 +916,13 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1538,39 +1543,6 @@ mod test {
|
|||||||
assert_eq!(metadata.bucket_incarnation_id, incarnation);
|
assert_eq!(metadata.bucket_incarnation_id, incarnation);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// backlog#2292: a replicated config is stamped with the source
|
|
||||||
/// `updated_at` it was given, not the local clock, while the plain
|
|
||||||
/// `update_config` entry keeps stamping the local clock.
|
|
||||||
#[test]
|
|
||||||
fn update_config_at_stamps_the_given_time_and_update_config_stamps_now() {
|
|
||||||
let source_time = OffsetDateTime::now_utc() - time::Duration::hours(3);
|
|
||||||
let mut metadata = BucketMetadata::new("source-stamped");
|
|
||||||
|
|
||||||
let stamped = metadata
|
|
||||||
.update_config_at(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec(), source_time)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(stamped, source_time);
|
|
||||||
assert_eq!(metadata.policy_config_updated_at, source_time);
|
|
||||||
|
|
||||||
let tagging = b"<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag></TagSet></Tagging>".to_vec();
|
|
||||||
let stamped = metadata
|
|
||||||
.update_config_at(BUCKET_TAGGING_CONFIG, tagging, source_time)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(stamped, source_time);
|
|
||||||
assert_eq!(metadata.tagging_config_updated_at, source_time);
|
|
||||||
|
|
||||||
let before = OffsetDateTime::now_utc();
|
|
||||||
let stamped = metadata
|
|
||||||
.update_config(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec())
|
|
||||||
.unwrap();
|
|
||||||
assert!(stamped >= before, "a local edit is stamped with the local clock");
|
|
||||||
assert_eq!(metadata.policy_config_updated_at, stamped);
|
|
||||||
assert_eq!(
|
|
||||||
metadata.tagging_config_updated_at, source_time,
|
|
||||||
"restamping one config must not move another config's stamp"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn object_locking_requires_lock_metadata_not_plain_versioning() {
|
fn object_locking_requires_lock_metadata_not_plain_versioning() {
|
||||||
use s3s::dto::ObjectLockEnabled;
|
use s3s::dto::ObjectLockEnabled;
|
||||||
@@ -1657,34 +1629,6 @@ mod test {
|
|||||||
assert!(bm.bucket_target_config.is_none());
|
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
|
/// The invariant every branch of `parse_all_configs` shares: a stored but
|
||||||
/// undecodable payload keeps its raw bytes and leaves the typed field
|
/// 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
|
/// `None`, so no branch fabricates a value. What a reader may then do with
|
||||||
@@ -2034,30 +1978,51 @@ 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"}}}"#;
|
||||||
|
|
||||||
/// The metadata codec preserves application-owned bytes and timestamps.
|
/// rustfs/backlog#2148: the on-demand migration config is a RustFS
|
||||||
|
/// extension entry that round-trips through `update_config` and the
|
||||||
|
/// msgpack codec, clears on delete, and never parses corruption into a
|
||||||
|
/// default.
|
||||||
#[test]
|
#[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(), None, "fresh metadata carries no config");
|
assert_eq!(bm.on_demand_migration_config(), Ok(None), "fresh metadata carries no config");
|
||||||
|
|
||||||
|
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
||||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||||
.expect("opaque config is accepted");
|
.expect("valid config is accepted");
|
||||||
let stamped = bm.on_demand_migration_config_updated_at;
|
assert_ne!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||||
assert_ne!(stamped, OffsetDateTime::UNIX_EPOCH);
|
assert_eq!(bm.on_demand_migration_config(), Ok(Some(expected.clone())));
|
||||||
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!(back.on_demand_migration_config_updated_at.unix_timestamp(), stamped.unix_timestamp());
|
assert_eq!(
|
||||||
|
back.on_demand_migration_config_updated_at.unix_timestamp(),
|
||||||
|
bm.on_demand_migration_config_updated_at.unix_timestamp()
|
||||||
|
);
|
||||||
|
assert_eq!(back.on_demand_migration_config(), Ok(Some(expected)));
|
||||||
|
|
||||||
|
// A blob this build cannot read is rejected at the write boundary
|
||||||
|
// rather than persisted for every reader to trip over.
|
||||||
|
let before = bm.on_demand_migration_config_json.clone();
|
||||||
|
assert!(
|
||||||
|
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(bm.on_demand_migration_config_json, before, "a rejected update leaves the blob untouched");
|
||||||
|
|
||||||
|
// Delete clears the entry.
|
||||||
|
let stamped = bm.on_demand_migration_config_updated_at;
|
||||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
|
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(), None);
|
assert_eq!(bm.on_demand_migration_config(), Ok(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())
|
|
||||||
.unwrap();
|
// Corruption that bypassed `update_config` (disk, another writer)
|
||||||
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
// is a typed error, never a default.
|
||||||
assert_eq!(
|
bm.on_demand_migration_config_json = b"not-json".to_vec();
|
||||||
back.on_demand_migration_config_json, b"not-json",
|
assert!(matches!(bm.on_demand_migration_config(), Err(OnDemandMigrationConfigError::Malformed(_))));
|
||||||
"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
|
||||||
@@ -2069,7 +2034,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(), None);
|
assert_eq!(bm.on_demand_migration_config(), Ok(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,6 +19,7 @@ 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};
|
||||||
@@ -48,11 +49,6 @@ 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"))]
|
||||||
@@ -399,21 +395,39 @@ fn clear_bucket_durability(bucket: &str) {
|
|||||||
crate::disk::local::bucket_durability::set(bucket, None);
|
crate::disk::local::bucket_durability::set(bucket, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish application-owned bytes on every cache install path.
|
/// Publish the bucket's on-demand migration config (or its absence) to the
|
||||||
|
/// runtime registered in `ON_DEMAND_MIGRATION_CONFIG_HOOK`.
|
||||||
|
///
|
||||||
|
/// Called from the same five cache-install paths as
|
||||||
|
/// [`sync_bucket_durability`]. A stored payload this build cannot parse is
|
||||||
|
/// published as `None`: the runtime must stop pulling for that bucket rather
|
||||||
|
/// than keep an older config or guess.
|
||||||
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
|
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
|
||||||
if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
|
let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() else {
|
||||||
hook(
|
return;
|
||||||
bucket,
|
};
|
||||||
super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG,
|
match bm.on_demand_migration_config() {
|
||||||
bm.on_demand_migration_config()
|
Ok(config) => hook(bucket, config.as_ref()),
|
||||||
.map(|(bytes, stamp)| (bytes, stamp, bm.bucket_incarnation_id)),
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
event = "bucket_metadata_parse_failed",
|
||||||
|
component = "ecstore",
|
||||||
|
subsystem = "bucket_metadata",
|
||||||
|
bucket = %bucket,
|
||||||
|
config = "on_demand_migration",
|
||||||
|
error = %err,
|
||||||
|
"Failed to parse bucket metadata config"
|
||||||
);
|
);
|
||||||
|
hook(bucket, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Withdraw a bucket's on-demand migration config when its metadata leaves
|
||||||
|
/// the cache.
|
||||||
fn clear_on_demand_migration(bucket: &str) {
|
fn clear_on_demand_migration(bucket: &str) {
|
||||||
if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
|
if let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() {
|
||||||
hook(bucket, super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, None);
|
hook(bucket, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,32 +581,6 @@ pub async fn update_if_incarnation(
|
|||||||
config_file,
|
config_file,
|
||||||
data,
|
data,
|
||||||
Some(expected_incarnation_id),
|
Some(expected_incarnation_id),
|
||||||
None,
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`update_if_incarnation`] stamping the config with `updated_at` instead of
|
|
||||||
/// the local clock.
|
|
||||||
///
|
|
||||||
/// For a site-replication receiver the edit's source time is the peer's
|
|
||||||
/// `updated_at`; persisting it keeps the stored `*_config_updated_at` on the
|
|
||||||
/// source clock so the next item's staleness is judged source-time against
|
|
||||||
/// source-time (backlog#2292). See [`BucketMetadata::update_config_at`].
|
|
||||||
pub async fn update_if_incarnation_at(
|
|
||||||
bucket: &str,
|
|
||||||
config_file: &str,
|
|
||||||
data: Vec<u8>,
|
|
||||||
expected_incarnation_id: Uuid,
|
|
||||||
updated_at: OffsetDateTime,
|
|
||||||
) -> Result<OffsetDateTime> {
|
|
||||||
Box::pin(update_with_sys_expected(
|
|
||||||
get_bucket_metadata_sys()?,
|
|
||||||
bucket,
|
|
||||||
config_file,
|
|
||||||
data,
|
|
||||||
Some(expected_incarnation_id),
|
|
||||||
Some(updated_at),
|
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -603,30 +591,6 @@ pub async fn delete_if_incarnation(bucket: &str, config_file: &str, expected_inc
|
|||||||
bucket,
|
bucket,
|
||||||
config_file,
|
config_file,
|
||||||
Some(expected_incarnation_id),
|
Some(expected_incarnation_id),
|
||||||
None,
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`delete_if_incarnation`] stamping the cleared config with `updated_at`
|
|
||||||
/// (a replicated deletion's source time) instead of the local clock.
|
|
||||||
///
|
|
||||||
/// The stamp survives the deletion as the config's `*_config_updated_at`, and
|
|
||||||
/// that is what the next incoming item is judged against: a local stamp on
|
|
||||||
/// the delete would reject a newer source re-create that was merely delivered
|
|
||||||
/// later (backlog#2292). See [`update_if_incarnation_at`].
|
|
||||||
pub async fn delete_if_incarnation_at(
|
|
||||||
bucket: &str,
|
|
||||||
config_file: &str,
|
|
||||||
expected_incarnation_id: Uuid,
|
|
||||||
updated_at: OffsetDateTime,
|
|
||||||
) -> Result<OffsetDateTime> {
|
|
||||||
Box::pin(delete_with_sys_expected(
|
|
||||||
get_bucket_metadata_sys()?,
|
|
||||||
bucket,
|
|
||||||
config_file,
|
|
||||||
Some(expected_incarnation_id),
|
|
||||||
Some(updated_at),
|
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -648,41 +612,34 @@ async fn update_with_sys(
|
|||||||
config_file: &str,
|
config_file: &str,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
update_with_sys_expected(sys, bucket, config_file, data, None, None).await
|
update_with_sys_expected(sys, bucket, config_file, data, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `updated_at` is the stamp persisted on the config; `None` uses the local
|
|
||||||
/// clock (the edit originates here), `Some` carries a replicated edit's
|
|
||||||
/// source time (backlog#2292).
|
|
||||||
async fn update_with_sys_expected(
|
async fn update_with_sys_expected(
|
||||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
sys: Arc<RwLock<BucketMetadataSys>>,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
config_file: &str,
|
config_file: &str,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
expected_incarnation_id: Option<Uuid>,
|
expected_incarnation_id: Option<Uuid>,
|
||||||
updated_at: Option<OffsetDateTime>,
|
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
|
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
|
||||||
update_under_config_write_guard(sys, &guard, config_file, data, updated_at).await
|
update_under_config_write_guard(sys, &guard, config_file, data).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`delete`] against an explicitly supplied metadata system. See
|
/// [`delete`] against an explicitly supplied metadata system. See
|
||||||
/// [`update_with_sys`].
|
/// [`update_with_sys`].
|
||||||
async fn delete_with_sys(sys: Arc<RwLock<BucketMetadataSys>>, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
async fn delete_with_sys(sys: Arc<RwLock<BucketMetadataSys>>, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
||||||
delete_with_sys_expected(sys, bucket, config_file, None, None).await
|
delete_with_sys_expected(sys, bucket, config_file, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `updated_at`: `None` stamps the local clock; `Some` persists a replicated
|
|
||||||
/// deletion's source time (backlog#2292).
|
|
||||||
async fn delete_with_sys_expected(
|
async fn delete_with_sys_expected(
|
||||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
sys: Arc<RwLock<BucketMetadataSys>>,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
config_file: &str,
|
config_file: &str,
|
||||||
expected_incarnation_id: Option<Uuid>,
|
expected_incarnation_id: Option<Uuid>,
|
||||||
updated_at: Option<OffsetDateTime>,
|
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
|
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
|
||||||
delete_under_config_write_guard(sys, &guard, config_file, updated_at).await
|
delete_under_config_write_guard(sys, &guard, config_file).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns the complete bucket-config mutation fence.
|
/// Owns the complete bucket-config mutation fence.
|
||||||
@@ -829,21 +786,7 @@ pub async fn update_under_transaction_lock(
|
|||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
guard.ensure_valid(bucket)?;
|
guard.ensure_valid(bucket)?;
|
||||||
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data, None).await
|
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data).await
|
||||||
}
|
|
||||||
|
|
||||||
/// [`update_under_transaction_lock`] stamping the config with `updated_at`
|
|
||||||
/// (a replicated edit's source time) instead of the local clock; see
|
|
||||||
/// [`update_if_incarnation_at`] (backlog#2292).
|
|
||||||
pub async fn update_under_transaction_lock_at(
|
|
||||||
guard: &BucketMetadataMutationGuard,
|
|
||||||
bucket: &str,
|
|
||||||
config_file: &str,
|
|
||||||
data: Vec<u8>,
|
|
||||||
updated_at: OffsetDateTime,
|
|
||||||
) -> Result<OffsetDateTime> {
|
|
||||||
guard.ensure_valid(bucket)?;
|
|
||||||
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data, Some(updated_at)).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear one config file while the caller holds this bucket's transaction lock.
|
/// Clear one config file while the caller holds this bucket's transaction lock.
|
||||||
@@ -853,7 +796,7 @@ pub async fn delete_under_transaction_lock(
|
|||||||
config_file: &str,
|
config_file: &str,
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
guard.ensure_valid(bucket)?;
|
guard.ensure_valid(bucket)?;
|
||||||
delete_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, None).await
|
delete_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_quota_if_incarnation(
|
pub async fn update_quota_if_incarnation(
|
||||||
@@ -861,29 +804,6 @@ pub async fn update_quota_if_incarnation(
|
|||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
expected_incarnation_id: Uuid,
|
expected_incarnation_id: Uuid,
|
||||||
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
||||||
) -> Result<OffsetDateTime> {
|
|
||||||
update_quota_if_incarnation_stamped(bucket, data, expected_incarnation_id, proof, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`update_quota_if_incarnation`] stamping the quota config with
|
|
||||||
/// `updated_at` (a replicated edit's source time) instead of the local
|
|
||||||
/// clock; see [`update_if_incarnation_at`] (backlog#2292).
|
|
||||||
pub async fn update_quota_if_incarnation_at(
|
|
||||||
bucket: &str,
|
|
||||||
data: Vec<u8>,
|
|
||||||
expected_incarnation_id: Uuid,
|
|
||||||
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
|
||||||
updated_at: OffsetDateTime,
|
|
||||||
) -> Result<OffsetDateTime> {
|
|
||||||
update_quota_if_incarnation_stamped(bucket, data, expected_incarnation_id, proof, Some(updated_at)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_quota_if_incarnation_stamped(
|
|
||||||
bucket: &str,
|
|
||||||
data: Vec<u8>,
|
|
||||||
expected_incarnation_id: Uuid,
|
|
||||||
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
|
|
||||||
updated_at: Option<OffsetDateTime>,
|
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
let sys = get_bucket_metadata_sys()?;
|
let sys = get_bucket_metadata_sys()?;
|
||||||
let guard = Box::pin(acquire_config_write_guard_for_incarnation(
|
let guard = Box::pin(acquire_config_write_guard_for_incarnation(
|
||||||
@@ -901,7 +821,7 @@ async fn update_quota_if_incarnation_stamped(
|
|||||||
achieved: 0,
|
achieved: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data, updated_at).await
|
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_bucket_targets_under_transaction_lock(
|
pub async fn update_bucket_targets_under_transaction_lock(
|
||||||
@@ -917,7 +837,6 @@ async fn update_under_config_write_guard(
|
|||||||
guard: &BucketMetadataMutationGuard,
|
guard: &BucketMetadataMutationGuard,
|
||||||
config_file: &str,
|
config_file: &str,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
updated_at: Option<OffsetDateTime>,
|
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
guard.ensure_valid(&guard.bucket)?;
|
guard.ensure_valid(&guard.bucket)?;
|
||||||
let metadata_sys = sys.read().await.clone();
|
let metadata_sys = sys.read().await.clone();
|
||||||
@@ -929,7 +848,7 @@ async fn update_under_config_write_guard(
|
|||||||
Some(&guard.transaction_guard),
|
Some(&guard.transaction_guard),
|
||||||
&guard.bucket,
|
&guard.bucket,
|
||||||
"bucket config transaction",
|
"bucket config transaction",
|
||||||
metadata_sys.update_checked(&guard.bucket, config_file, data, true, guard.incarnation_id, updated_at),
|
metadata_sys.update_checked(&guard.bucket, config_file, data, true, guard.incarnation_id),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -941,7 +860,6 @@ async fn delete_under_config_write_guard(
|
|||||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
sys: Arc<RwLock<BucketMetadataSys>>,
|
||||||
guard: &BucketMetadataMutationGuard,
|
guard: &BucketMetadataMutationGuard,
|
||||||
config_file: &str,
|
config_file: &str,
|
||||||
updated_at: Option<OffsetDateTime>,
|
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
guard.ensure_valid(&guard.bucket)?;
|
guard.ensure_valid(&guard.bucket)?;
|
||||||
let metadata_sys = sys.read().await.clone();
|
let metadata_sys = sys.read().await.clone();
|
||||||
@@ -953,7 +871,7 @@ async fn delete_under_config_write_guard(
|
|||||||
Some(&guard.transaction_guard),
|
Some(&guard.transaction_guard),
|
||||||
&guard.bucket,
|
&guard.bucket,
|
||||||
"bucket config deletion transaction",
|
"bucket config deletion transaction",
|
||||||
metadata_sys.update_checked(&guard.bucket, config_file, Vec::new(), false, guard.incarnation_id, updated_at),
|
metadata_sys.update_checked(&guard.bucket, config_file, Vec::new(), false, guard.incarnation_id),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1131,21 +1049,15 @@ 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. Bytes are opaque to the metadata owner.
|
/// `Ok(None)` when the bucket has none. A stored payload that does not parse
|
||||||
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
/// is a typed error (`OnDemandMigrationConfigError` inside `Error::Io`).
|
||||||
|
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
||||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
let bucket_meta_sys_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;
|
||||||
@@ -1858,17 +1770,15 @@ impl BucketMetadataSys {
|
|||||||
/// `update` and the config read alone). Keep these boxed.
|
/// `update` and the config read alone). Keep these boxed.
|
||||||
pub async fn update(&self, bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
pub async fn update(&self, bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||||
let incarnation_id = Box::pin(self.get_bucket_incarnation_id(bucket)).await?;
|
let incarnation_id = Box::pin(self.get_bucket_incarnation_id(bucket)).await?;
|
||||||
Box::pin(self.update_checked(bucket, config_file, data, true, incarnation_id, None)).await
|
Box::pin(self.update_checked(bucket, config_file, data, true, incarnation_id)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
pub async fn delete(&self, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
||||||
let incarnation_id = self.get_bucket_incarnation_id(bucket).await?;
|
let incarnation_id = self.get_bucket_incarnation_id(bucket).await?;
|
||||||
self.update_checked(bucket, config_file, Vec::new(), false, incarnation_id, None)
|
self.update_checked(bucket, config_file, Vec::new(), false, incarnation_id)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `updated_at`: `None` stamps the local clock; `Some` persists a
|
|
||||||
/// replicated edit's source time (backlog#2292).
|
|
||||||
async fn update_checked(
|
async fn update_checked(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -1876,7 +1786,6 @@ impl BucketMetadataSys {
|
|||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
parse: bool,
|
parse: bool,
|
||||||
expected_incarnation_id: Uuid,
|
expected_incarnation_id: Uuid,
|
||||||
updated_at: Option<OffsetDateTime>,
|
|
||||||
) -> Result<OffsetDateTime> {
|
) -> Result<OffsetDateTime> {
|
||||||
// Load through this system's own store, the one `save` persists to
|
// Load through this system's own store, the one `save` persists to
|
||||||
// (backlog#1052 S7). Reading from the ambient handle instead made the
|
// (backlog#1052 S7). Reading from the ambient handle instead made the
|
||||||
@@ -1887,10 +1796,7 @@ impl BucketMetadataSys {
|
|||||||
return Err(Error::BucketNotFound(bucket.to_string()));
|
return Err(Error::BucketNotFound(bucket.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = match updated_at {
|
let updated = bm.update_config(config_file, data)?;
|
||||||
Some(updated_at) => bm.update_config_at(config_file, data, updated_at)?,
|
|
||||||
None => bm.update_config(config_file, data)?,
|
|
||||||
};
|
|
||||||
|
|
||||||
Box::pin(self.save(bm)).await?;
|
Box::pin(self.save(bm)).await?;
|
||||||
|
|
||||||
@@ -2673,27 +2579,29 @@ impl BucketMetadataSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// See [`get_on_demand_migration_config`].
|
/// See [`get_on_demand_migration_config`].
|
||||||
pub async fn get_on_demand_migration_config(&self, bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
pub async fn get_on_demand_migration_config(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
||||||
let (bm, _) = self.get_config(bucket).await?;
|
let (bm, _) = self.get_config(bucket).await?;
|
||||||
|
|
||||||
Ok(bm
|
let config = bm.on_demand_migration_config().map_err(Error::other)?;
|
||||||
.on_demand_migration_config()
|
Ok(config.map(|config| (config, bm.on_demand_migration_config_updated_at)))
|
||||||
.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(any(test, feature = "test-util"))]
|
#[cfg(test)]
|
||||||
pub mod test_support {
|
pub(crate) 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 async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
pub(crate) 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 {
|
||||||
@@ -3857,106 +3765,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// backlog#2292: the explicit-stamp write path persists the given source
|
|
||||||
/// time as the config's `*_config_updated_at` — through the incarnation
|
|
||||||
/// path and through an already-held transaction guard — and survives a
|
|
||||||
/// reload from disk, while the plain path keeps stamping the local clock.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn explicit_updated_at_is_persisted_as_the_config_stamp() {
|
|
||||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
|
||||||
let bucket = "source-stamped-config";
|
|
||||||
for dir in &dirs {
|
|
||||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("bucket volume should be created");
|
|
||||||
}
|
|
||||||
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
|
||||||
let source_time = OffsetDateTime::now_utc() - Duration::from_secs(3 * 3600);
|
|
||||||
let policy = br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec();
|
|
||||||
let tagging = b"<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag></TagSet></Tagging>".to_vec();
|
|
||||||
|
|
||||||
// Incarnation path (`update_if_incarnation_at` minus the ambient lookup).
|
|
||||||
let stamped =
|
|
||||||
update_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy.clone(), None, Some(source_time))
|
|
||||||
.await
|
|
||||||
.expect("source-stamped policy write should persist");
|
|
||||||
assert_eq!(stamped, source_time);
|
|
||||||
|
|
||||||
// Held-guard path (`update_under_transaction_lock_at` minus the ambient lookup).
|
|
||||||
let guard = acquire_config_write_guard(sys.clone(), bucket).await.expect("write guard");
|
|
||||||
let stamped = update_under_config_write_guard(sys.clone(), &guard, BUCKET_TAGGING_CONFIG, tagging, Some(source_time))
|
|
||||||
.await
|
|
||||||
.expect("source-stamped tagging write should persist");
|
|
||||||
drop(guard);
|
|
||||||
assert_eq!(stamped, source_time);
|
|
||||||
|
|
||||||
let metadata_sys = sys.read().await.clone();
|
|
||||||
metadata_sys.metadata_map.write().await.clear();
|
|
||||||
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
|
|
||||||
assert_eq!(reloaded.policy_config_updated_at, source_time);
|
|
||||||
assert_eq!(reloaded.tagging_config_updated_at, source_time);
|
|
||||||
|
|
||||||
// The plain path is unchanged: a local edit is stamped with the local clock.
|
|
||||||
let before = OffsetDateTime::now_utc();
|
|
||||||
let stamped = update_with_sys(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy)
|
|
||||||
.await
|
|
||||||
.expect("locally stamped policy write should persist");
|
|
||||||
assert!(stamped >= before, "the plain write path must keep stamping the local clock");
|
|
||||||
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
|
|
||||||
assert_eq!(reloaded.policy_config_updated_at, stamped);
|
|
||||||
assert_eq!(
|
|
||||||
reloaded.tagging_config_updated_at, source_time,
|
|
||||||
"an unrelated config keeps its source stamp"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// backlog#2292: a replicated delete persists the source time as the
|
|
||||||
/// cleared config's `*_config_updated_at`, so the receive-side gate
|
|
||||||
/// (source time against stored stamp) lets a newer source re-create land
|
|
||||||
/// even when the delete was applied later than the re-create's source
|
|
||||||
/// time; the plain delete keeps stamping the local clock.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn explicit_updated_at_is_persisted_by_a_delete() {
|
|
||||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
|
||||||
let bucket = "source-stamped-delete";
|
|
||||||
for dir in &dirs {
|
|
||||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("bucket volume should be created");
|
|
||||||
}
|
|
||||||
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
|
||||||
let policy = br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec();
|
|
||||||
let created_at = OffsetDateTime::now_utc() - Duration::from_secs(3 * 3600);
|
|
||||||
let deleted_at = created_at + Duration::from_secs(60);
|
|
||||||
let recreated_at = deleted_at + Duration::from_secs(60);
|
|
||||||
|
|
||||||
update_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy.clone(), None, Some(created_at))
|
|
||||||
.await
|
|
||||||
.expect("source-stamped policy write should persist");
|
|
||||||
let stamped = delete_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, None, Some(deleted_at))
|
|
||||||
.await
|
|
||||||
.expect("source-stamped policy delete should persist");
|
|
||||||
assert_eq!(stamped, deleted_at);
|
|
||||||
|
|
||||||
let metadata_sys = sys.read().await.clone();
|
|
||||||
metadata_sys.metadata_map.write().await.clear();
|
|
||||||
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
|
|
||||||
assert!(reloaded.policy_config_json.is_empty(), "the delete cleared the payload");
|
|
||||||
assert_eq!(reloaded.policy_config_updated_at, deleted_at, "the delete kept the source stamp");
|
|
||||||
assert!(
|
|
||||||
recreated_at >= reloaded.policy_config_updated_at,
|
|
||||||
"a re-create newer than the delete's source time is not stale against the stored stamp"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The plain delete path is unchanged: stamped with the local clock.
|
|
||||||
update_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy, None, Some(recreated_at))
|
|
||||||
.await
|
|
||||||
.expect("re-create should persist");
|
|
||||||
let before = OffsetDateTime::now_utc();
|
|
||||||
let stamped = delete_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, None, None)
|
|
||||||
.await
|
|
||||||
.expect("locally stamped delete should persist");
|
|
||||||
assert!(stamped >= before, "the plain delete path must keep stamping the local clock");
|
|
||||||
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
|
|
||||||
assert_eq!(reloaded.policy_config_updated_at, stamped);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The load and the persisted write share one write guard, so concurrent
|
/// The load and the persisted write share one write guard, so concurrent
|
||||||
/// rewrites of the same config compose instead of clobbering each other.
|
/// rewrites of the same config compose instead of clobbering each other.
|
||||||
/// Moving the load outside that guard loses all but the last tag.
|
/// Moving the load outside that guard loses all but the last tag.
|
||||||
@@ -4173,14 +3981,8 @@ mod tests {
|
|||||||
let new_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.unwrap();
|
let new_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.unwrap();
|
||||||
assert_ne!(old_incarnation, new_incarnation);
|
assert_ne!(old_incarnation, new_incarnation);
|
||||||
|
|
||||||
let err = update_with_sys_expected(
|
let err =
|
||||||
sys.clone(),
|
update_with_sys_expected(sys.clone(), bucket, BUCKET_TAGGING_CONFIG, b"<Tagging/>".to_vec(), Some(old_incarnation))
|
||||||
bucket,
|
|
||||||
BUCKET_TAGGING_CONFIG,
|
|
||||||
b"<Tagging/>".to_vec(),
|
|
||||||
Some(old_incarnation),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.expect_err("a request authorized for the deleted incarnation must fail closed");
|
.expect_err("a request authorized for the deleted incarnation must fail closed");
|
||||||
assert!(matches!(err, Error::BucketNotFound(name) if name == bucket));
|
assert!(matches!(err, Error::BucketNotFound(name) if name == bucket));
|
||||||
@@ -4217,7 +4019,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
update_under_config_write_guard(sys, &guard, BUCKET_TAGGING_CONFIG, tagging, None)
|
update_under_config_write_guard(sys, &guard, BUCKET_TAGGING_CONFIG, tagging)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!delete.is_finished());
|
assert!(!delete.is_finished());
|
||||||
@@ -4583,26 +4385,19 @@ 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<RecordedOdmHookCall>> = std::sync::Mutex::new(Vec::new());
|
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<(String, Option<OnDemandMigrationConfig>)>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
fn install_recording_odm_hook() {
|
fn install_recording_odm_hook() {
|
||||||
BUCKET_CONFIG_PUBLISH_HOOK.get_or_init(|| {
|
ON_DEMAND_MIGRATION_CONFIG_HOOK.get_or_init(|| {
|
||||||
Box::new(|bucket, config_file, config| {
|
Box::new(|bucket, config| {
|
||||||
assert_eq!(config_file, super::super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG);
|
ODM_HOOK_CALLS.lock().unwrap().push((bucket.to_string(), config.cloned()));
|
||||||
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<RecordedOdmConfig> {
|
fn odm_hook_calls(bucket: &str) -> Vec<Option<OnDemandMigrationConfig>> {
|
||||||
ODM_HOOK_CALLS
|
ODM_HOOK_CALLS
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -4612,6 +4407,54 @@ 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
|
||||||
@@ -4625,22 +4468,15 @@ 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!(
|
assert_eq!(calls.last().unwrap().as_ref(), Some(&expected), "{label} must publish the stored config");
|
||||||
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());
|
||||||
@@ -4682,18 +4518,14 @@ 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);
|
||||||
|
|
||||||
// Opaque bytes reach the application even if they are not valid JSON.
|
// A corrupt payload is withdrawn, never published as a config.
|
||||||
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!(
|
assert_eq!(calls.last().unwrap(), &None, "unreadable config must publish absence");
|
||||||
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,6 +26,7 @@ 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;
|
||||||
|
|||||||
+63
-356
@@ -25,8 +25,8 @@
|
|||||||
//! [`BACKFILL_SAVE_INTERVAL`], and at every page end, with an `If-Match`
|
//! [`BACKFILL_SAVE_INTERVAL`], and at every page end, with an `If-Match`
|
||||||
//! compare-and-set so a concurrent cancel or takeover is never overwritten.
|
//! compare-and-set so a concurrent cancel or takeover is never overwritten.
|
||||||
//! - The `continuation_token` only advances once every pull queued from the
|
//! - The `continuation_token` only advances once every pull queued from the
|
||||||
//! page before it has succeeded. After a failure it stays at that page,
|
//! page before it has reported back, so a crash re-lists at most one page
|
||||||
//! so crash recovery cannot skip failed pulls (existing keys are skipped).
|
//! (already-present keys are then skipped, never re-pulled).
|
||||||
//! - The owner holds a lease of [`BACKFILL_LEASE`] renewed by every save. The
|
//! - The owner holds a lease of [`BACKFILL_LEASE`] renewed by every save. The
|
||||||
//! recovery loop ([`run_backfill_recovery_loop`]) scans the buckets this
|
//! recovery loop ([`run_backfill_recovery_loop`]) scans the buckets this
|
||||||
//! node has an ODM state for every [`BACKFILL_RECOVERY_INTERVAL`] and takes
|
//! node has an ODM state for every [`BACKFILL_RECOVERY_INTERVAL`] and takes
|
||||||
@@ -45,12 +45,16 @@
|
|||||||
|
|
||||||
use super::pull::{EnqueueOutcome, PullReason, QueuedPullOutcome};
|
use super::pull::{EnqueueOutcome, PullReason, QueuedPullOutcome};
|
||||||
use super::source_client::{SourceError, SourcePage};
|
use super::source_client::{SourceError, SourcePage};
|
||||||
use super::storage_api::{
|
|
||||||
BUCKET_META_PREFIX, ECStore, HTTPPreconditions, NamespaceLocking as _, ObjectOperations as _, ObjectOptions,
|
|
||||||
RUSTFS_META_BUCKET, StorageError, WriteCompletion, get_lock_acquire_timeout, get_on_demand_migration_config_in,
|
|
||||||
local_node_name, read_config_with_metadata, save_config_with_opts,
|
|
||||||
};
|
|
||||||
use super::sys::{BucketOdmState, OnDemandMigrationSys};
|
use super::sys::{BucketOdmState, OnDemandMigrationSys};
|
||||||
|
use crate::bucket::metadata_sys::bucket_metadata_sys_of;
|
||||||
|
use crate::config::com::{read_config_with_metadata, save_config_with_opts};
|
||||||
|
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
|
||||||
|
use crate::error::Error as StorageError;
|
||||||
|
use crate::object_api::ObjectOptions;
|
||||||
|
use crate::runtime::sources::local_node_name;
|
||||||
|
use crate::set_disk::get_lock_acquire_timeout;
|
||||||
|
use crate::storage_api_contracts::{namespace::NamespaceLocking as _, object::HTTPPreconditions, object::ObjectOperations as _};
|
||||||
|
use crate::store::ECStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use futures::stream::FuturesUnordered;
|
use futures::stream::FuturesUnordered;
|
||||||
@@ -363,15 +367,14 @@ pub struct LocalBackfillObject {
|
|||||||
pub source_etag: Option<String>,
|
pub source_etag: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared report of a new or coalesced pull; absent only when not admitted.
|
/// Receiver of one queued pull's report; `None` when the pull was coalesced
|
||||||
pub type PullReport = Option<super::pull::QueuedPullReport>;
|
/// into one already running.
|
||||||
|
pub type PullReport = Option<oneshot::Receiver<QueuedPullOutcome>>;
|
||||||
|
|
||||||
/// Everything the job needs from its bucket, so the loop can run against a
|
/// Everything the job needs from its bucket, so the loop can run against a
|
||||||
/// mock in unit tests. Production: [`BucketBackfillContext`].
|
/// mock in unit tests. Production: [`BucketBackfillContext`].
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait BackfillContext: Send + Sync {
|
pub trait BackfillContext: Send + Sync {
|
||||||
/// The bucket incarnation captured by this context.
|
|
||||||
fn incarnation_id(&self) -> Uuid;
|
|
||||||
/// One source page in the local key namespace.
|
/// One source page in the local key namespace.
|
||||||
async fn list_page(&self, prefix: Option<&str>, token: Option<&str>, max_keys: i32) -> Result<SourcePage, SourceError>;
|
async fn list_page(&self, prefix: Option<&str>, token: Option<&str>, max_keys: i32) -> Result<SourcePage, SourceError>;
|
||||||
/// Whether the breaker admits source traffic right now.
|
/// Whether the breaker admits source traffic right now.
|
||||||
@@ -413,10 +416,6 @@ impl BucketBackfillContext {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl BackfillContext for BucketBackfillContext {
|
impl BackfillContext for BucketBackfillContext {
|
||||||
fn incarnation_id(&self) -> Uuid {
|
|
||||||
self.state.incarnation_id()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_page(&self, prefix: Option<&str>, token: Option<&str>, max_keys: i32) -> Result<SourcePage, SourceError> {
|
async fn list_page(&self, prefix: Option<&str>, token: Option<&str>, max_keys: i32) -> Result<SourcePage, SourceError> {
|
||||||
let client = self.state.client().map_err(|err| SourceError::Unsupported(err.to_string()))?;
|
let client = self.state.client().map_err(|err| SourceError::Unsupported(err.to_string()))?;
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
@@ -476,10 +475,12 @@ impl BackfillContext for BucketBackfillContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn config_updated_at(&self) -> Result<Option<OffsetDateTime>, StorageError> {
|
async fn config_updated_at(&self) -> Result<Option<OffsetDateTime>, StorageError> {
|
||||||
Ok(
|
let sys = bucket_metadata_sys_of(&self.api.ctx)?;
|
||||||
super::config::decode_stored_config(get_on_demand_migration_config_in(&self.api, self.state.bucket()).await?)?
|
let guard = sys.read().await;
|
||||||
.map(|(_, updated_at)| updated_at),
|
Ok(guard
|
||||||
)
|
.get_on_demand_migration_config(self.state.bucket())
|
||||||
|
.await?
|
||||||
|
.map(|(_, updated_at)| updated_at))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,36 +668,8 @@ pub async fn read_checkpoint(api: &Arc<ECStore>, bucket: &str) -> Result<Option<
|
|||||||
async fn write_checkpoint(
|
async fn write_checkpoint(
|
||||||
api: &Arc<ECStore>,
|
api: &Arc<ECStore>,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
incarnation_id: Uuid,
|
|
||||||
checkpoint: &BackfillCheckpoint,
|
checkpoint: &BackfillCheckpoint,
|
||||||
expected_etag: Option<&str>,
|
expected_etag: Option<&str>,
|
||||||
) -> Result<String, BackfillError> {
|
|
||||||
let api = Arc::clone(api);
|
|
||||||
let bucket = bucket.to_string();
|
|
||||||
let checkpoint = checkpoint.clone();
|
|
||||||
let expected_etag = expected_etag.map(str::to_string);
|
|
||||||
// The storage commit owns detached work. Keep its user-bucket fence alive
|
|
||||||
// even when a caller aborts its waiter before the erasure tail has drained.
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let fence = api.acquire_bucket_incarnation_fence(&bucket, incarnation_id).await?;
|
|
||||||
let mut opts = ObjectOptions::default();
|
|
||||||
fence.attach_to_object_options(&mut opts);
|
|
||||||
let result = write_checkpoint_while_fenced(&api, &bucket, &checkpoint, expected_etag.as_deref(), opts).await;
|
|
||||||
drop(fence);
|
|
||||||
result
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|err| StorageError::other(format!("backfill checkpoint task failed: {err}")))?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The caller holds the destination bucket's lifecycle fence through the CAS
|
|
||||||
/// write and its read-back, including the drained erasure write tail.
|
|
||||||
async fn write_checkpoint_while_fenced(
|
|
||||||
api: &Arc<ECStore>,
|
|
||||||
bucket: &str,
|
|
||||||
checkpoint: &BackfillCheckpoint,
|
|
||||||
expected_etag: Option<&str>,
|
|
||||||
mut opts: ObjectOptions,
|
|
||||||
) -> Result<String, BackfillError> {
|
) -> Result<String, BackfillError> {
|
||||||
let data = checkpoint.to_json()?;
|
let data = checkpoint.to_json()?;
|
||||||
let preconditions = match expected_etag {
|
let preconditions = match expected_etag {
|
||||||
@@ -709,9 +682,12 @@ async fn write_checkpoint_while_fenced(
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
opts.max_parity = true;
|
let opts = ObjectOptions {
|
||||||
opts.write_completion = WriteCompletion::TailDrained;
|
max_parity: true,
|
||||||
opts.http_preconditions = Some(preconditions);
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
|
http_preconditions: Some(preconditions),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
match save_config_with_opts(Arc::clone(api), &checkpoint_path(bucket), data, &opts).await {
|
match save_config_with_opts(Arc::clone(api), &checkpoint_path(bucket), data, &opts).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(StorageError::PreconditionFailed) => return Err(BackfillError::Conflict(bucket.to_string())),
|
Err(StorageError::PreconditionFailed) => return Err(BackfillError::Conflict(bucket.to_string())),
|
||||||
@@ -882,14 +858,7 @@ impl BackfillRunner {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let checkpoint = BackfillCheckpoint::new(&request, config_updated_at, &self.node, now);
|
let checkpoint = BackfillCheckpoint::new(&request, config_updated_at, &self.node, now);
|
||||||
let etag = write_checkpoint(
|
let etag = write_checkpoint(&self.api, bucket, &checkpoint, stored.as_ref().map(|s| s.etag.as_str())).await?;
|
||||||
&self.api,
|
|
||||||
bucket,
|
|
||||||
context.incarnation_id(),
|
|
||||||
&checkpoint,
|
|
||||||
stored.as_ref().map(|s| s.etag.as_str()),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
info!(
|
info!(
|
||||||
event = EVENT_ODM_BACKFILL_STATE,
|
event = EVENT_ODM_BACKFILL_STATE,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -924,16 +893,8 @@ impl BackfillRunner {
|
|||||||
}
|
}
|
||||||
return Ok(handle.snapshot.lock().clone());
|
return Ok(handle.snapshot.lock().clone());
|
||||||
}
|
}
|
||||||
let incarnation_id = self.api.bucket_incarnation_id_from_disk(bucket).await?;
|
let _lock = self.lease_lock(bucket, get_lock_acquire_timeout()).await?;
|
||||||
let lock = self.lease_lock(bucket, get_lock_acquire_timeout()).await?;
|
let Some(stored) = read_checkpoint(&self.api, bucket).await? else {
|
||||||
let api = Arc::clone(&self.api);
|
|
||||||
let bucket = bucket.to_string();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _lock = lock;
|
|
||||||
let fence = api.acquire_bucket_incarnation_fence(&bucket, incarnation_id).await?;
|
|
||||||
let mut opts = ObjectOptions::default();
|
|
||||||
fence.attach_to_object_options(&mut opts);
|
|
||||||
let Some(stored) = read_checkpoint(&api, &bucket).await? else {
|
|
||||||
return Err(BackfillError::NotFound(bucket.to_string()));
|
return Err(BackfillError::NotFound(bucket.to_string()));
|
||||||
};
|
};
|
||||||
if !stored.checkpoint.state.is_active() {
|
if !stored.checkpoint.state.is_active() {
|
||||||
@@ -943,7 +904,7 @@ impl BackfillRunner {
|
|||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
checkpoint.state = BackfillState::Cancelled;
|
checkpoint.state = BackfillState::Cancelled;
|
||||||
checkpoint.updated_at = now;
|
checkpoint.updated_at = now;
|
||||||
write_checkpoint_while_fenced(&api, &bucket, &checkpoint, Some(&stored.etag), opts).await?;
|
write_checkpoint(&self.api, bucket, &checkpoint, Some(&stored.etag)).await?;
|
||||||
info!(
|
info!(
|
||||||
event = EVENT_ODM_BACKFILL_STATE,
|
event = EVENT_ODM_BACKFILL_STATE,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -955,11 +916,7 @@ impl BackfillRunner {
|
|||||||
owner = %checkpoint.owner.as_ref().map(|o| o.node.as_str()).unwrap_or_default(),
|
owner = %checkpoint.owner.as_ref().map(|o| o.node.as_str()).unwrap_or_default(),
|
||||||
"On-demand migration backfill job cancelled remotely"
|
"On-demand migration backfill job cancelled remotely"
|
||||||
);
|
);
|
||||||
drop(fence);
|
|
||||||
Ok(checkpoint)
|
Ok(checkpoint)
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|err| StorageError::other(format!("backfill cancellation task failed: {err}")))?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Latest checkpoint: the in-memory progress of a local job, else the
|
/// Latest checkpoint: the in-memory progress of a local job, else the
|
||||||
@@ -1052,7 +1009,7 @@ impl BackfillRunner {
|
|||||||
checkpoint.state = BackfillState::Cancelled;
|
checkpoint.state = BackfillState::Cancelled;
|
||||||
checkpoint.updated_at = now;
|
checkpoint.updated_at = now;
|
||||||
checkpoint.record_failure("config_changed", None, now);
|
checkpoint.record_failure("config_changed", None, now);
|
||||||
write_checkpoint(&self.api, bucket, context.incarnation_id(), &checkpoint, Some(&stored.etag)).await?;
|
write_checkpoint(&self.api, bucket, &checkpoint, Some(&stored.etag)).await?;
|
||||||
info!(
|
info!(
|
||||||
event = EVENT_ODM_BACKFILL_STATE,
|
event = EVENT_ODM_BACKFILL_STATE,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -1071,7 +1028,7 @@ impl BackfillRunner {
|
|||||||
node: self.node.clone(),
|
node: self.node.clone(),
|
||||||
lease_until: now + BACKFILL_LEASE,
|
lease_until: now + BACKFILL_LEASE,
|
||||||
});
|
});
|
||||||
let etag = write_checkpoint(&self.api, bucket, context.incarnation_id(), &checkpoint, Some(&stored.etag)).await?;
|
let etag = write_checkpoint(&self.api, bucket, &checkpoint, Some(&stored.etag)).await?;
|
||||||
warn!(
|
warn!(
|
||||||
event = EVENT_ODM_BACKFILL_LEASE_TAKEOVER,
|
event = EVENT_ODM_BACKFILL_LEASE_TAKEOVER,
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
@@ -1234,11 +1191,9 @@ impl Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn main_loop(&mut self) -> Result<(), Stop> {
|
async fn main_loop(&mut self) -> Result<(), Stop> {
|
||||||
let mut cursor = self.checkpoint.continuation_token.clone();
|
|
||||||
let failed_at_resume = self.checkpoint.failed;
|
|
||||||
loop {
|
loop {
|
||||||
self.check_cancel()?;
|
self.check_cancel()?;
|
||||||
let page = self.list_page(cursor.as_deref()).await?;
|
let page = self.list_page().await?;
|
||||||
for object in &page.objects {
|
for object in &page.objects {
|
||||||
self.check_cancel()?;
|
self.check_cancel()?;
|
||||||
self.checkpoint.listed += 1;
|
self.checkpoint.listed += 1;
|
||||||
@@ -1250,13 +1205,10 @@ impl Job {
|
|||||||
self.drain_ready();
|
self.drain_ready();
|
||||||
self.tick(false).await?;
|
self.tick(false).await?;
|
||||||
}
|
}
|
||||||
// A persisted cursor certifies successful work, not just listing
|
// Only advance the cursor once every pull of this page reported
|
||||||
// progress. Keep it at the first failed page for crash recovery.
|
// back, so a takeover re-lists at most this page.
|
||||||
self.drain_all().await?;
|
self.drain_all().await?;
|
||||||
cursor = page.next_continuation_token;
|
self.checkpoint.continuation_token = page.next_continuation_token.clone();
|
||||||
if self.checkpoint.failed == failed_at_resume {
|
|
||||||
self.checkpoint.continuation_token = cursor.clone();
|
|
||||||
}
|
|
||||||
self.tick(true).await?;
|
self.tick(true).await?;
|
||||||
if !page.is_truncated {
|
if !page.is_truncated {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1271,7 +1223,7 @@ impl Job {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_page(&mut self, cursor: Option<&str>) -> Result<SourcePage, Stop> {
|
async fn list_page(&mut self) -> Result<SourcePage, Stop> {
|
||||||
let mut attempt = 0;
|
let mut attempt = 0;
|
||||||
loop {
|
loop {
|
||||||
while !self.context.source_available() {
|
while !self.context.source_available() {
|
||||||
@@ -1279,7 +1231,7 @@ impl Job {
|
|||||||
self.tick(false).await?;
|
self.tick(false).await?;
|
||||||
}
|
}
|
||||||
let prefix = self.checkpoint.prefix.clone();
|
let prefix = self.checkpoint.prefix.clone();
|
||||||
let token = cursor.map(str::to_string);
|
let token = self.checkpoint.continuation_token.clone();
|
||||||
match self
|
match self
|
||||||
.context
|
.context
|
||||||
.list_page(prefix.as_deref(), token.as_deref(), BACKFILL_LIST_PAGE_SIZE)
|
.list_page(prefix.as_deref(), token.as_deref(), BACKFILL_LIST_PAGE_SIZE)
|
||||||
@@ -1353,10 +1305,9 @@ impl Job {
|
|||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
match self.context.enqueue(key) {
|
match self.context.enqueue(key) {
|
||||||
(EnqueueOutcome::Enqueued | EnqueueOutcome::Coalesced, report) => {
|
(EnqueueOutcome::Enqueued, report) => {
|
||||||
self.checkpoint.enqueued += 1;
|
self.checkpoint.enqueued += 1;
|
||||||
let rx = report.ok_or(Stop::Unavailable)?;
|
if let Some(rx) = report {
|
||||||
{
|
|
||||||
let key = key.to_string();
|
let key = key.to_string();
|
||||||
self.outstanding.push(Box::pin(async move { (key, rx.await) }));
|
self.outstanding.push(Box::pin(async move { (key, rx.await) }));
|
||||||
}
|
}
|
||||||
@@ -1371,6 +1322,11 @@ impl Job {
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
(EnqueueOutcome::Coalesced, _) => {
|
||||||
|
// Someone else pulls it; its result is not ours to count.
|
||||||
|
self.checkpoint.enqueued += 1;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
(EnqueueOutcome::QueueFull, _) => {
|
(EnqueueOutcome::QueueFull, _) => {
|
||||||
// Wait, never drop: one completion frees a slot.
|
// Wait, never drop: one completion frees a slot.
|
||||||
if self.outstanding.is_empty() {
|
if self.outstanding.is_empty() {
|
||||||
@@ -1510,8 +1466,7 @@ impl Job {
|
|||||||
lease_until: now + BACKFILL_LEASE,
|
lease_until: now + BACKFILL_LEASE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let etag =
|
let etag = write_checkpoint(&self.api, &self.bucket, &self.checkpoint, Some(&self.etag)).await?;
|
||||||
write_checkpoint(&self.api, &self.bucket, self.context.incarnation_id(), &self.checkpoint, Some(&self.etag)).await?;
|
|
||||||
self.etag = etag;
|
self.etag = etag;
|
||||||
self.keys_since_save = 0;
|
self.keys_since_save = 0;
|
||||||
self.last_save = Instant::now();
|
self.last_save = Instant::now();
|
||||||
@@ -1523,7 +1478,7 @@ impl Job {
|
|||||||
/// Spawns [`run_backfill_recovery_loop`] on the store's shutdown token;
|
/// Spawns [`run_backfill_recovery_loop`] on the store's shutdown token;
|
||||||
/// `false` (nothing spawned) when the store has no background token.
|
/// `false` (nothing spawned) when the store has no background token.
|
||||||
pub fn spawn_backfill_recovery_loop(runner: Arc<BackfillRunner>) -> bool {
|
pub fn spawn_backfill_recovery_loop(runner: Arc<BackfillRunner>) -> bool {
|
||||||
let Some(cancel) = runner.api.background_cancel_token() else {
|
let Some(cancel) = runner.api.ctx.background_cancel_token() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
tokio::spawn(run_backfill_recovery_loop(runner, cancel));
|
tokio::spawn(run_backfill_recovery_loop(runner, cancel));
|
||||||
@@ -1551,13 +1506,10 @@ pub async fn run_backfill_recovery_loop(runner: Arc<BackfillRunner>, cancel: Can
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::super::storage_api::test_support::{
|
|
||||||
BUCKET_LIFECYCLE_LOCK_OBJECT, BucketOperations as _, PutObjectCommitBarrier, PutObjectCommitPause,
|
|
||||||
isolated_store_over_temp_disks,
|
|
||||||
};
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::on_demand_migration::source_client::SourceObject;
|
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||||
use crate::on_demand_migration::sys::PullError;
|
use crate::bucket::on_demand_migration::source_client::SourceObject;
|
||||||
|
use crate::bucket::on_demand_migration::sys::PullError;
|
||||||
use std::collections::{BTreeSet, HashSet};
|
use std::collections::{BTreeSet, HashSet};
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
@@ -1680,7 +1632,6 @@ mod tests {
|
|||||||
|
|
||||||
/// Scripted source + local store + queue with a controllable report path.
|
/// Scripted source + local store + queue with a controllable report path.
|
||||||
struct MockContext {
|
struct MockContext {
|
||||||
incarnation_id: Mutex<Option<Uuid>>,
|
|
||||||
objects: Vec<SourceObject>,
|
objects: Vec<SourceObject>,
|
||||||
page_size: usize,
|
page_size: usize,
|
||||||
local: Mutex<HashMap<String, LocalBackfillObject>>,
|
local: Mutex<HashMap<String, LocalBackfillObject>>,
|
||||||
@@ -1689,7 +1640,6 @@ mod tests {
|
|||||||
queue_capacity: usize,
|
queue_capacity: usize,
|
||||||
pending: Mutex<Vec<(String, oneshot::Sender<QueuedPullOutcome>)>>,
|
pending: Mutex<Vec<(String, oneshot::Sender<QueuedPullOutcome>)>>,
|
||||||
fail_keys: HashSet<String>,
|
fail_keys: HashSet<String>,
|
||||||
coalesced: bool,
|
|
||||||
auto_complete: AtomicBool,
|
auto_complete: AtomicBool,
|
||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
config_updated_at: Mutex<Option<OffsetDateTime>>,
|
config_updated_at: Mutex<Option<OffsetDateTime>>,
|
||||||
@@ -1709,7 +1659,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
incarnation_id: Mutex::new(None),
|
|
||||||
objects,
|
objects,
|
||||||
page_size,
|
page_size,
|
||||||
local: Mutex::new(HashMap::new()),
|
local: Mutex::new(HashMap::new()),
|
||||||
@@ -1718,7 +1667,6 @@ mod tests {
|
|||||||
queue_capacity: usize::MAX,
|
queue_capacity: usize::MAX,
|
||||||
pending: Mutex::new(Vec::new()),
|
pending: Mutex::new(Vec::new()),
|
||||||
fail_keys: HashSet::new(),
|
fail_keys: HashSet::new(),
|
||||||
coalesced: false,
|
|
||||||
auto_complete: AtomicBool::new(true),
|
auto_complete: AtomicBool::new(true),
|
||||||
cancel: CancellationToken::new(),
|
cancel: CancellationToken::new(),
|
||||||
config_updated_at: Mutex::new(Some(ts(1_700_000_000))),
|
config_updated_at: Mutex::new(Some(ts(1_700_000_000))),
|
||||||
@@ -1744,10 +1692,6 @@ mod tests {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl BackfillContext for MockContext {
|
impl BackfillContext for MockContext {
|
||||||
fn incarnation_id(&self) -> Uuid {
|
|
||||||
self.incarnation_id.lock().expect("test bucket initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_page(&self, prefix: Option<&str>, token: Option<&str>, max_keys: i32) -> Result<SourcePage, SourceError> {
|
async fn list_page(&self, prefix: Option<&str>, token: Option<&str>, max_keys: i32) -> Result<SourcePage, SourceError> {
|
||||||
if let Some(err) = self.list_error.lock().take() {
|
if let Some(err) = self.list_error.lock().take() {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
@@ -1802,12 +1746,7 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
self.pending.lock().push((key.to_string(), tx));
|
self.pending.lock().push((key.to_string(), tx));
|
||||||
}
|
}
|
||||||
let outcome = if self.coalesced {
|
(EnqueueOutcome::Enqueued, Some(rx))
|
||||||
EnqueueOutcome::Coalesced
|
|
||||||
} else {
|
|
||||||
EnqueueOutcome::Enqueued
|
|
||||||
};
|
|
||||||
(outcome, Some(futures::FutureExt::shared(rx)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_token(&self) -> CancellationToken {
|
fn cancel_token(&self) -> CancellationToken {
|
||||||
@@ -1840,17 +1779,12 @@ mod tests {
|
|||||||
context: Arc<MockContext>,
|
context: Arc<MockContext>,
|
||||||
) -> (Vec<tempfile::TempDir>, Arc<ECStore>, Arc<BackfillRunner>) {
|
) -> (Vec<tempfile::TempDir>, Arc<ECStore>, Arc<BackfillRunner>) {
|
||||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||||
super::super::storage_api::test_support::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
// The isolated store has no bucket metadata system; the checkpoint
|
||||||
store
|
// only needs the bucket's directory under the metadata volume.
|
||||||
.make_bucket(bucket, &Default::default())
|
for dir in &dirs {
|
||||||
.await
|
std::fs::create_dir_all(dir.path().join(RUSTFS_META_BUCKET).join(BUCKET_META_PREFIX).join(bucket))
|
||||||
.expect("create test bucket");
|
.expect("test bucket metadata directory");
|
||||||
*context.incarnation_id.lock() = Some(
|
}
|
||||||
store
|
|
||||||
.bucket_incarnation_id_from_disk(bucket)
|
|
||||||
.await
|
|
||||||
.expect("test bucket identity"),
|
|
||||||
);
|
|
||||||
let runner = runner_on(node, bucket, context, Arc::clone(&store));
|
let runner = runner_on(node, bucket, context, Arc::clone(&store));
|
||||||
(dirs, store, runner)
|
(dirs, store, runner)
|
||||||
}
|
}
|
||||||
@@ -1860,129 +1794,6 @@ mod tests {
|
|||||||
BackfillRunner::new(store, node, Arc::new(contexts))
|
BackfillRunner::new(store, node, Arc::new(contexts))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cancelled_checkpoint_waiter_keeps_bucket_fenced_until_commit_finishes() {
|
|
||||||
for (suffix, pause) in [
|
|
||||||
("before", PutObjectCommitPause::BeforeQuotaRename),
|
|
||||||
("after", PutObjectCommitPause::AfterRenameQuorum),
|
|
||||||
] {
|
|
||||||
let bucket = format!("backfill-cancel-tail-{suffix}");
|
|
||||||
let context = MockContext::new(0, 1);
|
|
||||||
let (_dirs, store, _runner) = runner_with("node-a", &bucket, Arc::clone(&context)).await;
|
|
||||||
let original_incarnation = context.incarnation_id();
|
|
||||||
let checkpoint = BackfillCheckpoint::new(&BackfillRequest::default(), ts(1_700_000_000), "node-a", ts(1_700_000_001));
|
|
||||||
let barrier = PutObjectCommitBarrier::install(RUSTFS_META_BUCKET, &checkpoint_path(&bucket), pause);
|
|
||||||
let writer_api = Arc::clone(&store);
|
|
||||||
let writer_bucket = bucket.clone();
|
|
||||||
let waiter = tokio::spawn(async move {
|
|
||||||
write_checkpoint(&writer_api, &writer_bucket, original_incarnation, &checkpoint, None).await
|
|
||||||
});
|
|
||||||
barrier.wait_until_paused().await;
|
|
||||||
waiter.abort();
|
|
||||||
assert!(waiter.await.expect_err("caller aborted").is_cancelled());
|
|
||||||
|
|
||||||
let lifecycle_lock = store
|
|
||||||
.new_ns_lock(&bucket, BUCKET_LIFECYCLE_LOCK_OBJECT)
|
|
||||||
.await
|
|
||||||
.expect("lifecycle lock");
|
|
||||||
{
|
|
||||||
let mut probe = Box::pin(lifecycle_lock.get_write_lock(Duration::from_secs(1)));
|
|
||||||
assert!(
|
|
||||||
futures::poll!(probe.as_mut()).is_pending(),
|
|
||||||
"lifecycle writer must first try to acquire the lock"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
tokio::time::timeout(Duration::from_millis(100), probe.as_mut())
|
|
||||||
.await
|
|
||||||
.is_err(),
|
|
||||||
"the checkpoint owner must retain the user bucket lifecycle read lock after caller cancellation"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let delete_api = Arc::clone(&store);
|
|
||||||
let delete_bucket = bucket.clone();
|
|
||||||
let mut deletion = tokio::spawn(async move { delete_api.delete_bucket(&delete_bucket, &Default::default()).await });
|
|
||||||
assert!(
|
|
||||||
tokio::time::timeout(Duration::from_millis(100), &mut deletion).await.is_err(),
|
|
||||||
"DeleteBucket must wait for the checkpoint owner after its caller aborts"
|
|
||||||
);
|
|
||||||
barrier.release();
|
|
||||||
tokio::time::timeout(Duration::from_secs(10), deletion)
|
|
||||||
.await
|
|
||||||
.expect("commit must drain and release its lifecycle guard")
|
|
||||||
.expect("delete task")
|
|
||||||
.expect("delete original bucket");
|
|
||||||
store
|
|
||||||
.make_bucket(&bucket, &Default::default())
|
|
||||||
.await
|
|
||||||
.expect("recreate bucket");
|
|
||||||
assert_ne!(
|
|
||||||
original_incarnation,
|
|
||||||
store.bucket_incarnation_id_from_disk(&bucket).await.expect("new identity")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
read_checkpoint(&store, &bucket)
|
|
||||||
.await
|
|
||||||
.expect("read recreated bucket")
|
|
||||||
.is_none(),
|
|
||||||
"no old checkpoint may outlive bucket deletion"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn stale_checkpoint_writer_cannot_resurrect_or_overwrite_a_recreated_bucket() {
|
|
||||||
let bucket = "backfill-incarnation";
|
|
||||||
let context = MockContext::new(0, 1);
|
|
||||||
let (_dirs, store, _runner) = runner_with("node-a", bucket, Arc::clone(&context)).await;
|
|
||||||
let old_incarnation = context.incarnation_id();
|
|
||||||
let old = BackfillCheckpoint::new(&BackfillRequest::default(), ts(1_700_000_000), "node-a", ts(1_700_000_001));
|
|
||||||
let old_etag = write_checkpoint(&store, bucket, old_incarnation, &old, None)
|
|
||||||
.await
|
|
||||||
.expect("old checkpoint");
|
|
||||||
|
|
||||||
store
|
|
||||||
.delete_bucket(bucket, &Default::default())
|
|
||||||
.await
|
|
||||||
.expect("delete original bucket");
|
|
||||||
store.make_bucket(bucket, &Default::default()).await.expect("recreate bucket");
|
|
||||||
let current_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.expect("new identity");
|
|
||||||
assert_ne!(old_incarnation, current_incarnation);
|
|
||||||
assert!(
|
|
||||||
read_checkpoint(&store, bucket)
|
|
||||||
.await
|
|
||||||
.expect("read after recreation")
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
for expected_etag in [None, Some(old_etag.as_str())] {
|
|
||||||
let error = write_checkpoint(&store, bucket, old_incarnation, &old, expected_etag)
|
|
||||||
.await
|
|
||||||
.expect_err("stale writer rejected");
|
|
||||||
assert!(matches!(error, BackfillError::Storage(StorageError::BucketNotFound(_))));
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
read_checkpoint(&store, bucket)
|
|
||||||
.await
|
|
||||||
.expect("stale writer left no checkpoint")
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
|
|
||||||
let current = BackfillCheckpoint::new(&BackfillRequest::default(), ts(1_700_000_000), "node-b", ts(1_700_000_002));
|
|
||||||
let current_etag = write_checkpoint(&store, bucket, current_incarnation, ¤t, None)
|
|
||||||
.await
|
|
||||||
.expect("current checkpoint");
|
|
||||||
let error = write_checkpoint(&store, bucket, old_incarnation, &old, Some(¤t_etag))
|
|
||||||
.await
|
|
||||||
.expect_err("old identity cannot overwrite a matching ETag");
|
|
||||||
assert!(matches!(error, BackfillError::Storage(StorageError::BucketNotFound(_))));
|
|
||||||
let stored = read_checkpoint(&store, bucket)
|
|
||||||
.await
|
|
||||||
.expect("read current checkpoint")
|
|
||||||
.expect("current checkpoint remains");
|
|
||||||
assert_eq!(stored.etag, current_etag);
|
|
||||||
assert_eq!(stored.checkpoint, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn full_backfill_lists_pages_and_counts_every_key() {
|
async fn full_backfill_lists_pages_and_counts_every_key() {
|
||||||
let bucket = "backfill-full";
|
let bucket = "backfill-full";
|
||||||
@@ -2101,7 +1912,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn failed_pulls_are_counted_hashed_and_finish_with_failures() {
|
async fn failed_pulls_are_counted_hashed_and_finish_with_failures() {
|
||||||
let bucket = "backfill-failed";
|
let bucket = "backfill-failed";
|
||||||
let mut context = MockContext::new(5, 2);
|
let mut context = MockContext::new(5, 1000);
|
||||||
Arc::get_mut(&mut context)
|
Arc::get_mut(&mut context)
|
||||||
.expect("unshared")
|
.expect("unshared")
|
||||||
.fail_keys
|
.fail_keys
|
||||||
@@ -2116,52 +1927,12 @@ mod tests {
|
|||||||
.checkpoint;
|
.checkpoint;
|
||||||
assert_eq!(cp.state, BackfillState::CompletedWithFailures);
|
assert_eq!(cp.state, BackfillState::CompletedWithFailures);
|
||||||
assert_eq!((cp.pulled, cp.failed), (4, 1));
|
assert_eq!((cp.pulled, cp.failed), (4, 1));
|
||||||
assert_eq!(cp.continuation_token.as_deref(), Some("2"), "retain the first failed page for recovery");
|
|
||||||
assert_eq!(cp.failed_keys, vec![key_hash("k/00002")]);
|
assert_eq!(cp.failed_keys, vec![key_hash("k/00002")]);
|
||||||
let last = cp.last_error.expect("last error");
|
let last = cp.last_error.expect("last error");
|
||||||
assert_eq!(last.class, "local_write");
|
assert_eq!(last.class, "local_write");
|
||||||
assert_eq!(last.key_hash.as_deref(), Some(key_hash("k/00002").as_str()));
|
assert_eq!(last.key_hash.as_deref(), Some(key_hash("k/00002").as_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn coalesced_pulls_block_the_checkpoint_and_report_failures() {
|
|
||||||
let bucket = "backfill-coalesced";
|
|
||||||
let mut context = MockContext::new(1, 1);
|
|
||||||
{
|
|
||||||
let ctx = Arc::get_mut(&mut context).expect("unshared");
|
|
||||||
ctx.coalesced = true;
|
|
||||||
ctx.auto_complete = AtomicBool::new(false);
|
|
||||||
ctx.fail_keys.insert("k/00000".to_string());
|
|
||||||
}
|
|
||||||
let (_dirs, store, runner) = runner_with("node-a", bucket, Arc::clone(&context)).await;
|
|
||||||
runner.start(bucket, BackfillRequest::default()).await.expect("start");
|
|
||||||
tokio::time::timeout(Duration::from_secs(10), async {
|
|
||||||
while context.pending.lock().is_empty() {
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("job enqueued");
|
|
||||||
assert!(runner.is_running_locally(bucket), "coalescing is not completion");
|
|
||||||
let cp = read_checkpoint(&store, bucket)
|
|
||||||
.await
|
|
||||||
.expect("read")
|
|
||||||
.expect("checkpoint")
|
|
||||||
.checkpoint;
|
|
||||||
assert!(cp.state.is_active());
|
|
||||||
assert!(cp.continuation_token.is_none());
|
|
||||||
context.complete_pending();
|
|
||||||
runner.wait_until_idle(bucket).await;
|
|
||||||
let cp = read_checkpoint(&store, bucket)
|
|
||||||
.await
|
|
||||||
.expect("read")
|
|
||||||
.expect("checkpoint")
|
|
||||||
.checkpoint;
|
|
||||||
assert_eq!(cp.state, BackfillState::CompletedWithFailures);
|
|
||||||
assert_eq!((cp.enqueued, cp.pulled, cp.failed), (1, 0, 1));
|
|
||||||
assert_eq!(cp.failed_keys, vec![key_hash("k/00000")]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn listing_failure_marks_the_job_failed_with_the_error_class() {
|
async fn listing_failure_marks_the_job_failed_with_the_error_class() {
|
||||||
let bucket = "backfill-list-error";
|
let bucket = "backfill-list-error";
|
||||||
@@ -2322,7 +2093,7 @@ mod tests {
|
|||||||
node: "node-a".to_string(),
|
node: "node-a".to_string(),
|
||||||
lease_until: now - Duration::from_secs(120),
|
lease_until: now - Duration::from_secs(120),
|
||||||
});
|
});
|
||||||
let etag = write_checkpoint(&store, bucket, context.incarnation_id(), &crashed, None)
|
let etag = write_checkpoint(&store, bucket, &crashed, None)
|
||||||
.await
|
.await
|
||||||
.expect("seed checkpoint");
|
.expect("seed checkpoint");
|
||||||
|
|
||||||
@@ -2333,7 +2104,7 @@ mod tests {
|
|||||||
lease_until: now + Duration::from_secs(60),
|
lease_until: now + Duration::from_secs(60),
|
||||||
});
|
});
|
||||||
live.updated_at = now;
|
live.updated_at = now;
|
||||||
let etag = write_checkpoint(&store, bucket, context.incarnation_id(), &live, Some(&etag))
|
let etag = write_checkpoint(&store, bucket, &live, Some(&etag))
|
||||||
.await
|
.await
|
||||||
.expect("live lease");
|
.expect("live lease");
|
||||||
assert_eq!(runner.recover_once().await.taken_over, 0, "unexpired lease must not be taken over");
|
assert_eq!(runner.recover_once().await.taken_over, 0, "unexpired lease must not be taken over");
|
||||||
@@ -2350,7 +2121,7 @@ mod tests {
|
|||||||
lease_until: now - Duration::from_secs(1),
|
lease_until: now - Duration::from_secs(1),
|
||||||
});
|
});
|
||||||
expired.updated_at = now + Duration::from_millis(1);
|
expired.updated_at = now + Duration::from_millis(1);
|
||||||
write_checkpoint(&store, bucket, context.incarnation_id(), &expired, Some(&etag))
|
write_checkpoint(&store, bucket, &expired, Some(&etag))
|
||||||
.await
|
.await
|
||||||
.expect("expire lease");
|
.expect("expire lease");
|
||||||
let stats = runner.recover_once().await;
|
let stats = runner.recover_once().await;
|
||||||
@@ -2374,68 +2145,6 @@ mod tests {
|
|||||||
assert_eq!(runner.recover_once().await.taken_over, 0, "a finished job is not recovered");
|
assert_eq!(runner.recover_once().await.taken_over, 0, "a finished job is not recovered");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn recovery_advances_past_historical_failures_but_pins_new_failures() {
|
|
||||||
let bucket = "backfill-takeover-failed";
|
|
||||||
let mut context = MockContext::new(8, 2);
|
|
||||||
{
|
|
||||||
let ctx = Arc::get_mut(&mut context).expect("unshared");
|
|
||||||
ctx.auto_complete = AtomicBool::new(false);
|
|
||||||
ctx.fail_keys.insert("k/00004".to_string());
|
|
||||||
}
|
|
||||||
let (_dirs, store, runner) = runner_with("node-b", bucket, Arc::clone(&context)).await;
|
|
||||||
let crashed_at = OffsetDateTime::now_utc() - Duration::from_secs(300);
|
|
||||||
let mut crashed = BackfillCheckpoint::new(&BackfillRequest::default(), ts(1_700_000_000), "node-a", crashed_at);
|
|
||||||
crashed.continuation_token = Some("2".to_string());
|
|
||||||
crashed.failed = 1;
|
|
||||||
crashed.record_failure("local_write", Some("k/00002"), crashed_at);
|
|
||||||
write_checkpoint(&store, bucket, context.incarnation_id(), &crashed, None)
|
|
||||||
.await
|
|
||||||
.expect("seed failed page with an expired lease");
|
|
||||||
|
|
||||||
assert_eq!(runner.recover_once().await.taken_over, 1);
|
|
||||||
for (page_start, durable_token, failures) in [(2, "2", 1), (4, "4", 1), (6, "4", 2)] {
|
|
||||||
tokio::time::timeout(Duration::from_secs(10), async {
|
|
||||||
loop {
|
|
||||||
if context.pending.lock().len() == 2 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("resumed page enqueued before its reports complete");
|
|
||||||
assert_eq!(
|
|
||||||
context.pending.lock().iter().map(|(key, _)| key.clone()).collect::<Vec<_>>(),
|
|
||||||
vec![format!("k/{page_start:05}"), format!("k/{:05}", page_start + 1)]
|
|
||||||
);
|
|
||||||
let cp = read_checkpoint(&store, bucket)
|
|
||||||
.await
|
|
||||||
.expect("read persisted page boundary")
|
|
||||||
.expect("checkpoint")
|
|
||||||
.checkpoint;
|
|
||||||
assert_eq!(cp.job_id, crashed.job_id);
|
|
||||||
assert_eq!(cp.owner.as_ref().map(|owner| owner.node.as_str()), Some("node-b"));
|
|
||||||
assert_eq!(cp.continuation_token.as_deref(), Some(durable_token));
|
|
||||||
assert_eq!(cp.failed, failures);
|
|
||||||
context.complete_pending();
|
|
||||||
}
|
|
||||||
runner.wait_until_idle(bucket).await;
|
|
||||||
let cp = read_checkpoint(&store, bucket)
|
|
||||||
.await
|
|
||||||
.expect("read completed checkpoint")
|
|
||||||
.expect("checkpoint")
|
|
||||||
.checkpoint;
|
|
||||||
assert_eq!(cp.state, BackfillState::CompletedWithFailures);
|
|
||||||
assert_eq!((cp.pulled, cp.failed), (5, 2));
|
|
||||||
assert_eq!(cp.continuation_token.as_deref(), Some("4"));
|
|
||||||
assert_eq!(cp.failed_keys, vec![key_hash("k/00002"), key_hash("k/00004")]);
|
|
||||||
assert_eq!(
|
|
||||||
context.list_requests.lock().as_slice(),
|
|
||||||
&[Some("2".to_string()), Some("4".to_string()), Some("6".to_string())]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn recovery_cancels_a_job_whose_config_changed_and_reclaims_own_node_jobs() {
|
async fn recovery_cancels_a_job_whose_config_changed_and_reclaims_own_node_jobs() {
|
||||||
let bucket = "backfill-recovery-config";
|
let bucket = "backfill-recovery-config";
|
||||||
@@ -2445,9 +2154,7 @@ mod tests {
|
|||||||
|
|
||||||
// Same node name, unexpired lease: only a restart can produce this.
|
// Same node name, unexpired lease: only a restart can produce this.
|
||||||
let own = BackfillCheckpoint::new(&BackfillRequest::default(), ts(1_700_000_000), "node-a", now);
|
let own = BackfillCheckpoint::new(&BackfillRequest::default(), ts(1_700_000_000), "node-a", now);
|
||||||
let etag = write_checkpoint(&store, bucket, context.incarnation_id(), &own, None)
|
let etag = write_checkpoint(&store, bucket, &own, None).await.expect("seed");
|
||||||
.await
|
|
||||||
.expect("seed");
|
|
||||||
assert_eq!(runner.recover_once().await.taken_over, 1, "own-node running job is reclaimed at once");
|
assert_eq!(runner.recover_once().await.taken_over, 1, "own-node running job is reclaimed at once");
|
||||||
runner.wait_until_idle(bucket).await;
|
runner.wait_until_idle(bucket).await;
|
||||||
let cp = read_checkpoint(&store, bucket)
|
let cp = read_checkpoint(&store, bucket)
|
||||||
@@ -2465,7 +2172,7 @@ mod tests {
|
|||||||
node: "node-z".to_string(),
|
node: "node-z".to_string(),
|
||||||
lease_until: now - Duration::from_secs(1),
|
lease_until: now - Duration::from_secs(1),
|
||||||
});
|
});
|
||||||
write_checkpoint(&store, bucket, context.incarnation_id(), &stale, Some(&stored.etag))
|
write_checkpoint(&store, bucket, &stale, Some(&stored.etag))
|
||||||
.await
|
.await
|
||||||
.expect("seed stale");
|
.expect("seed stale");
|
||||||
let stats = runner.recover_once().await;
|
let stats = runner.recover_once().await;
|
||||||
+17
-515
@@ -14,44 +14,22 @@
|
|||||||
|
|
||||||
//! Bucket-level On-Demand Migration configuration: wire model (JSON stored
|
//! Bucket-level On-Demand Migration configuration: wire model (JSON stored
|
||||||
//! under `on-demand-migration.json`), pure validation, credential redaction,
|
//! under `on-demand-migration.json`), pure validation, credential redaction,
|
||||||
//! and persisted-config decoding (rustfs/backlog#2148).
|
//! and the publish hook the runtime registers into (rustfs/backlog#2148).
|
||||||
//!
|
//!
|
||||||
//! The persisted blob is not encrypted; it shares the trust boundary of
|
//! The persisted blob is not encrypted; it shares the trust boundary of
|
||||||
//! `bucket-targets.json` and `tier-config.bin`.
|
//! `bucket-targets.json` and `tier-config.bin`.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::sync::OnceLock;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
/// Decode bytes only at the service boundary, preserving typed corruption errors.
|
|
||||||
pub(super) fn decode_stored_config(
|
|
||||||
stored: Option<(Vec<u8>, time::OffsetDateTime)>,
|
|
||||||
) -> Result<Option<(OnDemandMigrationConfig, time::OffsetDateTime)>, super::storage_api::StorageError> {
|
|
||||||
stored
|
|
||||||
.map(|(bytes, updated_at)| {
|
|
||||||
OnDemandMigrationConfig::from_json(&bytes)
|
|
||||||
.map(|config| (config, updated_at))
|
|
||||||
.map_err(super::storage_api::StorageError::other)
|
|
||||||
})
|
|
||||||
.transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn get_config(
|
|
||||||
bucket: &str,
|
|
||||||
) -> Result<Option<(OnDemandMigrationConfig, time::OffsetDateTime)>, super::storage_api::StorageError> {
|
|
||||||
decode_stored_config(super::storage_api::get_on_demand_migration_config(bucket).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The only wire version this build reads and writes.
|
/// The only wire version this build reads and writes.
|
||||||
pub const ON_DEMAND_MIGRATION_CONFIG_VERSION: u32 = 1;
|
pub const ON_DEMAND_MIGRATION_CONFIG_VERSION: u32 = 1;
|
||||||
|
|
||||||
const REDACTED: &str = "REDACTED";
|
const REDACTED: &str = "REDACTED";
|
||||||
const AUTO_REGION: &str = "auto";
|
const AUTO_REGION: &str = "auto";
|
||||||
const AUTO_REGION_FALLBACK: &str = "us-east-1";
|
const AUTO_REGION_FALLBACK: &str = "us-east-1";
|
||||||
/// Public Azure Blob host suffix; the account name is the first label.
|
|
||||||
pub const AZURE_BLOB_SUFFIX: &str = "blob.core.windows.net";
|
|
||||||
/// Public Google Cloud Storage endpoint for the native provider.
|
|
||||||
pub const GCS_DEFAULT_ENDPOINT: &str = "https://storage.googleapis.com";
|
|
||||||
|
|
||||||
const KIB: u64 = 1024;
|
const KIB: u64 = 1024;
|
||||||
const MIB: u64 = 1024 * KIB;
|
const MIB: u64 = 1024 * KIB;
|
||||||
@@ -97,25 +75,14 @@ pub struct SourceConfig {
|
|||||||
pub bucket: String,
|
pub bucket: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub path_style: PathStyle,
|
pub path_style: PathStyle,
|
||||||
/// `None` means anonymous access to a public source bucket. Only the
|
/// `None` means anonymous access to a public source bucket.
|
||||||
/// SigV4 providers read it; `azure` and `gcs_native` carry their own
|
|
||||||
/// credentials in `azure` / `gcs`.
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub credentials: Option<SourceCredentials>,
|
pub credentials: Option<SourceCredentials>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tls: TlsConfig,
|
pub tls: TlsConfig,
|
||||||
/// Required for [`Provider::Azure`] and rejected for every other
|
|
||||||
/// provider.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub azure: Option<AzureSourceConfig>,
|
|
||||||
/// Required for [`Provider::GcsNative`] and rejected for every other
|
|
||||||
/// provider. [`Provider::Gcs`] keeps using `credentials` because it
|
|
||||||
/// speaks the S3 interoperability API.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub gcs: Option<GcsSourceConfig>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Source vendor family.
|
/// Source vendor family. `azure` is deliberately absent from this version.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Provider {
|
pub enum Provider {
|
||||||
@@ -127,12 +94,6 @@ pub enum Provider {
|
|||||||
R2,
|
R2,
|
||||||
/// GCS XML interoperability API with HMAC keys.
|
/// GCS XML interoperability API with HMAC keys.
|
||||||
Gcs,
|
Gcs,
|
||||||
/// Native Azure Blob service; parameters in `source.azure`.
|
|
||||||
Azure,
|
|
||||||
/// Native GCS JSON API with a service-account key; parameters in
|
|
||||||
/// `source.gcs`.
|
|
||||||
#[serde(rename = "gcs_native")]
|
|
||||||
GcsNative,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Provider {
|
impl Provider {
|
||||||
@@ -144,22 +105,13 @@ impl Provider {
|
|||||||
Provider::Rustfs => "rustfs",
|
Provider::Rustfs => "rustfs",
|
||||||
Provider::R2 => "r2",
|
Provider::R2 => "r2",
|
||||||
Provider::Gcs => "gcs",
|
Provider::Gcs => "gcs",
|
||||||
Provider::Azure => "azure",
|
|
||||||
Provider::GcsNative => "gcs_native",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Providers that do not speak S3 and therefore ignore `region`,
|
|
||||||
/// `path_style` and `credentials`.
|
|
||||||
pub fn is_native(&self) -> bool {
|
|
||||||
matches!(self, Provider::Azure | Provider::GcsNative)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Providers whose SDKs accept `region = "auto"`; RustFS maps it to
|
/// Providers whose SDKs accept `region = "auto"`; RustFS maps it to
|
||||||
/// `us-east-1` for signing. The native providers never sign with a
|
/// `us-east-1` for signing.
|
||||||
/// region, so they accept it as well.
|
|
||||||
fn accepts_auto_region(&self) -> bool {
|
fn accepts_auto_region(&self) -> bool {
|
||||||
matches!(self, Provider::R2 | Provider::Minio | Provider::Rustfs) || self.is_native()
|
matches!(self, Provider::R2 | Provider::Minio | Provider::Rustfs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,73 +164,6 @@ impl fmt::Debug for SourceCredentials {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Native Azure Blob source parameters. The container is `source.bucket`,
|
|
||||||
/// so a config never carries two names for the same container. Exactly one
|
|
||||||
/// of `account_key` and `sas_token` must be set: the account key signs with
|
|
||||||
/// Shared Key, the SAS token is appended to every request URL.
|
|
||||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct AzureSourceConfig {
|
|
||||||
/// Storage account name; also derives the default `blob.core.windows.net`
|
|
||||||
/// endpoint when `source.endpoint` is absent.
|
|
||||||
pub account: String,
|
|
||||||
/// Base64 shared key of the storage account.
|
|
||||||
#[serde(default)]
|
|
||||||
pub account_key: Option<String>,
|
|
||||||
/// SAS query string without the leading `?`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub sas_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AzureSourceConfig {
|
|
||||||
/// A copy safe to return to admin clients or log: both secrets are
|
|
||||||
/// replaced by `REDACTED`, and whether each is set stays visible.
|
|
||||||
pub fn redacted(&self) -> Self {
|
|
||||||
Self {
|
|
||||||
account: self.account.clone(),
|
|
||||||
account_key: self.account_key.as_ref().map(|_| REDACTED.to_string()),
|
|
||||||
sas_token: self.sas_token.as_ref().map(|_| REDACTED.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for AzureSourceConfig {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("AzureSourceConfig")
|
|
||||||
.field("account", &self.account)
|
|
||||||
.field("account_key", &self.account_key.as_ref().map(|_| REDACTED))
|
|
||||||
.field("sas_token", &self.sas_token.as_ref().map(|_| REDACTED))
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Native Google Cloud Storage source parameters. The bucket is
|
|
||||||
/// `source.bucket`; only the service-account key lives here.
|
|
||||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct GcsSourceConfig {
|
|
||||||
/// Service-account key JSON, verbatim as downloaded from Google Cloud.
|
|
||||||
pub service_account_json: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GcsSourceConfig {
|
|
||||||
/// A copy safe to return to admin clients or log: the whole key JSON is
|
|
||||||
/// a secret (it embeds the private key), so it is replaced wholesale.
|
|
||||||
pub fn redacted(&self) -> Self {
|
|
||||||
Self {
|
|
||||||
service_account_json: REDACTED.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for GcsSourceConfig {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("GcsSourceConfig")
|
|
||||||
.field("service_account_json", &REDACTED)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct TlsConfig {
|
pub struct TlsConfig {
|
||||||
@@ -469,14 +354,6 @@ pub enum OnDemandMigrationConfigError {
|
|||||||
InvalidBucket(&'static str),
|
InvalidBucket(&'static str),
|
||||||
#[error("source credentials field {0} must not be empty")]
|
#[error("source credentials field {0} must not be empty")]
|
||||||
EmptyCredential(&'static str),
|
EmptyCredential(&'static str),
|
||||||
#[error("source.{0} is required for provider {1}")]
|
|
||||||
MissingProviderBlock(&'static str, Provider),
|
|
||||||
#[error("source.{0} is not valid for provider {1}")]
|
|
||||||
UnexpectedProviderBlock(&'static str, Provider),
|
|
||||||
/// Carries only the reason: the block holds account keys, SAS tokens and
|
|
||||||
/// service-account JSON, so no value of it is ever echoed.
|
|
||||||
#[error("source.{0} is invalid: {1}")]
|
|
||||||
InvalidProviderBlock(&'static str, &'static str),
|
|
||||||
#[error("source tls.ca_cert_pem is not a PEM certificate")]
|
#[error("source tls.ca_cert_pem is not a PEM certificate")]
|
||||||
InvalidCaCert,
|
InvalidCaCert,
|
||||||
#[error("filter.{0} must be null or a non-empty string")]
|
#[error("filter.{0} must be null or a non-empty string")]
|
||||||
@@ -511,8 +388,6 @@ impl OnDemandMigrationConfig {
|
|||||||
pub fn redacted(&self) -> Self {
|
pub fn redacted(&self) -> Self {
|
||||||
let mut copy = self.clone();
|
let mut copy = self.clone();
|
||||||
copy.source.credentials = self.source.credentials.as_ref().map(SourceCredentials::redacted);
|
copy.source.credentials = self.source.credentials.as_ref().map(SourceCredentials::redacted);
|
||||||
copy.source.azure = self.source.azure.as_ref().map(AzureSourceConfig::redacted);
|
|
||||||
copy.source.gcs = self.source.gcs.as_ref().map(GcsSourceConfig::redacted);
|
|
||||||
copy
|
copy
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,12 +433,6 @@ impl SourceConfig {
|
|||||||
match (&self.endpoint, self.provider) {
|
match (&self.endpoint, self.provider) {
|
||||||
(Some(endpoint), _) => endpoint.clone(),
|
(Some(endpoint), _) => endpoint.clone(),
|
||||||
(None, Provider::Aws) => format!("https://s3.{}.amazonaws.com", self.region),
|
(None, Provider::Aws) => format!("https://s3.{}.amazonaws.com", self.region),
|
||||||
(None, Provider::Azure) => self
|
|
||||||
.azure
|
|
||||||
.as_ref()
|
|
||||||
.map(|azure| format!("https://{}.{AZURE_BLOB_SUFFIX}", azure.account))
|
|
||||||
.unwrap_or_default(),
|
|
||||||
(None, Provider::GcsNative) => GCS_DEFAULT_ENDPOINT.to_string(),
|
|
||||||
(None, _) => String::new(),
|
(None, _) => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -579,8 +448,6 @@ impl SourceConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), OnDemandMigrationConfigError> {
|
fn validate(&self) -> Result<(), OnDemandMigrationConfigError> {
|
||||||
self.validate_provider_block()?;
|
|
||||||
|
|
||||||
if self.region.is_empty() {
|
if self.region.is_empty() {
|
||||||
return Err(OnDemandMigrationConfigError::EmptyRegion);
|
return Err(OnDemandMigrationConfigError::EmptyRegion);
|
||||||
}
|
}
|
||||||
@@ -599,9 +466,6 @@ impl SourceConfig {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Both native providers derive a fixed endpoint; Azure's is built
|
|
||||||
// from the account name, already checked by `validate_provider_block`.
|
|
||||||
None if self.provider.is_native() => {}
|
|
||||||
None => return Err(OnDemandMigrationConfigError::MissingEndpoint(self.provider)),
|
None => return Err(OnDemandMigrationConfigError::MissingEndpoint(self.provider)),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,84 +496,6 @@ impl SourceConfig {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The provider-specific block must be present for exactly its own
|
|
||||||
/// provider: a stray `azure` block on an `s3` source would otherwise be
|
|
||||||
/// accepted, stored, and silently ignored by the client builder.
|
|
||||||
fn validate_provider_block(&self) -> Result<(), OnDemandMigrationConfigError> {
|
|
||||||
let missing = OnDemandMigrationConfigError::MissingProviderBlock;
|
|
||||||
let unexpected = OnDemandMigrationConfigError::UnexpectedProviderBlock;
|
|
||||||
let invalid = OnDemandMigrationConfigError::InvalidProviderBlock;
|
|
||||||
|
|
||||||
if self.provider != Provider::Azure && self.azure.is_some() {
|
|
||||||
return Err(unexpected("azure", self.provider));
|
|
||||||
}
|
|
||||||
if self.provider != Provider::GcsNative && self.gcs.is_some() {
|
|
||||||
return Err(unexpected("gcs", self.provider));
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.provider {
|
|
||||||
Provider::Azure => {
|
|
||||||
let azure = self.azure.as_ref().ok_or(missing("azure", self.provider))?;
|
|
||||||
if azure.account.is_empty() {
|
|
||||||
return Err(invalid("azure", "account must not be empty"));
|
|
||||||
}
|
|
||||||
// The account feeds a hostname when the endpoint is derived:
|
|
||||||
// keep it to label characters so it cannot rewrite the host.
|
|
||||||
if !azure.account.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
|
|
||||||
return Err(invalid("azure", "account contains characters outside [A-Za-z0-9-]"));
|
|
||||||
}
|
|
||||||
match (azure.account_key.as_deref(), azure.sas_token.as_deref()) {
|
|
||||||
(Some(_), Some(_)) => return Err(invalid("azure", "account_key and sas_token are mutually exclusive")),
|
|
||||||
(None, None) => return Err(invalid("azure", "one of account_key and sas_token is required")),
|
|
||||||
(Some(key), None) => {
|
|
||||||
if key.is_empty() {
|
|
||||||
return Err(invalid("azure", "account_key must not be empty"));
|
|
||||||
}
|
|
||||||
// Decoded here so a mistyped key fails at the admin
|
|
||||||
// boundary instead of on the first source request.
|
|
||||||
if base64_simd::STANDARD.decode_to_vec(key.as_bytes()).is_err() {
|
|
||||||
return Err(invalid("azure", "account_key is not base64"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, Some(sas)) => {
|
|
||||||
if sas.is_empty() {
|
|
||||||
return Err(invalid("azure", "sas_token must not be empty"));
|
|
||||||
}
|
|
||||||
if sas.starts_with('?') {
|
|
||||||
return Err(invalid("azure", "sas_token must not start with '?'"));
|
|
||||||
}
|
|
||||||
if sas.chars().any(char::is_whitespace) {
|
|
||||||
return Err(invalid("azure", "sas_token must not contain whitespace"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Provider::GcsNative => {
|
|
||||||
let gcs = self.gcs.as_ref().ok_or(missing("gcs", self.provider))?;
|
|
||||||
let key: serde_json::Value = serde_json::from_str(&gcs.service_account_json)
|
|
||||||
.map_err(|_| invalid("gcs", "service_account_json is not valid JSON"))?;
|
|
||||||
let Some(object) = key.as_object() else {
|
|
||||||
return Err(invalid("gcs", "service_account_json is not a JSON object"));
|
|
||||||
};
|
|
||||||
if object.get("type").and_then(serde_json::Value::as_str) != Some("service_account") {
|
|
||||||
return Err(invalid("gcs", "service_account_json is not a service_account key"));
|
|
||||||
}
|
|
||||||
for field in ["client_email", "private_key"] {
|
|
||||||
if object
|
|
||||||
.get(field)
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.is_none_or(str::is_empty)
|
|
||||||
{
|
|
||||||
return Err(invalid("gcs", "service_account_json is missing client_email or private_key"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Provider::S3 | Provider::Aws | Provider::Minio | Provider::Rustfs | Provider::R2 | Provider::Gcs => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_endpoint(endpoint: &str) -> Result<(), OnDemandMigrationConfigError> {
|
fn validate_endpoint(endpoint: &str) -> Result<(), OnDemandMigrationConfigError> {
|
||||||
@@ -804,14 +590,20 @@ impl EndpointKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signature of the runtime publish hook: called with the bucket name and
|
||||||
|
/// its parsed config (`None` when absent, cleared, or unreadable) every time
|
||||||
|
/// the bucket's metadata is installed into or removed from the cache.
|
||||||
|
pub type ConfigPublishHook = Box<dyn Fn(&str, Option<&OnDemandMigrationConfig>) + Send + Sync>;
|
||||||
|
|
||||||
|
/// Registration point for the runtime (`OnDemandMigrationSys`). Until it is
|
||||||
|
/// set, metadata publishes are no-ops for ODM, so this crate carries no
|
||||||
|
/// runtime dependency and the config layer stays inert.
|
||||||
|
pub static ON_DEMAND_MIGRATION_CONFIG_HOOK: OnceLock<ConfigPublishHook> = OnceLock::new();
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
mod before_native_sources {
|
|
||||||
include!("../../fixtures/on_demand_migration/source_config_e2a.rs");
|
|
||||||
}
|
|
||||||
|
|
||||||
const FULL_JSON: &str = r#"{
|
const FULL_JSON: &str = r#"{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -882,32 +674,6 @@ mod tests {
|
|||||||
assert_eq!(minimal.policy.source_timeout.first_byte_ms, 15_000);
|
assert_eq!(minimal.policy.source_timeout.first_byte_ms, 15_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn s3_config_writes_remain_readable_by_the_strict_pre_native_reader() {
|
|
||||||
// FULL_JSON is the complete config fixture already present in e2a921bc.
|
|
||||||
for provider in ["s3", "aws", "minio", "rustfs", "r2", "gcs"] {
|
|
||||||
let mut old_wire: serde_json::Value = serde_json::from_str(FULL_JSON).expect("historical config fixture");
|
|
||||||
old_wire["source"]["provider"] = provider.into();
|
|
||||||
let config = OnDemandMigrationConfig::from_json(&serde_json::to_vec(&old_wire).expect("historical wire"))
|
|
||||||
.expect("current reader accepts the historical source");
|
|
||||||
let wire = config.to_json().expect("persist current config");
|
|
||||||
let actual: serde_json::Value = serde_json::from_slice(&wire).expect("persisted config JSON");
|
|
||||||
let old_source: before_native_sources::SourceConfig = serde_json::from_value(actual["source"].clone())
|
|
||||||
.expect("an existing S3 source must remain readable by the strict e2a source consumer");
|
|
||||||
assert_eq!(serde_json::to_value(old_source).expect("old reader wire"), old_wire["source"]);
|
|
||||||
assert_eq!(actual, old_wire, "provider={provider}: no existing config field or value may change");
|
|
||||||
|
|
||||||
for field in ["azure", "gcs"] {
|
|
||||||
let mut rejected = old_wire["source"].clone();
|
|
||||||
rejected[field] = serde_json::Value::Null;
|
|
||||||
assert!(
|
|
||||||
serde_json::from_value::<before_native_sources::SourceConfig>(rejected).is_err(),
|
|
||||||
"the frozen old reader must reject {field}, even when null"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_fields_are_rejected_at_every_level() {
|
fn unknown_fields_are_rejected_at_every_level() {
|
||||||
for (label, json) in [
|
for (label, json) in [
|
||||||
@@ -933,15 +699,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"provider enum",
|
"provider enum",
|
||||||
r#"{"source":{"provider":"swift","endpoint":"https://h","region":"r","bucket":"b"}}"#,
|
r#"{"source":{"provider":"azure","endpoint":"https://h","region":"r","bucket":"b"}}"#,
|
||||||
),
|
|
||||||
(
|
|
||||||
"azure block",
|
|
||||||
r#"{"source":{"provider":"azure","region":"auto","bucket":"b","azure":{"account":"acct","account_key":"a2V5","extra":1}}}"#,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"gcs block",
|
|
||||||
r#"{"source":{"provider":"gcs_native","region":"auto","bucket":"b","gcs":{"service_account_json":"{}","extra":1}}}"#,
|
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let err = OnDemandMigrationConfig::from_json(json.as_bytes()).expect_err(label);
|
let err = OnDemandMigrationConfig::from_json(json.as_bytes()).expect_err(label);
|
||||||
@@ -1062,214 +820,9 @@ mod tests {
|
|||||||
"{provider}"
|
"{provider}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// The native providers never sign with a region, so "auto" is the
|
|
||||||
// honest value to write for them.
|
|
||||||
for cfg in [azure_cfg(), gcs_native_cfg()] {
|
|
||||||
assert_eq!(cfg.source.region, "auto");
|
|
||||||
cfg.validate(empty_ctx())
|
|
||||||
.unwrap_or_else(|err| panic!("{}: {err}", cfg.source.provider));
|
|
||||||
}
|
|
||||||
assert_eq!(sample().source.effective_region(), "us-west-1");
|
assert_eq!(sample().source.effective_region(), "us-west-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVICE_ACCOUNT_JSON: &str = r#"{"type":"service_account","project_id":"p","client_email":"a@b.iam.gserviceaccount.com","private_key":"-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----"}"#;
|
|
||||||
|
|
||||||
fn azure_cfg() -> OnDemandMigrationConfig {
|
|
||||||
let mut cfg = sample();
|
|
||||||
cfg.source.provider = Provider::Azure;
|
|
||||||
cfg.source.endpoint = None;
|
|
||||||
cfg.source.region = "auto".to_string();
|
|
||||||
cfg.source.credentials = None;
|
|
||||||
cfg.source.azure = Some(AzureSourceConfig {
|
|
||||||
account: "legacyaccount".to_string(),
|
|
||||||
account_key: Some("c2VjcmV0LWtleQ==".to_string()),
|
|
||||||
sas_token: None,
|
|
||||||
});
|
|
||||||
cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
fn gcs_native_cfg() -> OnDemandMigrationConfig {
|
|
||||||
let mut cfg = sample();
|
|
||||||
cfg.source.provider = Provider::GcsNative;
|
|
||||||
cfg.source.endpoint = None;
|
|
||||||
cfg.source.region = "auto".to_string();
|
|
||||||
cfg.source.credentials = None;
|
|
||||||
cfg.source.gcs = Some(GcsSourceConfig {
|
|
||||||
service_account_json: SERVICE_ACCOUNT_JSON.to_string(),
|
|
||||||
});
|
|
||||||
cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn native_providers_derive_their_endpoint_and_round_trip_on_the_wire() {
|
|
||||||
let azure = azure_cfg();
|
|
||||||
assert_eq!(azure.source.effective_endpoint(), "https://legacyaccount.blob.core.windows.net");
|
|
||||||
let gcs = gcs_native_cfg();
|
|
||||||
assert_eq!(gcs.source.effective_endpoint(), "https://storage.googleapis.com");
|
|
||||||
|
|
||||||
for cfg in [azure_cfg(), gcs_native_cfg()] {
|
|
||||||
let json = cfg.to_json().expect("config must serialize");
|
|
||||||
assert_eq!(OnDemandMigrationConfig::from_json(&json).expect("config must parse"), cfg);
|
|
||||||
let wire: serde_json::Value = serde_json::from_slice(&json).expect("native config JSON");
|
|
||||||
let (present, absent, expected) = match cfg.source.provider {
|
|
||||||
Provider::Azure => ("azure", "gcs", serde_json::to_value(&cfg.source.azure).expect("Azure block")),
|
|
||||||
Provider::GcsNative => ("gcs", "azure", serde_json::to_value(&cfg.source.gcs).expect("GCS block")),
|
|
||||||
_ => unreachable!("native fixture"),
|
|
||||||
};
|
|
||||||
assert!(expected.is_object(), "native credentials must be present");
|
|
||||||
assert_eq!(wire["source"][present], expected);
|
|
||||||
assert!(wire["source"].get(absent).is_none());
|
|
||||||
assert!(
|
|
||||||
serde_json::from_value::<before_native_sources::SourceConfig>(wire["source"].clone()).is_err(),
|
|
||||||
"native providers still require upgraded readers"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The wire labels are part of the admin contract.
|
|
||||||
assert!(
|
|
||||||
String::from_utf8(azure_cfg().to_json().expect("json"))
|
|
||||||
.expect("utf8")
|
|
||||||
.contains(r#""provider":"azure""#)
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
String::from_utf8(gcs_native_cfg().to_json().expect("json"))
|
|
||||||
.expect("utf8")
|
|
||||||
.contains(r#""provider":"gcs_native""#)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_explicit_endpoint_overrides_the_derived_native_one() {
|
|
||||||
// Azurite and fake-gcs-server are addressed this way.
|
|
||||||
let mut cfg = azure_cfg();
|
|
||||||
cfg.source.endpoint = Some("http://azurite.example.com:10000".to_string());
|
|
||||||
cfg.validate(empty_ctx()).expect("an explicit native endpoint is allowed");
|
|
||||||
assert_eq!(cfg.source.effective_endpoint(), "http://azurite.example.com:10000");
|
|
||||||
|
|
||||||
cfg.source.endpoint = Some("http://azurite.example.com:10000/devstoreaccount1".to_string());
|
|
||||||
assert!(
|
|
||||||
matches!(cfg.validate(empty_ctx()), Err(OnDemandMigrationConfigError::InvalidEndpoint(_))),
|
|
||||||
"a native endpoint is still an origin"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_provider_block_belongs_to_exactly_its_own_provider() {
|
|
||||||
let mut cfg = sample();
|
|
||||||
cfg.source.azure = azure_cfg().source.azure;
|
|
||||||
assert_eq!(
|
|
||||||
cfg.validate(empty_ctx()),
|
|
||||||
Err(OnDemandMigrationConfigError::UnexpectedProviderBlock("azure", Provider::S3))
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut cfg = sample();
|
|
||||||
cfg.source.gcs = gcs_native_cfg().source.gcs;
|
|
||||||
assert_eq!(
|
|
||||||
cfg.validate(empty_ctx()),
|
|
||||||
Err(OnDemandMigrationConfigError::UnexpectedProviderBlock("gcs", Provider::S3))
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut cfg = azure_cfg();
|
|
||||||
cfg.source.azure = None;
|
|
||||||
assert_eq!(
|
|
||||||
cfg.validate(empty_ctx()),
|
|
||||||
Err(OnDemandMigrationConfigError::MissingProviderBlock("azure", Provider::Azure))
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut cfg = gcs_native_cfg();
|
|
||||||
cfg.source.gcs = None;
|
|
||||||
assert_eq!(
|
|
||||||
cfg.validate(empty_ctx()),
|
|
||||||
Err(OnDemandMigrationConfigError::MissingProviderBlock("gcs", Provider::GcsNative))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn azure_block_rules() {
|
|
||||||
let with = |account: &str, key: Option<&str>, sas: Option<&str>| {
|
|
||||||
let mut cfg = azure_cfg();
|
|
||||||
cfg.source.azure = Some(AzureSourceConfig {
|
|
||||||
account: account.to_string(),
|
|
||||||
account_key: key.map(str::to_string),
|
|
||||||
sas_token: sas.map(str::to_string),
|
|
||||||
});
|
|
||||||
cfg.validate(empty_ctx())
|
|
||||||
};
|
|
||||||
|
|
||||||
with("legacyaccount", None, Some("sv=2021-08-06&sig=abc%3D")).expect("a SAS token is a complete credential");
|
|
||||||
with("legacyaccount", Some("c2VjcmV0LWtleQ=="), None).expect("an account key is a complete credential");
|
|
||||||
|
|
||||||
for (label, result) in [
|
|
||||||
("empty account", with("", Some("c2VjcmV0LWtleQ=="), None)),
|
|
||||||
// The account becomes the first label of the derived hostname.
|
|
||||||
("account with a dot", with("legacy.account", Some("c2VjcmV0LWtleQ=="), None)),
|
|
||||||
("account with a slash", with("legacy/account", Some("c2VjcmV0LWtleQ=="), None)),
|
|
||||||
("no credential", with("legacyaccount", None, None)),
|
|
||||||
("both credentials", with("legacyaccount", Some("c2VjcmV0LWtleQ=="), Some("sv=1"))),
|
|
||||||
("empty key", with("legacyaccount", Some(""), None)),
|
|
||||||
("key that is not base64", with("legacyaccount", Some("not base64!"), None)),
|
|
||||||
("empty sas", with("legacyaccount", None, Some(""))),
|
|
||||||
("sas with a leading question mark", with("legacyaccount", None, Some("?sv=1"))),
|
|
||||||
("sas with whitespace", with("legacyaccount", None, Some("sv=1 &sig=a"))),
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
matches!(result, Err(OnDemandMigrationConfigError::InvalidProviderBlock("azure", _))),
|
|
||||||
"{label}: {result:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gcs_native_block_requires_a_usable_service_account_key() {
|
|
||||||
let with = |json: &str| {
|
|
||||||
let mut cfg = gcs_native_cfg();
|
|
||||||
cfg.source.gcs = Some(GcsSourceConfig {
|
|
||||||
service_account_json: json.to_string(),
|
|
||||||
});
|
|
||||||
cfg.validate(empty_ctx())
|
|
||||||
};
|
|
||||||
|
|
||||||
with(SERVICE_ACCOUNT_JSON).expect("a service-account key is accepted");
|
|
||||||
for (label, json) in [
|
|
||||||
("empty", ""),
|
|
||||||
("not json", "not json"),
|
|
||||||
("not an object", "[]"),
|
|
||||||
("wrong type", r#"{"type":"authorized_user","client_email":"a@b","private_key":"k"}"#),
|
|
||||||
("no private key", r#"{"type":"service_account","client_email":"a@b"}"#),
|
|
||||||
("empty client email", r#"{"type":"service_account","client_email":"","private_key":"k"}"#),
|
|
||||||
] {
|
|
||||||
let result = with(json);
|
|
||||||
assert!(
|
|
||||||
matches!(result, Err(OnDemandMigrationConfigError::InvalidProviderBlock("gcs", _))),
|
|
||||||
"{label}: {result:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn native_secrets_never_survive_redaction_or_debug() {
|
|
||||||
let mut azure = azure_cfg();
|
|
||||||
azure.source.azure.as_mut().expect("block").sas_token = Some("sv=2021-08-06&sig=top-secret".to_string());
|
|
||||||
azure.source.azure.as_mut().expect("block").account_key = None;
|
|
||||||
let gcs = gcs_native_cfg();
|
|
||||||
|
|
||||||
for rendered in [
|
|
||||||
format!("{:?}", azure.redacted()),
|
|
||||||
format!("{azure:?}"),
|
|
||||||
String::from_utf8(azure.redacted().to_json().expect("json")).expect("utf8"),
|
|
||||||
] {
|
|
||||||
assert!(!rendered.contains("top-secret"), "{rendered}");
|
|
||||||
assert!(rendered.contains("legacyaccount"), "the account name is not a secret: {rendered}");
|
|
||||||
}
|
|
||||||
for rendered in [
|
|
||||||
format!("{:?}", gcs.redacted()),
|
|
||||||
format!("{gcs:?}"),
|
|
||||||
String::from_utf8(gcs.redacted().to_json().expect("json")).expect("utf8"),
|
|
||||||
] {
|
|
||||||
assert!(!rendered.contains("PRIVATE KEY-----"), "{rendered}");
|
|
||||||
assert!(!rendered.contains("gserviceaccount"), "{rendered}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bucket_rules() {
|
fn bucket_rules() {
|
||||||
let mut cfg = sample();
|
let mut cfg = sample();
|
||||||
@@ -1552,55 +1105,4 @@ mod tests {
|
|||||||
assert!(!rendered.contains("topsecret"), "{rendered}");
|
assert!(!rendered.contains("topsecret"), "{rendered}");
|
||||||
assert!(!rendered.contains("SK"), "{rendered}");
|
assert!(!rendered.contains("SK"), "{rendered}");
|
||||||
}
|
}
|
||||||
/// 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 super::super::storage_api::StorageError as Error;
|
|
||||||
use super::super::storage_api::test_support::{
|
|
||||||
BUCKET_ON_DEMAND_MIGRATION_CONFIG, BucketMetadata, BucketMetadataSys, isolated_store_over_temp_disks,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
|
||||||
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"}}}"#;
|
|
||||||
|
|
||||||
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!(
|
|
||||||
decode_stored_config(sys.get_on_demand_migration_config(bucket).await.unwrap()).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 = decode_stored_config(sys.get_on_demand_migration_config(bucket).await.unwrap())
|
|
||||||
.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(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) = decode_stored_config(sys.get_on_demand_migration_config(bucket).await.unwrap())
|
|
||||||
.unwrap()
|
|
||||||
.expect("stored config is returned");
|
|
||||||
assert_eq!(config, OnDemandMigrationConfig::from_json(ODM_JSON).unwrap());
|
|
||||||
assert_eq!(updated_at, stamped);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+32
-624
@@ -25,21 +25,13 @@ use parking_lot::Mutex;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// The continuation-token version used by ordinary progressing pages.
|
/// The only continuation-token envelope version this build reads and writes.
|
||||||
pub const LIST_THROUGH_TOKEN_VERSION: u32 = 1;
|
pub const LIST_THROUGH_TOKEN_VERSION: u32 = 1;
|
||||||
const LIST_THROUGH_PROGRESS_TOKEN_VERSION: u32 = 2;
|
|
||||||
|
|
||||||
/// The sixteenth consecutive merged page without a key or new EOF fails.
|
|
||||||
/// This also bounds legitimate sparse listings; it is not a cycle detector.
|
|
||||||
pub const MAX_LIST_NO_PROGRESS_PAGES: u8 = 16;
|
|
||||||
|
|
||||||
/// Envelope marker. A bucket that is *not* merging hands out the local
|
/// Envelope marker. A bucket that is *not* merging hands out the local
|
||||||
/// listing's own marker, so the decoder needs a positive signal before it
|
/// listing's own marker, so the decoder needs a positive signal before it
|
||||||
/// treats an opaque token as a merged one.
|
/// treats an opaque token as a merged one.
|
||||||
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
|
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
|
||||||
// Object keys cannot contain NUL (bucket::utils::is_valid_object_prefix),
|
|
||||||
// so this framing cannot collide with a local key used as an opaque marker.
|
|
||||||
const LIST_THROUGH_TOKEN_PREFIX: &str = "\0odm-list:";
|
|
||||||
|
|
||||||
/// Pages fetched per side per request: the first page, plus at most one refill
|
/// Pages fetched per side per request: the first page, plus at most one refill
|
||||||
/// when the first one was mostly consumed by the previous page. Two pages of
|
/// when the first one was mostly consumed by the previous page. Two pages of
|
||||||
@@ -94,16 +86,14 @@ pub struct MergePick {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The continuation-token envelope. Opaque to clients: it is serialized as
|
/// The continuation-token envelope. Opaque to clients: it is serialized as
|
||||||
/// JSON, optionally framed, then base64-encoded like a local marker.
|
/// JSON and then base64-encoded by the same helper that encodes a plain local
|
||||||
|
/// marker, so the wire shape is `base64(json)`.
|
||||||
///
|
///
|
||||||
/// A `null` cursor with `done = false` means "list that side from the start";
|
/// A `null` cursor with `done = false` means "list that side from the start";
|
||||||
/// `done = true` means the side is finished and must not be listed again.
|
/// `done = true` means the side is finished and must not be listed again.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ListThroughToken {
|
pub struct ListThroughToken {
|
||||||
/// Transport framing observed by the decoder, never an envelope field.
|
|
||||||
#[serde(skip)]
|
|
||||||
pub framed: bool,
|
|
||||||
/// Envelope marker, always [`LIST_THROUGH_TOKEN_TAG`].
|
/// Envelope marker, always [`LIST_THROUGH_TOKEN_TAG`].
|
||||||
pub t: String,
|
pub t: String,
|
||||||
pub v: u32,
|
pub v: u32,
|
||||||
@@ -121,16 +111,11 @@ pub struct ListThroughToken {
|
|||||||
/// common prefix compares as itself, never as its members.
|
/// common prefix compares as itself, never as its members.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_key: Option<String>,
|
pub last_key: Option<String>,
|
||||||
/// Consecutive empty truncated merged pages, present only in v2 tokens.
|
|
||||||
/// Ordinary v1 tokens retain their original serialized shape.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub no_progress: Option<u8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListThroughToken {
|
impl ListThroughToken {
|
||||||
fn new(local: SideCursor, source: SideCursor, last_key: Option<String>) -> Self {
|
fn new(local: SideCursor, source: SideCursor, last_key: Option<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
framed: false,
|
|
||||||
t: LIST_THROUGH_TOKEN_TAG.to_string(),
|
t: LIST_THROUGH_TOKEN_TAG.to_string(),
|
||||||
v: LIST_THROUGH_TOKEN_VERSION,
|
v: LIST_THROUGH_TOKEN_VERSION,
|
||||||
local: local.token,
|
local: local.token,
|
||||||
@@ -138,19 +123,13 @@ impl ListThroughToken {
|
|||||||
source: source.token,
|
source: source.token,
|
||||||
source_done: source.done,
|
source_done: source.done,
|
||||||
last_key,
|
last_key,
|
||||||
no_progress: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encode(&self) -> String {
|
pub fn encode(&self) -> String {
|
||||||
// The envelope is built here from owned strings, so serialization
|
// The envelope is built here from owned strings, so serialization
|
||||||
// cannot fail; the fallback keeps the signature infallible.
|
// cannot fail; the fallback keeps the signature infallible.
|
||||||
let json = serde_json::to_string(self).unwrap_or_default();
|
serde_json::to_string(self).unwrap_or_default()
|
||||||
if self.framed {
|
|
||||||
format!("{LIST_THROUGH_TOKEN_PREFIX}{json}")
|
|
||||||
} else {
|
|
||||||
json
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,57 +153,29 @@ pub enum ListThroughTokenError {
|
|||||||
|
|
||||||
/// Classifies an already base64-decoded continuation token.
|
/// Classifies an already base64-decoded continuation token.
|
||||||
///
|
///
|
||||||
/// Framed envelopes and complete historical writer envelopes are merged tokens.
|
/// Only a JSON object carrying the envelope marker is read as a merged token;
|
||||||
/// Partial JSON-shaped keys remain local markers. A key identical to a complete
|
/// anything else is a local marker, so a bucket that turns `list_through` off
|
||||||
/// historical envelope is inherently ambiguous and retains merged semantics.
|
/// keeps paginating with the tokens it handed out. A token that *is* an
|
||||||
/// Recognized envelopes share the same version, count and field validation.
|
/// envelope but was tampered with (unknown version, unknown field, truncated
|
||||||
|
/// JSON) is an error, never a silent fallback.
|
||||||
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
|
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
|
||||||
let (payload, framed) = match decoded.strip_prefix(LIST_THROUGH_TOKEN_PREFIX) {
|
if !decoded.starts_with('{') {
|
||||||
Some(payload) => (payload, true),
|
|
||||||
None if decoded.starts_with('{') => (decoded, false),
|
|
||||||
None => return Ok(ListThroughCursor::Local(decoded.to_string())),
|
|
||||||
};
|
|
||||||
let value = match serde_json::from_str::<serde_json::Value>(payload) {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(_) if framed => return Err(ListThroughTokenError::Malformed),
|
|
||||||
Err(_) => return Ok(ListThroughCursor::Local(decoded.to_string())),
|
|
||||||
};
|
|
||||||
// RUSTFS_COMPAT_TODO(odm-list-bare-envelope): old writers issued bare JSON. Remove after all supported readers understand framing and outstanding bare listings have drained or explicitly restarted.
|
|
||||||
if !framed
|
|
||||||
&& (value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG)
|
|
||||||
|| ["v", "local", "local_done", "source", "source_done", "last_key"]
|
|
||||||
.iter()
|
|
||||||
.any(|field| value.get(field).is_none()))
|
|
||||||
{
|
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
||||||
}
|
}
|
||||||
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(decoded) else {
|
||||||
|
// Not JSON at all: an object key may legitimately start with '{'.
|
||||||
|
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
||||||
|
};
|
||||||
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
|
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
|
||||||
return Err(ListThroughTokenError::Malformed);
|
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
||||||
}
|
}
|
||||||
match value.get("v").and_then(serde_json::Value::as_u64) {
|
match value.get("v").and_then(serde_json::Value::as_u64) {
|
||||||
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {
|
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {}
|
||||||
// v1 readers reject this field even when it is null or zero.
|
|
||||||
if value.get("no_progress").is_some() {
|
|
||||||
return Err(ListThroughTokenError::Malformed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(version) if version == u64::from(LIST_THROUGH_PROGRESS_TOKEN_VERSION) => {
|
|
||||||
if !value
|
|
||||||
.get("no_progress")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.is_some_and(|count| (1..u64::from(MAX_LIST_NO_PROGRESS_PAGES)).contains(&count))
|
|
||||||
{
|
|
||||||
return Err(ListThroughTokenError::Malformed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
|
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
|
||||||
None => return Err(ListThroughTokenError::Malformed),
|
None => return Err(ListThroughTokenError::Malformed),
|
||||||
}
|
}
|
||||||
serde_json::from_value::<ListThroughToken>(value)
|
serde_json::from_value::<ListThroughToken>(value)
|
||||||
.map(|mut token| {
|
.map(|token| ListThroughCursor::Merged(Box::new(token)))
|
||||||
token.framed = framed;
|
|
||||||
ListThroughCursor::Merged(Box::new(token))
|
|
||||||
})
|
|
||||||
.map_err(|_| ListThroughTokenError::Malformed)
|
.map_err(|_| ListThroughTokenError::Malformed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,8 +288,6 @@ pub enum ListPageError {
|
|||||||
Empty,
|
Empty,
|
||||||
#[error("truncated listing repeats a continuation token")]
|
#[error("truncated listing repeats a continuation token")]
|
||||||
Repeated,
|
Repeated,
|
||||||
#[error("listing exhausted its consecutive no-progress page budget")]
|
|
||||||
NoProgress(MergeSide),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_list_page(is_truncated: bool, token: Option<&str>, next_token: Option<&str>) -> Result<(), ListPageError> {
|
pub(crate) fn validate_list_page(is_truncated: bool, token: Option<&str>, next_token: Option<&str>) -> Result<(), ListPageError> {
|
||||||
@@ -403,7 +352,6 @@ pub struct MergeOutcome {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ListThroughMerger {
|
pub struct ListThroughMerger {
|
||||||
max_keys: usize,
|
max_keys: usize,
|
||||||
no_progress: Option<u8>,
|
|
||||||
last_key: Option<String>,
|
last_key: Option<String>,
|
||||||
local: SideState,
|
local: SideState,
|
||||||
source: SideState,
|
source: SideState,
|
||||||
@@ -423,7 +371,6 @@ impl ListThroughMerger {
|
|||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
max_keys,
|
max_keys,
|
||||||
no_progress: token.and_then(|token| token.no_progress),
|
|
||||||
last_key,
|
last_key,
|
||||||
local,
|
local,
|
||||||
source,
|
source,
|
||||||
@@ -489,18 +436,13 @@ impl ListThroughMerger {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `issue_progress_tokens` allows a v1 chain to start carrying a budget.
|
pub fn finish(self) -> MergeOutcome {
|
||||||
/// An existing v2 budget is always enforced, including on reader-only nodes.
|
|
||||||
/// Borrowing lets a source failure re-merge the fetched local buffers.
|
|
||||||
pub fn finish(&self, issue_progress_tokens: bool) -> Result<MergeOutcome, ListPageError> {
|
|
||||||
let Self {
|
let Self {
|
||||||
max_keys,
|
max_keys,
|
||||||
no_progress,
|
|
||||||
last_key,
|
last_key,
|
||||||
local,
|
local,
|
||||||
source,
|
source,
|
||||||
} = self;
|
} = self;
|
||||||
let max_keys = *max_keys;
|
|
||||||
|
|
||||||
// A side with more pages behind it can only be trusted up to the last
|
// A side with more pages behind it can only be trusted up to the last
|
||||||
// key it handed over: past that horizon the other side's entries could
|
// key it handed over: past that horizon the other side's entries could
|
||||||
@@ -566,44 +508,12 @@ impl ListThroughMerger {
|
|||||||
let source_left = !source.disabled && (!source_cursor.done || consumed_source < source.entries.len());
|
let source_left = !source.disabled && (!source_cursor.done || consumed_source < source.entries.len());
|
||||||
let is_truncated = local_left || source_left;
|
let is_truncated = local_left || source_left;
|
||||||
|
|
||||||
let reached_eof = (!local.start.done && local_cursor.done) || (!source.start.done && source_cursor.done);
|
let last_key = consumed_key.or(last_key);
|
||||||
let next_no_progress = if !is_truncated || !picks.is_empty() || reached_eof {
|
MergeOutcome {
|
||||||
None
|
|
||||||
} else if max_keys == 0 {
|
|
||||||
// A zero-sized request cannot consume entries. Preserve an existing
|
|
||||||
// budget without spending it or starting a new one.
|
|
||||||
*no_progress
|
|
||||||
} else if issue_progress_tokens || no_progress.is_some() {
|
|
||||||
let count = no_progress.unwrap_or(0).saturating_add(1);
|
|
||||||
if count >= MAX_LIST_NO_PROGRESS_PAGES {
|
|
||||||
// An empty truncated side closes the merge horizon. Local
|
|
||||||
// failure takes precedence; disabling the source cannot fix it.
|
|
||||||
let side = if local.more && local.entries.is_empty() {
|
|
||||||
MergeSide::Local
|
|
||||||
} else if !source.disabled && source.more && source.entries.is_empty() {
|
|
||||||
MergeSide::Source
|
|
||||||
} else {
|
|
||||||
MergeSide::Local
|
|
||||||
};
|
|
||||||
return Err(ListPageError::NoProgress(side));
|
|
||||||
}
|
|
||||||
Some(count)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let last_key = consumed_key.or_else(|| last_key.clone());
|
|
||||||
Ok(MergeOutcome {
|
|
||||||
picks,
|
picks,
|
||||||
is_truncated,
|
is_truncated,
|
||||||
next_token: is_truncated.then(|| {
|
next_token: is_truncated.then(|| ListThroughToken::new(local_cursor, source_cursor, last_key)),
|
||||||
let mut token = ListThroughToken::new(local_cursor, source_cursor, last_key);
|
|
||||||
if let Some(count) = next_no_progress {
|
|
||||||
token.v = LIST_THROUGH_PROGRESS_TOKEN_VERSION;
|
|
||||||
token.no_progress = Some(count);
|
|
||||||
}
|
}
|
||||||
token
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,126 +579,6 @@ impl Default for SourceListRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frozen framed-only codec from e1608fbd9ca934d157b5de46c80b4393f2dd3dd6.
|
|
||||||
// Keep its own DTO and constants: current-reader round trips cannot establish
|
|
||||||
// whether a deployed framed-only reader accepts the bytes we issue.
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod e160_framed_reader {
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// The continuation-token version used by ordinary progressing pages.
|
|
||||||
pub const LIST_THROUGH_TOKEN_VERSION: u32 = 1;
|
|
||||||
const LIST_THROUGH_PROGRESS_TOKEN_VERSION: u32 = 2;
|
|
||||||
|
|
||||||
/// The sixteenth consecutive merged page without a key or new EOF fails.
|
|
||||||
/// This also bounds legitimate sparse listings; it is not a cycle detector.
|
|
||||||
pub const MAX_LIST_NO_PROGRESS_PAGES: u8 = 16;
|
|
||||||
|
|
||||||
/// Envelope marker. A bucket that is *not* merging hands out the local
|
|
||||||
/// listing's own marker, so the decoder needs a positive signal before it
|
|
||||||
/// treats an opaque token as a merged one.
|
|
||||||
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
|
|
||||||
// Object keys cannot contain NUL (bucket::utils::is_valid_object_prefix),
|
|
||||||
// so this framing cannot collide with a local key used as an opaque marker.
|
|
||||||
const LIST_THROUGH_TOKEN_PREFIX: &str = "\0odm-list:";
|
|
||||||
|
|
||||||
/// The continuation-token envelope. Opaque to clients: it is serialized as
|
|
||||||
/// framed JSON and then base64-encoded by the same helper as a local marker.
|
|
||||||
///
|
|
||||||
/// A `null` cursor with `done = false` means "list that side from the start";
|
|
||||||
/// `done = true` means the side is finished and must not be listed again.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct ListThroughToken {
|
|
||||||
/// Envelope marker, always [`LIST_THROUGH_TOKEN_TAG`].
|
|
||||||
pub t: String,
|
|
||||||
pub v: u32,
|
|
||||||
#[serde(default)]
|
|
||||||
pub local: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub local_done: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub source: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub source_done: bool,
|
|
||||||
/// Last entry the previous page consumed. A side whose page was only
|
|
||||||
/// partially consumed is re-listed from the same cursor and everything at
|
|
||||||
/// or below this key is dropped, which is delimiter-safe: a rolled-up
|
|
||||||
/// common prefix compares as itself, never as its members.
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_key: Option<String>,
|
|
||||||
/// Consecutive empty truncated merged pages, present only in v2 tokens.
|
|
||||||
/// Ordinary v1 tokens retain their original serialized shape.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub no_progress: Option<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListThroughToken {
|
|
||||||
pub fn encode(&self) -> String {
|
|
||||||
// The envelope is built here from owned strings, so serialization
|
|
||||||
// cannot fail; the fallback keeps the signature infallible.
|
|
||||||
format!("{LIST_THROUGH_TOKEN_PREFIX}{}", serde_json::to_string(self).unwrap_or_default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a decoded (base64-stripped) continuation token turned out to be.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum ListThroughCursor {
|
|
||||||
/// A plain local listing marker: the bucket was not merging when the token
|
|
||||||
/// was issued, or the client is paginating a non-merged listing.
|
|
||||||
Local(String),
|
|
||||||
Merged(Box<ListThroughToken>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
|
||||||
pub enum ListThroughTokenError {
|
|
||||||
#[error("continuation token version {0} is not supported")]
|
|
||||||
UnsupportedVersion(u32),
|
|
||||||
/// The message never echoes the token: it is client-controlled input.
|
|
||||||
#[error("continuation token is malformed")]
|
|
||||||
Malformed,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classifies an already base64-decoded continuation token.
|
|
||||||
///
|
|
||||||
/// Only a framed JSON object is read as a merged token;
|
|
||||||
/// anything else is a local marker, so a bucket that turns `list_through` off
|
|
||||||
/// keeps paginating with the tokens it handed out. A token that *is* an
|
|
||||||
/// envelope but was tampered with (unknown version, unknown field, truncated
|
|
||||||
/// JSON) is an error, never a silent fallback.
|
|
||||||
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
|
|
||||||
let Some(payload) = decoded.strip_prefix(LIST_THROUGH_TOKEN_PREFIX) else {
|
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
|
||||||
};
|
|
||||||
let value = serde_json::from_str::<serde_json::Value>(payload).map_err(|_| ListThroughTokenError::Malformed)?;
|
|
||||||
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
|
|
||||||
return Err(ListThroughTokenError::Malformed);
|
|
||||||
}
|
|
||||||
match value.get("v").and_then(serde_json::Value::as_u64) {
|
|
||||||
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {
|
|
||||||
// v1 readers reject this field even when it is null or zero.
|
|
||||||
if value.get("no_progress").is_some() {
|
|
||||||
return Err(ListThroughTokenError::Malformed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(version) if version == u64::from(LIST_THROUGH_PROGRESS_TOKEN_VERSION) => {
|
|
||||||
if !value
|
|
||||||
.get("no_progress")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.is_some_and(|count| (1..u64::from(MAX_LIST_NO_PROGRESS_PAGES)).contains(&count))
|
|
||||||
{
|
|
||||||
return Err(ListThroughTokenError::Malformed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
|
|
||||||
None => return Err(ListThroughTokenError::Malformed),
|
|
||||||
}
|
|
||||||
serde_json::from_value::<ListThroughToken>(value)
|
|
||||||
.map(|token| ListThroughCursor::Merged(Box::new(token)))
|
|
||||||
.map_err(|_| ListThroughTokenError::Malformed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -851,7 +641,7 @@ mod tests {
|
|||||||
.push_page(fetch.side, kept, truncated, next)
|
.push_page(fetch.side, kept, truncated, next)
|
||||||
.expect("reference provider pages must advance");
|
.expect("reference provider pages must advance");
|
||||||
}
|
}
|
||||||
let outcome = merger.finish(false).expect("valid merge outcome");
|
let outcome = merger.finish();
|
||||||
assert_eq!(outcome.is_truncated, outcome.next_token.is_some());
|
assert_eq!(outcome.is_truncated, outcome.next_token.is_some());
|
||||||
if outcome.is_truncated {
|
if outcome.is_truncated {
|
||||||
assert_ne!(outcome.next_token, token, "every truncated merged page must make progress");
|
assert_ne!(outcome.next_token, token, "every truncated merged page must make progress");
|
||||||
@@ -934,7 +724,7 @@ mod tests {
|
|||||||
.push_page(MergeSide::Local, vec![ListEntryKey::object("a")], false, None)
|
.push_page(MergeSide::Local, vec![ListEntryKey::object("a")], false, None)
|
||||||
.expect("local EOF is valid");
|
.expect("local EOF is valid");
|
||||||
assert_eq!(merger.next_fetch(), None);
|
assert_eq!(merger.next_fetch(), None);
|
||||||
let outcome = merger.finish(false).expect("valid merge outcome");
|
let outcome = merger.finish();
|
||||||
assert_eq!(outcome.picks.len(), 1);
|
assert_eq!(outcome.picks.len(), 1);
|
||||||
assert!(!outcome.is_truncated);
|
assert!(!outcome.is_truncated);
|
||||||
assert!(outcome.next_token.is_none());
|
assert!(outcome.next_token.is_none());
|
||||||
@@ -943,7 +733,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_degraded_page_keeps_the_source_cursor_for_the_next_one() {
|
fn a_degraded_page_keeps_the_source_cursor_for_the_next_one() {
|
||||||
let resume = ListThroughToken {
|
let resume = ListThroughToken {
|
||||||
framed: false,
|
|
||||||
t: LIST_THROUGH_TOKEN_TAG.to_string(),
|
t: LIST_THROUGH_TOKEN_TAG.to_string(),
|
||||||
v: LIST_THROUGH_TOKEN_VERSION,
|
v: LIST_THROUGH_TOKEN_VERSION,
|
||||||
local: Some("local-1".to_string()),
|
local: Some("local-1".to_string()),
|
||||||
@@ -951,7 +740,6 @@ mod tests {
|
|||||||
source: Some("source-1".to_string()),
|
source: Some("source-1".to_string()),
|
||||||
source_done: false,
|
source_done: false,
|
||||||
last_key: Some("a".to_string()),
|
last_key: Some("a".to_string()),
|
||||||
no_progress: None,
|
|
||||||
};
|
};
|
||||||
let mut merger = ListThroughMerger::new(1, Some(&resume));
|
let mut merger = ListThroughMerger::new(1, Some(&resume));
|
||||||
merger.disable_source();
|
merger.disable_source();
|
||||||
@@ -963,7 +751,7 @@ mod tests {
|
|||||||
Some("local-2".to_string()),
|
Some("local-2".to_string()),
|
||||||
)
|
)
|
||||||
.expect("local cursor advances");
|
.expect("local cursor advances");
|
||||||
let outcome = merger.finish(false).expect("valid merge outcome");
|
let outcome = merger.finish();
|
||||||
assert!(outcome.is_truncated);
|
assert!(outcome.is_truncated);
|
||||||
let token = outcome.next_token.expect("truncated page carries a token");
|
let token = outcome.next_token.expect("truncated page carries a token");
|
||||||
assert_eq!(token.source.as_deref(), Some("source-1"), "the source cursor must not move");
|
assert_eq!(token.source.as_deref(), Some("source-1"), "the source cursor must not move");
|
||||||
@@ -1042,7 +830,7 @@ mod tests {
|
|||||||
.expect("opaque cursor advances regardless of sort order");
|
.expect("opaque cursor advances regardless of sort order");
|
||||||
}
|
}
|
||||||
assert!(merger.next_fetch().is_none(), "two source fetches exhaust the request budget");
|
assert!(merger.next_fetch().is_none(), "two source fetches exhaust the request budget");
|
||||||
let outcome = merger.finish(false).expect("valid merge outcome");
|
let outcome = merger.finish();
|
||||||
assert!(outcome.picks.is_empty());
|
assert!(outcome.picks.is_empty());
|
||||||
assert!(outcome.is_truncated);
|
assert!(outcome.is_truncated);
|
||||||
let token = outcome.next_token.expect("empty progressing page has a cursor");
|
let token = outcome.next_token.expect("empty progressing page has a cursor");
|
||||||
@@ -1052,7 +840,7 @@ mod tests {
|
|||||||
merger
|
merger
|
||||||
.push_page(MergeSide::Source, vec![ListEntryKey::object("result")], false, None)
|
.push_page(MergeSide::Source, vec![ListEntryKey::object("result")], false, None)
|
||||||
.expect("source EOF");
|
.expect("source EOF");
|
||||||
let outcome = merger.finish(false).expect("valid merge outcome");
|
let outcome = merger.finish();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
outcome.picks,
|
outcome.picks,
|
||||||
vec![MergePick {
|
vec![MergePick {
|
||||||
@@ -1099,7 +887,7 @@ mod tests {
|
|||||||
Err(ListPageError::Repeated)
|
Err(ListPageError::Repeated)
|
||||||
);
|
);
|
||||||
merger.disable_source();
|
merger.disable_source();
|
||||||
let outcome = merger.finish(false).expect("valid merge outcome");
|
let outcome = merger.finish();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
outcome.picks,
|
outcome.picks,
|
||||||
vec![MergePick {
|
vec![MergePick {
|
||||||
@@ -1180,7 +968,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn token_round_trips_and_rejects_tampering() {
|
fn token_round_trips_and_rejects_tampering() {
|
||||||
let mut token = ListThroughToken::new(
|
let token = ListThroughToken::new(
|
||||||
SideCursor {
|
SideCursor {
|
||||||
token: Some("l".to_string()),
|
token: Some("l".to_string()),
|
||||||
done: false,
|
done: false,
|
||||||
@@ -1188,404 +976,24 @@ mod tests {
|
|||||||
SideCursor { token: None, done: true },
|
SideCursor { token: None, done: true },
|
||||||
Some("k".to_string()),
|
Some("k".to_string()),
|
||||||
);
|
);
|
||||||
token.framed = true;
|
|
||||||
let encoded = token.encode();
|
let encoded = token.encode();
|
||||||
assert_eq!(decode_continuation_token(&encoded), Ok(ListThroughCursor::Merged(Box::new(token))));
|
assert_eq!(decode_continuation_token(&encoded), Ok(ListThroughCursor::Merged(Box::new(token))));
|
||||||
|
|
||||||
let bumped = encoded.replace("\"v\":1", "\"v\":3");
|
let bumped = encoded.replace("\"v\":1", "\"v\":2");
|
||||||
assert_eq!(decode_continuation_token(&bumped), Err(ListThroughTokenError::UnsupportedVersion(3)));
|
assert_eq!(decode_continuation_token(&bumped), Err(ListThroughTokenError::UnsupportedVersion(2)));
|
||||||
|
|
||||||
let extra = encoded.replace("{", "{\"x\":1,");
|
let extra = encoded.replace("{", "{\"x\":1,");
|
||||||
assert_eq!(decode_continuation_token(&extra), Err(ListThroughTokenError::Malformed));
|
assert_eq!(decode_continuation_token(&extra), Err(ListThroughTokenError::Malformed));
|
||||||
|
|
||||||
let truncated = &encoded[..encoded.len() - 3];
|
let truncated = &encoded[..encoded.len() - 3];
|
||||||
assert_eq!(decode_continuation_token(truncated), Err(ListThroughTokenError::Malformed));
|
assert_eq!(decode_continuation_token(truncated), Ok(ListThroughCursor::Local(truncated.to_string())));
|
||||||
|
|
||||||
let no_version = "\0odm-list:{\"t\":\"odm-list\"}";
|
let no_version = "{\"t\":\"odm-list\"}";
|
||||||
assert_eq!(decode_continuation_token(no_version), Err(ListThroughTokenError::Malformed));
|
assert_eq!(decode_continuation_token(no_version), Err(ListThroughTokenError::Malformed));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn progress_token(count: Option<u8>, local_done: bool, source_done: bool) -> ListThroughToken {
|
|
||||||
let mut token = ListThroughToken::new(
|
|
||||||
SideCursor {
|
|
||||||
token: None,
|
|
||||||
done: local_done,
|
|
||||||
},
|
|
||||||
SideCursor {
|
|
||||||
token: Some("A".into()),
|
|
||||||
done: source_done,
|
|
||||||
},
|
|
||||||
Some("last-key".into()),
|
|
||||||
);
|
|
||||||
if let Some(count) = count {
|
|
||||||
token.v = LIST_THROUGH_PROGRESS_TOKEN_VERSION;
|
|
||||||
token.no_progress = Some(count);
|
|
||||||
}
|
|
||||||
token
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_empty_pages(merger: &mut ListThroughMerger, side: MergeSide) {
|
|
||||||
for _ in 0..MAX_LIST_FETCHES_PER_SIDE {
|
|
||||||
let fetch = merger.next_fetch().expect("empty truncated side must be fetched");
|
|
||||||
assert_eq!(fetch.side, side);
|
|
||||||
let next = format!("{}:next", fetch.token.unwrap_or_default());
|
|
||||||
merger
|
|
||||||
.push_page(side, vec![], true, Some(next))
|
|
||||||
.expect("opaque cursor advances");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn progress_tokens_preserve_v1_bytes_and_validate_v2_counts() {
|
|
||||||
let token = progress_token(None, true, false);
|
|
||||||
assert_eq!(
|
|
||||||
token.encode(),
|
|
||||||
r#"{"t":"odm-list","v":1,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key"}"#
|
|
||||||
);
|
|
||||||
for framed in [false, true] {
|
|
||||||
let prefix = if framed { LIST_THROUGH_TOKEN_PREFIX } else { "" };
|
|
||||||
for count in 1..MAX_LIST_NO_PROGRESS_PAGES {
|
|
||||||
let mut token = progress_token(Some(count), true, false);
|
|
||||||
token.framed = framed;
|
|
||||||
assert_eq!(decode_continuation_token(&token.encode()), Ok(ListThroughCursor::Merged(Box::new(token))));
|
|
||||||
}
|
|
||||||
// Bare recognition requires the complete shape emitted by old writers;
|
|
||||||
// partial JSON objects are also valid local keys.
|
|
||||||
for version in [1, 2] {
|
|
||||||
for value in ["null", "0", "16", "-1", "1.5", "256", "18446744073709551616", "\"1\""] {
|
|
||||||
let encoded = format!(
|
|
||||||
r#"{prefix}{{"t":"odm-list","v":{version},"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key","no_progress":{value}}}"#
|
|
||||||
);
|
|
||||||
assert_eq!(decode_continuation_token(&encoded), Err(ListThroughTokenError::Malformed), "{encoded}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for encoded in [
|
|
||||||
r#"{"t":"odm-list","v":1,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key","no_progress":1}"#,
|
|
||||||
r#"{"t":"odm-list","v":2,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key"}"#,
|
|
||||||
r#"{"t":"odm-list","v":2,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key","no_progress":1,"extra":true}"#,
|
|
||||||
r#"{"t":"odm-list","v":2,"local":null,"local_done":true,"source":"A","source_done":false,"last_key":"last-key","no_progress":1,"framed":true}"#,
|
|
||||||
] {
|
|
||||||
let encoded = format!("{prefix}{encoded}");
|
|
||||||
assert_eq!(decode_continuation_token(&encoded), Err(ListThroughTokenError::Malformed), "{encoded}");
|
|
||||||
}
|
|
||||||
let bumped = format!("{prefix}{}", token.encode().replace("\"v\":1", "\"v\":9"));
|
|
||||||
assert_eq!(decode_continuation_token(&bumped), Err(ListThroughTokenError::UnsupportedVersion(9)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frozen decoder from 447f3c704, before framing was introduced. Keeping this
|
|
||||||
// independent of the current decoder catches a default-writer rollout break.
|
|
||||||
fn decode_before_framing(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
|
|
||||||
if !decoded.starts_with('{') {
|
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
|
||||||
}
|
|
||||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(decoded) else {
|
|
||||||
// Not JSON at all: an object key may legitimately start with '{'.
|
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
|
||||||
};
|
|
||||||
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
|
|
||||||
return Ok(ListThroughCursor::Local(decoded.to_string()));
|
|
||||||
}
|
|
||||||
match value.get("v").and_then(serde_json::Value::as_u64) {
|
|
||||||
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {
|
|
||||||
// v1 readers reject this field even when it is null or zero.
|
|
||||||
if value.get("no_progress").is_some() {
|
|
||||||
return Err(ListThroughTokenError::Malformed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(version) if version == u64::from(LIST_THROUGH_PROGRESS_TOKEN_VERSION) => {
|
|
||||||
if !value
|
|
||||||
.get("no_progress")
|
|
||||||
.and_then(serde_json::Value::as_u64)
|
|
||||||
.is_some_and(|count| (1..u64::from(MAX_LIST_NO_PROGRESS_PAGES)).contains(&count))
|
|
||||||
{
|
|
||||||
return Err(ListThroughTokenError::Malformed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
|
|
||||||
None => return Err(ListThroughTokenError::Malformed),
|
|
||||||
}
|
|
||||||
serde_json::from_value::<ListThroughToken>(value)
|
|
||||||
.map(|token| ListThroughCursor::Merged(Box::new(token)))
|
|
||||||
.map_err(|_| ListThroughTokenError::Malformed)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn historical_writer_fixtures_and_default_output_remain_readable() {
|
|
||||||
for (wire, version, count) in [
|
|
||||||
(
|
|
||||||
r#"{"t":"odm-list","v":1,"local":"local-2","local_done":false,"source":"source-2","source_done":false,"last_key":"k"}"#,
|
|
||||||
1,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r#"{"t":"odm-list","v":2,"local":"local-2","local_done":false,"source":"source-2","source_done":false,"last_key":"k","no_progress":15}"#,
|
|
||||||
2,
|
|
||||||
Some(15),
|
|
||||||
),
|
|
||||||
] {
|
|
||||||
let ListThroughCursor::Merged(mut token) = decode_continuation_token(wire).expect("historical issued token") else {
|
|
||||||
panic!("a historical cursor must not silently become a local marker, even if a key has identical JSON");
|
|
||||||
};
|
|
||||||
assert_eq!(token.local.as_deref(), Some("local-2"));
|
|
||||||
assert_eq!(token.source.as_deref(), Some("source-2"));
|
|
||||||
assert_eq!(token.last_key.as_deref(), Some("k"));
|
|
||||||
assert_eq!(token.v, version);
|
|
||||||
assert_eq!(token.no_progress, count);
|
|
||||||
assert!(!token.framed);
|
|
||||||
assert_eq!(token.encode(), wire, "bare output retains the historical bytes");
|
|
||||||
assert_eq!(decode_before_framing(&token.encode()), Ok(ListThroughCursor::Merged(token.clone())));
|
|
||||||
token.framed = true;
|
|
||||||
let framed = format!("\0odm-list:{wire}");
|
|
||||||
assert_eq!(token.encode(), framed, "framing leaves the JSON payload unchanged");
|
|
||||||
assert_eq!(decode_continuation_token(&framed), Ok(ListThroughCursor::Merged(token)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn frozen_e160_reader_distinguishes_framing_and_keeps_strict_budget_validation() {
|
|
||||||
use super::e160_framed_reader as old;
|
|
||||||
|
|
||||||
for raw in [
|
|
||||||
r#"{"t":"odm-list","v":1,"local":"local-2","local_done":false,"source":"source-2","source_done":false,"last_key":"k"}"#,
|
|
||||||
r#"{"t":"odm-list","v":2,"local":"local-2","local_done":false,"source":"source-2","source_done":false,"last_key":"k","no_progress":15}"#,
|
|
||||||
] {
|
|
||||||
assert_eq!(old::decode_continuation_token(raw), Ok(old::ListThroughCursor::Local(raw.to_string())));
|
|
||||||
let framed = format!("\0odm-list:{raw}");
|
|
||||||
let old::ListThroughCursor::Merged(old_token) = old::decode_continuation_token(&framed).expect("old writer bytes")
|
|
||||||
else {
|
|
||||||
panic!("e160 recognizes its own frame");
|
|
||||||
};
|
|
||||||
assert_eq!(old_token.encode(), framed);
|
|
||||||
let ListThroughCursor::Merged(current) = decode_continuation_token(&framed).expect("dual reader") else {
|
|
||||||
panic!("dual readers preserve old framed chains");
|
|
||||||
};
|
|
||||||
assert_eq!(current.encode(), framed);
|
|
||||||
assert_eq!(current.local, old_token.local);
|
|
||||||
assert_eq!(current.local_done, old_token.local_done);
|
|
||||||
assert_eq!(current.source, old_token.source);
|
|
||||||
assert_eq!(current.source_done, old_token.source_done);
|
|
||||||
assert_eq!(current.last_key, old_token.last_key);
|
|
||||||
assert_eq!(current.v, old_token.v);
|
|
||||||
assert_eq!(current.no_progress, old_token.no_progress);
|
|
||||||
}
|
|
||||||
for count in ["null", "0", "16", "-1", "1.5", "\"1\"", "256"] {
|
|
||||||
let raw = format!(
|
|
||||||
"\0odm-list:{{\"t\":\"odm-list\",\"v\":2,\"local\":null,\"local_done\":true,\"source\":\"A\",\"source_done\":false,\"last_key\":null,\"no_progress\":{count}}}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
old::decode_continuation_token(&raw),
|
|
||||||
Err(old::ListThroughTokenError::Malformed),
|
|
||||||
"{count}"
|
|
||||||
);
|
|
||||||
assert_eq!(decode_continuation_token(&raw), Err(ListThroughTokenError::Malformed), "{count}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reader_only_nodes_do_not_start_a_budget_but_mixed_readers_preserve_one() {
|
|
||||||
let mut token = progress_token(None, true, false);
|
|
||||||
for _ in 0..MAX_LIST_NO_PROGRESS_PAGES {
|
|
||||||
let mut merger = ListThroughMerger::new(2, Some(&token));
|
|
||||||
push_empty_pages(&mut merger, MergeSide::Source);
|
|
||||||
token = merger
|
|
||||||
.finish(false)
|
|
||||||
.expect("reader-only v1 behavior")
|
|
||||||
.next_token
|
|
||||||
.expect("truncated cursor");
|
|
||||||
assert_eq!(token.v, 1);
|
|
||||||
assert_eq!(token.no_progress, None);
|
|
||||||
}
|
|
||||||
for count in 1..=MAX_LIST_NO_PROGRESS_PAGES {
|
|
||||||
let mut merger = ListThroughMerger::new(2, Some(&token));
|
|
||||||
push_empty_pages(&mut merger, MergeSide::Source);
|
|
||||||
assert!(merger.next_fetch().is_none(), "the per-request two-fetch limit stays intact");
|
|
||||||
let outcome = merger.finish(count % 2 == 1);
|
|
||||||
if count == MAX_LIST_NO_PROGRESS_PAGES {
|
|
||||||
assert_eq!(outcome, Err(ListPageError::NoProgress(MergeSide::Source)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
token = outcome.expect("budget not exhausted").next_token.expect("truncated cursor");
|
|
||||||
assert_eq!(token.no_progress, Some(count));
|
|
||||||
let ListThroughCursor::Merged(decoded) = decode_continuation_token(&token.encode()).expect("round-trip v2") else {
|
|
||||||
panic!("merged cursor expected");
|
|
||||||
};
|
|
||||||
token = *decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn objects_and_common_prefixes_reset_a_budget_at_the_boundary() {
|
|
||||||
for entry in [ListEntryKey::object("result"), ListEntryKey::prefix("result/")] {
|
|
||||||
for issue_tokens in [false, true] {
|
|
||||||
let resume = progress_token(Some(MAX_LIST_NO_PROGRESS_PAGES - 1), true, false);
|
|
||||||
let mut merger = ListThroughMerger::new(2, Some(&resume));
|
|
||||||
merger
|
|
||||||
.push_page(MergeSide::Source, vec![], true, Some("B".into()))
|
|
||||||
.expect("empty advancing page");
|
|
||||||
merger
|
|
||||||
.push_page(MergeSide::Source, vec![entry.clone()], true, Some("C".into()))
|
|
||||||
.expect("real progress");
|
|
||||||
let outcome = merger
|
|
||||||
.finish(issue_tokens)
|
|
||||||
.expect("real progress does not exhaust the budget");
|
|
||||||
assert_eq!(
|
|
||||||
outcome.picks,
|
|
||||||
vec![MergePick {
|
|
||||||
side: MergeSide::Source,
|
|
||||||
index: 0
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
let next = outcome.next_token.expect("source remains truncated");
|
|
||||||
assert_eq!(next.last_key.as_deref(), Some(entry.name.as_str()));
|
|
||||||
assert_eq!(next.v, 1);
|
|
||||||
assert_eq!(next.no_progress, None);
|
|
||||||
assert!(!next.encode().contains("no_progress"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_a_new_eof_transition_resets_the_empty_page_budget() {
|
|
||||||
for finished_side in [MergeSide::Local, MergeSide::Source] {
|
|
||||||
let resume = progress_token(Some(MAX_LIST_NO_PROGRESS_PAGES - 1), false, false);
|
|
||||||
let mut merger = ListThroughMerger::new(2, Some(&resume));
|
|
||||||
if finished_side == MergeSide::Local {
|
|
||||||
merger
|
|
||||||
.push_page(MergeSide::Local, vec![], false, None)
|
|
||||||
.expect("new local EOF");
|
|
||||||
push_empty_pages(&mut merger, MergeSide::Source);
|
|
||||||
} else {
|
|
||||||
push_empty_pages(&mut merger, MergeSide::Local);
|
|
||||||
merger
|
|
||||||
.push_page(MergeSide::Source, vec![], false, None)
|
|
||||||
.expect("new source EOF");
|
|
||||||
}
|
|
||||||
let next = merger
|
|
||||||
.finish(false)
|
|
||||||
.expect("new EOF is progress")
|
|
||||||
.next_token
|
|
||||||
.expect("other side truncated");
|
|
||||||
assert_eq!(next.no_progress, None);
|
|
||||||
assert_eq!(next.v, 1);
|
|
||||||
assert_eq!(next.local_done, finished_side == MergeSide::Local);
|
|
||||||
assert_eq!(next.source_done, finished_side == MergeSide::Source);
|
|
||||||
let mut merger = ListThroughMerger::new(2, Some(&next));
|
|
||||||
let remaining = if finished_side == MergeSide::Local {
|
|
||||||
MergeSide::Source
|
|
||||||
} else {
|
|
||||||
MergeSide::Local
|
|
||||||
};
|
|
||||||
push_empty_pages(&mut merger, remaining);
|
|
||||||
let next = merger
|
|
||||||
.finish(true)
|
|
||||||
.expect("a new budget starts")
|
|
||||||
.next_token
|
|
||||||
.expect("truncated");
|
|
||||||
assert_eq!(next.no_progress, Some(1), "an already-done side cannot reset every page");
|
|
||||||
}
|
|
||||||
let resume = progress_token(Some(MAX_LIST_NO_PROGRESS_PAGES - 1), true, false);
|
|
||||||
let mut merger = ListThroughMerger::new(2, Some(&resume));
|
|
||||||
merger.push_page(MergeSide::Source, vec![], false, None).expect("final EOF");
|
|
||||||
let outcome = merger.finish(false).expect("EOF succeeds at the budget boundary");
|
|
||||||
assert!(!outcome.is_truncated);
|
|
||||||
assert!(outcome.next_token.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filtered_duplicates_cannot_reset_the_no_progress_budget() {
|
|
||||||
let resume = progress_token(Some(MAX_LIST_NO_PROGRESS_PAGES - 1), true, false);
|
|
||||||
let mut merger = ListThroughMerger::new(2, Some(&resume));
|
|
||||||
for next in ["B", "C"] {
|
|
||||||
let entries = [ListEntryKey::object("last-key"), ListEntryKey::object("earlier")]
|
|
||||||
.into_iter()
|
|
||||||
.filter(|entry| merger.accepts(&entry.name))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert!(entries.is_empty(), "both provider entries were already consumed");
|
|
||||||
merger
|
|
||||||
.push_page(MergeSide::Source, entries, true, Some(next.into()))
|
|
||||||
.expect("advancing cursor");
|
|
||||||
}
|
|
||||||
assert_eq!(merger.finish(false), Err(ListPageError::NoProgress(MergeSide::Source)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_progress_is_attributed_to_local_when_source_cannot_unblock_it() {
|
|
||||||
for source_mode in ["disabled", "done", "empty", "data"] {
|
|
||||||
let resume = progress_token(Some(MAX_LIST_NO_PROGRESS_PAGES - 1), false, source_mode == "done");
|
|
||||||
let mut merger = ListThroughMerger::new(2, Some(&resume));
|
|
||||||
if source_mode == "disabled" {
|
|
||||||
merger.disable_source();
|
|
||||||
}
|
|
||||||
push_empty_pages(&mut merger, MergeSide::Local);
|
|
||||||
match source_mode {
|
|
||||||
"empty" => push_empty_pages(&mut merger, MergeSide::Source),
|
|
||||||
"data" => merger
|
|
||||||
.push_page(MergeSide::Source, vec![ListEntryKey::object("source")], false, None)
|
|
||||||
.expect("source data"),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
assert_eq!(merger.finish(false), Err(ListPageError::NoProgress(MergeSide::Local)), "{source_mode}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn source_budget_failure_remerges_local_objects_and_prefixes_without_refetching() {
|
|
||||||
let resume = progress_token(Some(MAX_LIST_NO_PROGRESS_PAGES - 1), false, false);
|
|
||||||
let mut merger = ListThroughMerger::new(2, Some(&resume));
|
|
||||||
merger
|
|
||||||
.push_page(MergeSide::Local, vec![ListEntryKey::object("local")], true, Some("L1".into()))
|
|
||||||
.expect("local object");
|
|
||||||
merger
|
|
||||||
.push_page(MergeSide::Local, vec![ListEntryKey::prefix("prefix/")], true, Some("L2".into()))
|
|
||||||
.expect("local prefix");
|
|
||||||
push_empty_pages(&mut merger, MergeSide::Source);
|
|
||||||
assert_eq!(merger.finish(false), Err(ListPageError::NoProgress(MergeSide::Source)));
|
|
||||||
merger.disable_source();
|
|
||||||
assert!(merger.next_fetch().is_none(), "fallback does not perform another fetch");
|
|
||||||
let outcome = merger.finish(false).expect("local data makes progress");
|
|
||||||
assert_eq!(
|
|
||||||
outcome.picks,
|
|
||||||
vec![
|
|
||||||
MergePick {
|
|
||||||
side: MergeSide::Local,
|
|
||||||
index: 0
|
|
||||||
},
|
|
||||||
MergePick {
|
|
||||||
side: MergeSide::Local,
|
|
||||||
index: 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
);
|
|
||||||
let token = outcome.next_token.expect("remaining local page");
|
|
||||||
assert_eq!(token.local.as_deref(), Some("L2"));
|
|
||||||
assert_eq!(token.source.as_deref(), Some("A"));
|
|
||||||
assert_eq!(token.last_key.as_deref(), Some("prefix/"));
|
|
||||||
assert_eq!(token.no_progress, None);
|
|
||||||
assert_eq!(token.v, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_zero_sized_merge_preserves_an_existing_budget() {
|
|
||||||
let resume = progress_token(Some(MAX_LIST_NO_PROGRESS_PAGES - 1), true, false);
|
|
||||||
let mut merger = ListThroughMerger::new(0, Some(&resume));
|
|
||||||
merger
|
|
||||||
.push_page(MergeSide::Source, vec![ListEntryKey::object("result")], true, Some("B".into()))
|
|
||||||
.expect("source page");
|
|
||||||
let outcome = merger.finish(false).expect("a zero-sized request cannot consume entries");
|
|
||||||
assert!(outcome.picks.is_empty());
|
|
||||||
assert_eq!(outcome.next_token.expect("unconsumed source").no_progress, resume.no_progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_plain_local_marker_stays_local() {
|
fn a_plain_local_marker_stays_local() {
|
||||||
for marker in [
|
|
||||||
r#"{"t":"odm-list","v":1}"#,
|
|
||||||
r#"{"t":"odm-list","v":2,"local_done":true}"#,
|
|
||||||
r#"{"t":"odm-list"}"#,
|
|
||||||
] {
|
|
||||||
assert_eq!(decode_continuation_token(marker), Ok(ListThroughCursor::Local(marker.to_string())));
|
|
||||||
}
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_continuation_token("photos/2024/01.jpg"),
|
decode_continuation_token("photos/2024/01.jpg"),
|
||||||
Ok(ListThroughCursor::Local("photos/2024/01.jpg".to_string()))
|
Ok(ListThroughCursor::Local("photos/2024/01.jpg".to_string()))
|
||||||
+7
-30
@@ -19,45 +19,30 @@
|
|||||||
//! client, and the per-node runtime (`sys`) that turns configs into live
|
//! client, and the per-node runtime (`sys`) that turns configs into live
|
||||||
//! clients guarded by a breaker, a negative cache, singleflight and a pull
|
//! clients guarded by a breaker, a negative cache, singleflight and a pull
|
||||||
//! concurrency limit (rustfs/backlog#2147).
|
//! concurrency limit (rustfs/backlog#2147).
|
||||||
//!
|
|
||||||
//! A source is reached through one `SourceBackend`: the S3 dialect for every
|
|
||||||
//! S3-compatible provider, and a native backend for the providers that have no
|
|
||||||
//! S3 API (`azure`, `gcs_native`).
|
|
||||||
|
|
||||||
pub mod azure;
|
|
||||||
#[cfg(test)]
|
|
||||||
mod backend_contract;
|
|
||||||
pub mod backfill;
|
pub mod backfill;
|
||||||
pub mod breaker;
|
pub mod breaker;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
#[cfg(feature = "gcs")]
|
|
||||||
pub mod gcs;
|
|
||||||
pub mod list_through;
|
pub mod list_through;
|
||||||
mod metrics;
|
|
||||||
mod native_http;
|
|
||||||
pub mod negative_cache;
|
pub mod negative_cache;
|
||||||
pub mod pull;
|
pub mod pull;
|
||||||
pub mod source_client;
|
pub mod source_client;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
mod storage_api;
|
|
||||||
pub mod sys;
|
pub mod sys;
|
||||||
#[cfg(test)]
|
|
||||||
mod test_http_fixture;
|
|
||||||
|
|
||||||
pub use breaker::{
|
pub use breaker::{
|
||||||
BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION, Breaker,
|
BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION, Breaker,
|
||||||
BreakerState, BreakerTransition, BreakerVerdict,
|
BreakerState, BreakerTransition, BreakerVerdict,
|
||||||
};
|
};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
AzureSourceConfig, FilterConfig, GcsSourceConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_VERSION, OnDemandMigrationConfig,
|
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
|
||||||
OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy, SourceConfig, SourceCredentials,
|
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy, SourceConfig,
|
||||||
SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
|
SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
|
||||||
};
|
};
|
||||||
pub use list_through::{
|
pub use list_through::{
|
||||||
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListPageError, ListThroughCursor, ListThroughMerger,
|
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
|
||||||
ListThroughToken, ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MAX_LIST_NO_PROGRESS_PAGES, MergeOutcome, MergePick,
|
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
|
||||||
MergeSide, SOURCE_LIST_MAX_RATE_WAIT, SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter,
|
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
|
||||||
decode_continuation_token, source_list_plan,
|
|
||||||
};
|
};
|
||||||
pub use negative_cache::{NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache};
|
pub use negative_cache::{NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache};
|
||||||
pub use pull::{
|
pub use pull::{
|
||||||
@@ -65,19 +50,11 @@ pub use pull::{
|
|||||||
PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody, WriteBackError,
|
PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody, WriteBackError,
|
||||||
WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with, idle_guarded_body,
|
WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with, idle_guarded_body,
|
||||||
};
|
};
|
||||||
pub use source_client::{
|
|
||||||
SourceClient, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage, SourceSse, is_multipart_etag,
|
|
||||||
};
|
|
||||||
pub use stats::{
|
pub use stats::{
|
||||||
GaugeGuard, LastSourceError, LatencyBucketSnapshot, OdmOp, OdmOutcome, OdmStats, OdmStatsSnapshot, PullFailureReason,
|
GaugeGuard, LastSourceError, LatencyBucketSnapshot, OdmOp, OdmOutcome, OdmStats, OdmStatsSnapshot, PullFailureReason,
|
||||||
PullPath, SOURCE_LATENCY_BUCKET_BOUNDS_MS, SourceLatencySnapshot,
|
PullPath, SOURCE_LATENCY_BUCKET_BOUNDS_MS, SourceLatencySnapshot,
|
||||||
};
|
};
|
||||||
pub use sys::{
|
pub use sys::{
|
||||||
ApplyOutcome, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, OdmBucketSnapshot, OdmLookup, OdmStateError,
|
ApplyOutcome, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, OdmBucketSnapshot, OdmLookup, OdmStateError,
|
||||||
OnDemandMigrationSys, PullError, PullFollower, PullLeader, PullOutcome, PullResult, PullSlot, source_backend_spec,
|
OnDemandMigrationSys, PullError, PullFollower, PullLeader, PullOutcome, PullResult, PullSlot, source_client_spec,
|
||||||
source_client_spec,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn register_metrics() {
|
|
||||||
metrics::register();
|
|
||||||
}
|
|
||||||
+25
-59
@@ -46,10 +46,10 @@ use super::stats::{PullFailureReason, PullPath};
|
|||||||
use super::sys::{BucketOdmState, OnDemandMigrationSys, PullError, PullOutcome, PullSlot};
|
use super::sys::{BucketOdmState, OnDemandMigrationSys, PullError, PullOutcome, PullSlot};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::{FutureExt, Stream, StreamExt, future::Shared};
|
use futures::{Stream, StreamExt};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -133,8 +133,6 @@ pub enum QueuedPullOutcome {
|
|||||||
Failed(PullError),
|
Failed(PullError),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type QueuedPullReport = Shared<oneshot::Receiver<QueuedPullOutcome>>;
|
|
||||||
|
|
||||||
/// Result of [`PullQueue::enqueue`].
|
/// Result of [`PullQueue::enqueue`].
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub enum EnqueueOutcome {
|
pub enum EnqueueOutcome {
|
||||||
@@ -243,8 +241,6 @@ impl PullSource for SourceClient {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct WriteBackRequest {
|
pub struct WriteBackRequest {
|
||||||
pub bucket: String,
|
pub bucket: String,
|
||||||
/// Identity captured with the source configuration, retained through cleanup.
|
|
||||||
pub bucket_incarnation_id: uuid::Uuid,
|
|
||||||
pub key: String,
|
pub key: String,
|
||||||
/// Source HEAD/GET of the whole object.
|
/// Source HEAD/GET of the whole object.
|
||||||
pub head: SourceHead,
|
pub head: SourceHead,
|
||||||
@@ -255,7 +251,6 @@ pub struct WriteBackRequest {
|
|||||||
pub preserve_etag: bool,
|
pub preserve_etag: bool,
|
||||||
/// `policy.emit_events`.
|
/// `policy.emit_events`.
|
||||||
pub emit_events: bool,
|
pub emit_events: bool,
|
||||||
pub respect_delete_marker: bool,
|
|
||||||
/// Source tags to copy (`policy.copy_tags`), `None` to skip.
|
/// Source tags to copy (`policy.copy_tags`), `None` to skip.
|
||||||
pub tags: Option<HashMap<String, String>>,
|
pub tags: Option<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
@@ -265,14 +260,12 @@ impl WriteBackRequest {
|
|||||||
let config = state.config();
|
let config = state.config();
|
||||||
Self {
|
Self {
|
||||||
bucket: state.bucket().to_string(),
|
bucket: state.bucket().to_string(),
|
||||||
bucket_incarnation_id: state.incarnation_id(),
|
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
head,
|
head,
|
||||||
source_label: format!("{}:{}", config.source.provider.as_str(), config.source.bucket),
|
source_label: format!("{}:{}", config.source.provider.as_str(), config.source.bucket),
|
||||||
pulled_at: OffsetDateTime::now_utc(),
|
pulled_at: OffsetDateTime::now_utc(),
|
||||||
preserve_etag: config.policy.preserve_etag,
|
preserve_etag: config.policy.preserve_etag,
|
||||||
emit_events: config.policy.emit_events,
|
emit_events: config.policy.emit_events,
|
||||||
respect_delete_marker: config.policy.respect_local_delete_marker,
|
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,7 +353,7 @@ pub trait OdmWriteBack: Send + Sync {
|
|||||||
parts: Vec<WriteBackPart>,
|
parts: Vec<WriteBackPart>,
|
||||||
) -> Result<WriteBackOutcome, WriteBackError>;
|
) -> Result<WriteBackOutcome, WriteBackError>;
|
||||||
|
|
||||||
async fn abort_multipart_upload(&self, request: &WriteBackRequest, upload_id: &str) -> Result<(), WriteBackError>;
|
async fn abort_multipart_upload(&self, bucket: &str, key: &str, upload_id: &str) -> Result<(), WriteBackError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why the pump stopped feeding the write-back before EOF.
|
/// Why the pump stopped feeding the write-back before EOF.
|
||||||
@@ -665,7 +658,9 @@ async fn write_multipart(
|
|||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
};
|
};
|
||||||
if completed.is_err()
|
if completed.is_err()
|
||||||
&& let Err(abort_err) = write_back.abort_multipart_upload(request, &upload_id).await
|
&& let Err(abort_err) = write_back
|
||||||
|
.abort_multipart_upload(&request.bucket, &request.key, &upload_id)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
debug!(
|
debug!(
|
||||||
event = EVENT_ODM_PULL_FAILED,
|
event = EVENT_ODM_PULL_FAILED,
|
||||||
@@ -835,7 +830,7 @@ pub struct PullQueue {
|
|||||||
bucket: String,
|
bucket: String,
|
||||||
tx: mpsc::Sender<PullJob>,
|
tx: mpsc::Sender<PullJob>,
|
||||||
/// Keys queued or running; the job removes its key when it ends.
|
/// Keys queued or running; the job removes its key when it ends.
|
||||||
pending: Mutex<HashMap<String, QueuedPullReport>>,
|
pending: Mutex<HashSet<String>>,
|
||||||
capacity: usize,
|
capacity: usize,
|
||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
stats: Arc<super::stats::OdmStats>,
|
stats: Arc<super::stats::OdmStats>,
|
||||||
@@ -874,7 +869,7 @@ impl PullQueue {
|
|||||||
let queue = Arc::new(Self {
|
let queue = Arc::new(Self {
|
||||||
bucket: state.bucket().to_string(),
|
bucket: state.bucket().to_string(),
|
||||||
tx,
|
tx,
|
||||||
pending: Mutex::new(HashMap::new()),
|
pending: Mutex::new(HashSet::new()),
|
||||||
capacity,
|
capacity,
|
||||||
cancel: state.cancel_token(),
|
cancel: state.cancel_token(),
|
||||||
stats: Arc::clone(state.stats()),
|
stats: Arc::clone(state.stats()),
|
||||||
@@ -908,24 +903,29 @@ impl PullQueue {
|
|||||||
self.enqueue_with_report(key, reason).0
|
self.enqueue_with_report(key, reason).0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`Self::enqueue`] with a shared report, including for coalesced pulls.
|
/// [`Self::enqueue`] that also hands back the job's report channel when
|
||||||
pub fn enqueue_with_report(&self, key: &str, reason: PullReason) -> (EnqueueOutcome, Option<QueuedPullReport>) {
|
/// a new job was queued (`Coalesced` pulls report to their first
|
||||||
|
/// requester only).
|
||||||
|
pub fn enqueue_with_report(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
reason: PullReason,
|
||||||
|
) -> (EnqueueOutcome, Option<oneshot::Receiver<QueuedPullOutcome>>) {
|
||||||
if self.cancel.is_cancelled() {
|
if self.cancel.is_cancelled() {
|
||||||
return (EnqueueOutcome::Unavailable, None);
|
return (EnqueueOutcome::Unavailable, None);
|
||||||
}
|
}
|
||||||
let mut pending = self.pending.lock();
|
let mut pending = self.pending.lock();
|
||||||
if let Some(report) = pending.get(key) {
|
if pending.contains(key) {
|
||||||
return (EnqueueOutcome::Coalesced, Some(report.clone()));
|
return (EnqueueOutcome::Coalesced, None);
|
||||||
}
|
}
|
||||||
let (report_tx, report_rx) = oneshot::channel();
|
let (report_tx, report_rx) = oneshot::channel();
|
||||||
let report_rx = report_rx.shared();
|
|
||||||
match self.tx.try_send(PullJob {
|
match self.tx.try_send(PullJob {
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
reason,
|
reason,
|
||||||
report: Some(report_tx),
|
report: Some(report_tx),
|
||||||
}) {
|
}) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
pending.insert(key.to_string(), report_rx.clone());
|
pending.insert(key.to_string());
|
||||||
(EnqueueOutcome::Enqueued, Some(report_rx))
|
(EnqueueOutcome::Enqueued, Some(report_rx))
|
||||||
}
|
}
|
||||||
Err(TrySendError::Full(_)) => {
|
Err(TrySendError::Full(_)) => {
|
||||||
@@ -1072,7 +1072,7 @@ impl BucketOdmState {
|
|||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
key: &str,
|
key: &str,
|
||||||
reason: PullReason,
|
reason: PullReason,
|
||||||
) -> (EnqueueOutcome, Option<QueuedPullReport>) {
|
) -> (EnqueueOutcome, Option<oneshot::Receiver<QueuedPullOutcome>>) {
|
||||||
match self.pull_queue() {
|
match self.pull_queue() {
|
||||||
Some(queue) => queue.enqueue_with_report(key, reason),
|
Some(queue) => queue.enqueue_with_report(key, reason),
|
||||||
None => (EnqueueOutcome::Unavailable, None),
|
None => (EnqueueOutcome::Unavailable, None),
|
||||||
@@ -1094,7 +1094,7 @@ impl OnDemandMigrationSys {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::on_demand_migration::config::{
|
use crate::bucket::on_demand_migration::config::{
|
||||||
FilterConfig, OnDemandMigrationConfig, PathStyle as ConfigPathStyle, PolicyConfig, Provider, SourceConfig,
|
FilterConfig, OnDemandMigrationConfig, PathStyle as ConfigPathStyle, PolicyConfig, Provider, SourceConfig,
|
||||||
SourceCredentials, TlsConfig,
|
SourceCredentials, TlsConfig,
|
||||||
};
|
};
|
||||||
@@ -1119,8 +1119,6 @@ mod tests {
|
|||||||
session_token: None,
|
session_token: None,
|
||||||
}),
|
}),
|
||||||
tls: TlsConfig::default(),
|
tls: TlsConfig::default(),
|
||||||
azure: None,
|
|
||||||
gcs: None,
|
|
||||||
},
|
},
|
||||||
filter: FilterConfig::default(),
|
filter: FilterConfig::default(),
|
||||||
policy: PolicyConfig::default(),
|
policy: PolicyConfig::default(),
|
||||||
@@ -1341,7 +1339,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn abort_multipart_upload(&self, _request: &WriteBackRequest, upload_id: &str) -> Result<(), WriteBackError> {
|
async fn abort_multipart_upload(&self, _bucket: &str, _key: &str, upload_id: &str) -> Result<(), WriteBackError> {
|
||||||
self.aborted.lock().push(upload_id.to_string());
|
self.aborted.lock().push(upload_id.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1401,21 +1399,13 @@ mod tests {
|
|||||||
assert_eq!(queue.capacity(), 1024);
|
assert_eq!(queue.capacity(), 1024);
|
||||||
|
|
||||||
let mut outcomes = HashMap::new();
|
let mut outcomes = HashMap::new();
|
||||||
let mut shared_report = None;
|
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
let (outcome, report) = queue.enqueue_with_report("a", PullReason::RangeGet);
|
*outcomes.entry(queue.enqueue("a", PullReason::RangeGet)).or_insert(0) += 1;
|
||||||
*outcomes.entry(outcome).or_insert(0) += 1;
|
|
||||||
shared_report = report;
|
|
||||||
}
|
}
|
||||||
assert_eq!(outcomes.get(&EnqueueOutcome::Enqueued), Some(&1));
|
assert_eq!(outcomes.get(&EnqueueOutcome::Enqueued), Some(&1));
|
||||||
assert_eq!(outcomes.get(&EnqueueOutcome::Coalesced), Some(&99));
|
assert_eq!(outcomes.get(&EnqueueOutcome::Coalesced), Some(&99));
|
||||||
assert_eq!(queue.pending_keys(), 1);
|
assert_eq!(queue.pending_keys(), 1);
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
shared_report.expect("coalesced report").await,
|
|
||||||
Ok(QueuedPullOutcome::Stored { size: 1000 })
|
|
||||||
);
|
|
||||||
|
|
||||||
wait_until("first pull to finish", || queue.pending_keys() == 0).await;
|
wait_until("first pull to finish", || queue.pending_keys() == 0).await;
|
||||||
assert_eq!(source.head_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(source.head_calls.load(Ordering::SeqCst), 1);
|
||||||
assert_eq!(source.get_calls.load(Ordering::SeqCst), 1);
|
assert_eq!(source.get_calls.load(Ordering::SeqCst), 1);
|
||||||
@@ -1448,23 +1438,6 @@ mod tests {
|
|||||||
assert_eq!(queue.enqueue("a", PullReason::RangeGet), EnqueueOutcome::Unavailable);
|
assert_eq!(queue.enqueue("a", PullReason::RangeGet), EnqueueOutcome::Unavailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn coalesced_enqueues_share_failure_reports() {
|
|
||||||
let sys = OnDemandMigrationSys::new();
|
|
||||||
let state = enabled_state(&sys, &config()).await;
|
|
||||||
let source = MockSource::with_object("missing", 1000, BodyKind::Bytes(body_bytes(1000)));
|
|
||||||
let queue = PullQueue::start(Arc::clone(&state), source, Arc::new(MockWriteBack::default()));
|
|
||||||
let (first, first_report) = queue.enqueue_with_report("absent", PullReason::RangeGet);
|
|
||||||
let (second, second_report) = queue.enqueue_with_report("absent", PullReason::Backfill);
|
|
||||||
assert_eq!(first, EnqueueOutcome::Enqueued);
|
|
||||||
assert_eq!(second, EnqueueOutcome::Coalesced);
|
|
||||||
let (first, second) = tokio::join!(first_report.expect("leader report"), second_report.expect("coalesced report"));
|
|
||||||
assert_eq!(first, second);
|
|
||||||
assert!(matches!(first, Ok(QueuedPullOutcome::Failed(_))));
|
|
||||||
sys.remove(BUCKET);
|
|
||||||
queue.wait_until_stopped().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn queue_full_is_reported_and_cancel_drains_without_leaking_tasks() {
|
async fn queue_full_is_reported_and_cancel_drains_without_leaking_tasks() {
|
||||||
let sys = OnDemandMigrationSys::new();
|
let sys = OnDemandMigrationSys::new();
|
||||||
@@ -1494,23 +1467,16 @@ mod tests {
|
|||||||
wait_until("dispatcher to wait for a slot", || state.stats().queue_depth() == 1).await;
|
wait_until("dispatcher to wait for a slot", || state.stats().queue_depth() == 1).await;
|
||||||
assert_eq!(queue.enqueue("c", PullReason::LargeObject), EnqueueOutcome::Enqueued);
|
assert_eq!(queue.enqueue("c", PullReason::LargeObject), EnqueueOutcome::Enqueued);
|
||||||
assert_eq!(queue.enqueue("d", PullReason::LargeObject), EnqueueOutcome::QueueFull);
|
assert_eq!(queue.enqueue("d", PullReason::LargeObject), EnqueueOutcome::QueueFull);
|
||||||
let (coalesced, canceled_report) = queue.enqueue_with_report("c", PullReason::LargeObject);
|
assert_eq!(queue.enqueue("c", PullReason::LargeObject), EnqueueOutcome::Coalesced);
|
||||||
assert_eq!(coalesced, EnqueueOutcome::Coalesced);
|
|
||||||
assert_eq!(queue.pending_keys(), 3);
|
assert_eq!(queue.pending_keys(), 3);
|
||||||
assert_eq!(failures(&state).get("queue_full"), Some(&1));
|
assert_eq!(failures(&state).get("queue_full"), Some(&1));
|
||||||
assert!(!queue.is_stopped());
|
assert!(!queue.is_stopped());
|
||||||
|
|
||||||
assert_eq!(sys.remove(BUCKET), crate::on_demand_migration::ApplyOutcome::Removed);
|
assert_eq!(sys.remove(BUCKET), crate::bucket::on_demand_migration::ApplyOutcome::Removed);
|
||||||
tokio::time::timeout(Duration::from_secs(5), queue.wait_until_stopped())
|
tokio::time::timeout(Duration::from_secs(5), queue.wait_until_stopped())
|
||||||
.await
|
.await
|
||||||
.expect("dispatcher and in-flight job must exit after cancel");
|
.expect("dispatcher and in-flight job must exit after cancel");
|
||||||
assert!(queue.is_stopped());
|
assert!(queue.is_stopped());
|
||||||
assert!(
|
|
||||||
tokio::time::timeout(Duration::from_secs(5), canceled_report.expect("coalesced cancellation report"))
|
|
||||||
.await
|
|
||||||
.expect("cancellation closes the report")
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert_eq!(queue.pending_keys(), 0);
|
assert_eq!(queue.pending_keys(), 0);
|
||||||
assert_eq!(state.inflight_keys(), 0);
|
assert_eq!(state.inflight_keys(), 0);
|
||||||
assert_eq!(state.stats().inflight_pulls(), 0);
|
assert_eq!(state.stats().inflight_pulls(), 0);
|
||||||
+30
-333
@@ -25,14 +25,11 @@
|
|||||||
//! Client-supplied `If-*`, `Authorization`, `Host` and SSE-C headers are never
|
//! Client-supplied `If-*`, `Authorization`, `Host` and SSE-C headers are never
|
||||||
//! forwarded: v1 rejects SSE-C source objects outright.
|
//! forwarded: v1 rejects SSE-C source objects outright.
|
||||||
|
|
||||||
use super::azure::AzureSourceBackend;
|
|
||||||
#[cfg(feature = "gcs")]
|
|
||||||
use super::gcs::GcsNativeSourceBackend;
|
|
||||||
use super::list_through::{ListPageError, validate_list_page};
|
use super::list_through::{ListPageError, validate_list_page};
|
||||||
use super::storage_api::HTTPRangeSpec;
|
use crate::bucket::remote_s3_client::{
|
||||||
use super::storage_api::remote_s3_client::{
|
|
||||||
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_config,
|
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_config,
|
||||||
};
|
};
|
||||||
|
use crate::storage_api_contracts::range::HTTPRangeSpec;
|
||||||
use aws_sdk_s3::Client as S3Client;
|
use aws_sdk_s3::Client as S3Client;
|
||||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||||
use aws_sdk_s3::operation::get_object::GetObjectOutput;
|
use aws_sdk_s3::operation::get_object::GetObjectOutput;
|
||||||
@@ -68,10 +65,6 @@ pub enum SourceProvider {
|
|||||||
/// Generic S3-compatible service.
|
/// Generic S3-compatible service.
|
||||||
#[default]
|
#[default]
|
||||||
S3,
|
S3,
|
||||||
/// Native Azure Blob service; not an S3 dialect.
|
|
||||||
Azure,
|
|
||||||
/// Native GCS JSON API with a service-account key; not an S3 dialect.
|
|
||||||
GcsNative,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SourceProvider {
|
impl SourceProvider {
|
||||||
@@ -83,8 +76,6 @@ impl SourceProvider {
|
|||||||
"minio" => Some(Self::Minio),
|
"minio" => Some(Self::Minio),
|
||||||
"rustfs" => Some(Self::Rustfs),
|
"rustfs" => Some(Self::Rustfs),
|
||||||
"s3" => Some(Self::S3),
|
"s3" => Some(Self::S3),
|
||||||
"azure" => Some(Self::Azure),
|
|
||||||
"gcs_native" => Some(Self::GcsNative),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,8 +88,6 @@ impl SourceProvider {
|
|||||||
Self::Minio => "minio",
|
Self::Minio => "minio",
|
||||||
Self::Rustfs => "rustfs",
|
Self::Rustfs => "rustfs",
|
||||||
Self::S3 => "s3",
|
Self::S3 => "s3",
|
||||||
Self::Azure => "azure",
|
|
||||||
Self::GcsNative => "gcs_native",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,75 +153,12 @@ pub struct SourceClientSpec {
|
|||||||
/// Wire requests one logical source call may cost. The pull pipeline and
|
/// Wire requests one logical source call may cost. The pull pipeline and
|
||||||
/// the backfill job own the retry budget (`pull.rs` `PULL_MAX_RETRIES`,
|
/// the backfill job own the retry budget (`pull.rs` `PULL_MAX_RETRIES`,
|
||||||
/// `backfill.rs` `LIST_MAX_RETRIES`) and the breaker counts logical calls,
|
/// `backfill.rs` `LIST_MAX_RETRIES`) and the breaker counts logical calls,
|
||||||
/// so ODM declares [`RemoteS3RetryPolicy::Disabled`]. An ambiguous HEAD
|
/// so ODM declares [`RemoteS3RetryPolicy::Disabled`] and keeps one counted
|
||||||
/// 404 additionally probes the bucket before declaring a key absent.
|
/// failure equal to one request against a struggling source.
|
||||||
pub retry: RemoteS3RetryPolicy,
|
pub retry: RemoteS3RetryPolicy,
|
||||||
/// Bytes per second the pull pipeline may consume from this source;
|
/// Bytes per second the pull pipeline may consume from this source;
|
||||||
/// `None` means unlimited. Enforced by the consumer, not by this client.
|
/// `None` means unlimited. Enforced by the consumer, not by this client.
|
||||||
pub bandwidth_limit: Option<NonZeroU64>,
|
pub bandwidth_limit: Option<NonZeroU64>,
|
||||||
/// Which [`SourceBackend`] to build. The S3 variant reads `region`,
|
|
||||||
/// `path_style` and `credentials`; the native variants ignore all three
|
|
||||||
/// and carry their own credentials.
|
|
||||||
pub backend: SourceBackendSpec,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provider-specific half of [`SourceClientSpec`].
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
||||||
pub enum SourceBackendSpec {
|
|
||||||
#[default]
|
|
||||||
S3,
|
|
||||||
Azure(AzureSourceSpec),
|
|
||||||
Gcs(GcsSourceSpec),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Native Azure Blob parameters. The container is [`SourceClientSpec::bucket`].
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub struct AzureSourceSpec {
|
|
||||||
pub account: String,
|
|
||||||
pub auth: AzureAuth,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for AzureSourceSpec {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("AzureSourceSpec")
|
|
||||||
.field("account", &self.account)
|
|
||||||
.field("auth", &self.auth)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How Azure requests are authorized.
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub enum AzureAuth {
|
|
||||||
/// Base64 storage-account key, signed per request with Shared Key.
|
|
||||||
SharedKey(String),
|
|
||||||
/// SAS query string without the leading `?`, appended to every URL.
|
|
||||||
Sas(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for AzureAuth {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
// Both variants are secrets; only the scheme may be rendered.
|
|
||||||
f.write_str(match self {
|
|
||||||
Self::SharedKey(_) => "SharedKey(REDACTED)",
|
|
||||||
Self::Sas(_) => "Sas(REDACTED)",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Native GCS parameters. The bucket is [`SourceClientSpec::bucket`].
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub struct GcsSourceSpec {
|
|
||||||
/// Service-account key JSON.
|
|
||||||
pub service_account_json: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for GcsSourceSpec {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("GcsSourceSpec")
|
|
||||||
.field("service_account_json", &"REDACTED")
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SourceClientSpec {
|
impl SourceClientSpec {
|
||||||
@@ -335,9 +261,8 @@ const THROTTLE_CODES: &[&str] = &[
|
|||||||
"RequestLimitExceeded",
|
"RequestLimitExceeded",
|
||||||
"TooManyRequests",
|
"TooManyRequests",
|
||||||
"RequestThrottled",
|
"RequestThrottled",
|
||||||
"ServerBusy",
|
|
||||||
];
|
];
|
||||||
const NOT_FOUND_CODES: &[&str] = &["NoSuchKey"];
|
const NOT_FOUND_CODES: &[&str] = &["NoSuchKey", "NotFound", "NoSuchBucket", "NoSuchVersion"];
|
||||||
const ACCESS_DENIED_CODES: &[&str] = &[
|
const ACCESS_DENIED_CODES: &[&str] = &[
|
||||||
"AccessDenied",
|
"AccessDenied",
|
||||||
"InvalidAccessKeyId",
|
"InvalidAccessKeyId",
|
||||||
@@ -345,10 +270,9 @@ const ACCESS_DENIED_CODES: &[&str] = &[
|
|||||||
"AllAccessDisabled",
|
"AllAccessDisabled",
|
||||||
"ExpiredToken",
|
"ExpiredToken",
|
||||||
"InvalidToken",
|
"InvalidToken",
|
||||||
"AuthorizationPermissionMismatch",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
pub(super) fn classify_status(status: u16, code: Option<&str>, message: String) -> SourceError {
|
fn classify_status(status: u16, code: Option<&str>, message: String) -> SourceError {
|
||||||
if let Some(code) = code {
|
if let Some(code) = code {
|
||||||
if THROTTLE_CODES.contains(&code) {
|
if THROTTLE_CODES.contains(&code) {
|
||||||
return SourceError::Throttled;
|
return SourceError::Throttled;
|
||||||
@@ -361,6 +285,7 @@ pub(super) fn classify_status(status: u16, code: Option<&str>, message: String)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
match status {
|
match status {
|
||||||
|
404 => SourceError::NotFound,
|
||||||
401 | 403 => SourceError::AccessDenied,
|
401 | 403 => SourceError::AccessDenied,
|
||||||
429 | 503 => SourceError::Throttled,
|
429 | 503 => SourceError::Throttled,
|
||||||
500..=599 => SourceError::ServerError(status),
|
500..=599 => SourceError::ServerError(status),
|
||||||
@@ -420,11 +345,6 @@ pub struct SourceHead {
|
|||||||
pub storage_class: Option<String>,
|
pub storage_class: Option<String>,
|
||||||
pub sse: Option<SourceSse>,
|
pub sse: Option<SourceSse>,
|
||||||
pub is_multipart_etag: bool,
|
pub is_multipart_etag: bool,
|
||||||
/// The provider's ETag is not derived from the object bytes (Azure
|
|
||||||
/// stamps an opaque concurrency token). Such an ETag is recorded for
|
|
||||||
/// provenance but must never be read as a content digest, so the
|
|
||||||
/// write-back path refuses to use it as the expected MD5.
|
|
||||||
pub etag_is_opaque: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-operation fields shared by HEAD and GET outputs.
|
/// Per-operation fields shared by HEAD and GET outputs.
|
||||||
@@ -446,7 +366,7 @@ struct HeadParts {
|
|||||||
sse_customer_algorithm: Option<String>,
|
sse_customer_algorithm: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn normalize_etag(etag: Option<String>) -> Option<String> {
|
fn normalize_etag(etag: Option<String>) -> Option<String> {
|
||||||
etag.map(|etag| etag.trim().trim_matches('"').to_string())
|
etag.map(|etag| etag.trim().trim_matches('"').to_string())
|
||||||
.filter(|etag| !etag.is_empty())
|
.filter(|etag| !etag.is_empty())
|
||||||
}
|
}
|
||||||
@@ -495,7 +415,6 @@ fn source_head(parts: HeadParts) -> Result<SourceHead, SourceError> {
|
|||||||
storage_class: parts.storage_class,
|
storage_class: parts.storage_class,
|
||||||
sse,
|
sse,
|
||||||
is_multipart_etag,
|
is_multipart_etag,
|
||||||
etag_is_opaque: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,55 +625,14 @@ impl fmt::Debug for SourceClient {
|
|||||||
|
|
||||||
impl SourceClient {
|
impl SourceClient {
|
||||||
pub async fn new(spec: &SourceClientSpec) -> Result<Self, RemoteS3ClientError> {
|
pub async fn new(spec: &SourceClientSpec) -> Result<Self, RemoteS3ClientError> {
|
||||||
match &spec.backend {
|
|
||||||
SourceBackendSpec::S3 => {
|
|
||||||
let endpoint = spec.endpoint_spec()?;
|
let endpoint = spec.endpoint_spec()?;
|
||||||
let config = build_remote_s3_config(&endpoint).await?;
|
let config = build_remote_s3_config(&endpoint).await?;
|
||||||
Ok(Self::from_config_builder(config, endpoint.endpoint_url(), spec))
|
Ok(Self::from_config_builder(config, endpoint.endpoint_url(), spec))
|
||||||
}
|
}
|
||||||
SourceBackendSpec::Azure(azure) => {
|
|
||||||
let backend = AzureSourceBackend::new(
|
|
||||||
&spec.endpoint,
|
|
||||||
&spec.bucket,
|
|
||||||
azure,
|
|
||||||
spec.timeouts,
|
|
||||||
spec.skip_tls_verify,
|
|
||||||
spec.ca_cert_pem.as_deref(),
|
|
||||||
)?;
|
|
||||||
Ok(Self::from_backend(Box::new(backend), spec))
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "gcs"))]
|
|
||||||
SourceBackendSpec::Gcs(_) => Err(RemoteS3ClientError::BackendNotCompiled("gcs_native")),
|
|
||||||
#[cfg(feature = "gcs")]
|
|
||||||
SourceBackendSpec::Gcs(gcs) => {
|
|
||||||
let backend = GcsNativeSourceBackend::new(
|
|
||||||
&spec.endpoint,
|
|
||||||
&spec.bucket,
|
|
||||||
gcs,
|
|
||||||
spec.timeouts,
|
|
||||||
spec.skip_tls_verify,
|
|
||||||
spec.ca_cert_pem.as_deref(),
|
|
||||||
)?;
|
|
||||||
Ok(Self::from_backend(Box::new(backend), spec))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wraps a ready backend in the prefix-mapping client. The endpoint is
|
|
||||||
/// kept only for `Debug` and admin status.
|
|
||||||
fn from_backend(backend: Box<dyn SourceBackend>, spec: &SourceClientSpec) -> Self {
|
|
||||||
Self {
|
|
||||||
backend,
|
|
||||||
endpoint: spec.endpoint.clone(),
|
|
||||||
bucket: spec.bucket.clone(),
|
|
||||||
source_prefix: spec.source_prefix.clone().filter(|prefix| !prefix.is_empty()),
|
|
||||||
timeouts: spec.timeouts,
|
|
||||||
bandwidth_limit: spec.bandwidth_limit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `config` must come from [`SourceClientSpec::endpoint_spec`], which is
|
/// `config` must come from [`SourceClientSpec::endpoint_spec`], which is
|
||||||
/// where the policy disabling SDK-level retries is declared.
|
/// where the retry policy that keeps one logical call equal to one wire
|
||||||
|
/// request is declared.
|
||||||
fn from_config_builder(config: aws_sdk_s3::config::Builder, endpoint: String, spec: &SourceClientSpec) -> Self {
|
fn from_config_builder(config: aws_sdk_s3::config::Builder, endpoint: String, spec: &SourceClientSpec) -> Self {
|
||||||
let client = S3Client::from_conf(config.interceptor(SourceProxyMarkerInterceptor::new()).build());
|
let client = S3Client::from_conf(config.interceptor(SourceProxyMarkerInterceptor::new()).build());
|
||||||
Self {
|
Self {
|
||||||
@@ -876,16 +754,15 @@ impl SourceClient {
|
|||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl SourceBackend for S3SourceBackend {
|
impl SourceBackend for S3SourceBackend {
|
||||||
async fn head(&self, key: &str) -> Result<SourceHead, SourceError> {
|
async fn head(&self, key: &str) -> Result<SourceHead, SourceError> {
|
||||||
match self.client.head_object().bucket(&self.bucket).key(key).send().await {
|
let output = self
|
||||||
Ok(output) => source_head_from_head_output(output),
|
.client
|
||||||
Err(err) if err.raw_response().is_some_and(|response| response.status().as_u16() == 404) => {
|
.head_object()
|
||||||
// HEAD has no error body: a missing bucket must not poison
|
.bucket(&self.bucket)
|
||||||
// the per-key negative cache as though only the key was absent.
|
.key(key)
|
||||||
self.probe().await?;
|
.send()
|
||||||
Err(SourceError::NotFound)
|
.await
|
||||||
}
|
.map_err(classify_sdk_error)?;
|
||||||
Err(err) => Err(classify_sdk_error(err)),
|
source_head_from_head_output(output)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Streams the object; `range` is passed through as an HTTP `Range`
|
/// Streams the object; `range` is passed through as an HTTP `Range`
|
||||||
@@ -932,8 +809,8 @@ impl SourceBackend for S3SourceBackend {
|
|||||||
.contents
|
.contents
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(s3_source_object)
|
.filter_map(s3_source_object)
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect();
|
||||||
let common_prefixes = output
|
let common_prefixes = output
|
||||||
.common_prefixes
|
.common_prefixes
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -972,20 +849,14 @@ impl SourceBackend for S3SourceBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn s3_source_object(object: SdkObject) -> Result<SourceObject, SourceError> {
|
fn s3_source_object(object: SdkObject) -> Option<SourceObject> {
|
||||||
let key = object
|
let key = object.key?;
|
||||||
.key
|
|
||||||
.ok_or_else(|| SourceError::Other("source listing object has no key".to_string()))?;
|
|
||||||
let size = object
|
|
||||||
.size
|
|
||||||
.and_then(|size| u64::try_from(size).ok())
|
|
||||||
.ok_or_else(|| SourceError::Other("source listing object has no valid size".to_string()))?;
|
|
||||||
let etag = normalize_etag(object.e_tag);
|
let etag = normalize_etag(object.e_tag);
|
||||||
let is_multipart_etag = etag.as_deref().is_some_and(is_multipart_etag);
|
let is_multipart_etag = etag.as_deref().is_some_and(is_multipart_etag);
|
||||||
Ok(SourceObject {
|
Some(SourceObject {
|
||||||
key,
|
key,
|
||||||
etag,
|
etag,
|
||||||
size,
|
size: object.size.and_then(|size| u64::try_from(size).ok()).unwrap_or(0),
|
||||||
last_modified: system_time(object.last_modified),
|
last_modified: system_time(object.last_modified),
|
||||||
storage_class: object.storage_class.map(|class| class.as_str().to_string()),
|
storage_class: object.storage_class.map(|class| class.as_str().to_string()),
|
||||||
is_multipart_etag,
|
is_multipart_etag,
|
||||||
@@ -995,7 +866,6 @@ fn s3_source_object(object: SdkObject) -> Result<SourceObject, SourceError> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::on_demand_migration::backend_contract::{BackendCapabilities, OBJECT_MD5, assert_backend_contract};
|
|
||||||
use aws_smithy_runtime_api::client::http::{HttpConnector, HttpConnectorFuture, SharedHttpConnector, http_client_fn};
|
use aws_smithy_runtime_api::client::http::{HttpConnector, HttpConnectorFuture, SharedHttpConnector, http_client_fn};
|
||||||
use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
|
use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
|
||||||
use aws_smithy_runtime_api::client::result::ConnectorError;
|
use aws_smithy_runtime_api::client::result::ConnectorError;
|
||||||
@@ -1113,32 +983,9 @@ mod tests {
|
|||||||
retry: RemoteS3RetryPolicy::Disabled,
|
retry: RemoteS3RetryPolicy::Disabled,
|
||||||
timeouts: SourceTimeouts::default(),
|
timeouts: SourceTimeouts::default(),
|
||||||
bandwidth_limit: NonZeroU64::new(1_000_000),
|
bandwidth_limit: NonZeroU64::new(1_000_000),
|
||||||
backend: SourceBackendSpec::S3,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "gcs"))]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn gcs_backend_not_compiled_keeps_hmac_s3_available() {
|
|
||||||
let mut native = spec(None);
|
|
||||||
native.provider = SourceProvider::GcsNative;
|
|
||||||
native.credentials = None;
|
|
||||||
native.backend = SourceBackendSpec::Gcs(GcsSourceSpec {
|
|
||||||
service_account_json: "{}".to_string(),
|
|
||||||
});
|
|
||||||
assert!(matches!(
|
|
||||||
SourceClient::new(&native).await,
|
|
||||||
Err(RemoteS3ClientError::BackendNotCompiled("gcs_native"))
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut hmac = spec(None);
|
|
||||||
hmac.provider = SourceProvider::Gcs;
|
|
||||||
hmac.endpoint = "https://storage.googleapis.com".to_string();
|
|
||||||
SourceClient::new(&hmac)
|
|
||||||
.await
|
|
||||||
.expect("GCS HMAC uses the always-available S3 backend");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn scripted_client(spec: &SourceClientSpec, responses: Vec<Scripted>) -> (SourceClient, Recorded) {
|
async fn scripted_client(spec: &SourceClientSpec, responses: Vec<Scripted>) -> (SourceClient, Recorded) {
|
||||||
let requests: Recorded = Arc::new(Mutex::new(Vec::new()));
|
let requests: Recorded = Arc::new(Mutex::new(Vec::new()));
|
||||||
let connector = SharedHttpConnector::new(ScriptedConnector {
|
let connector = SharedHttpConnector::new(ScriptedConnector {
|
||||||
@@ -1642,10 +1489,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn source_error_classification_covers_every_class() {
|
async fn source_error_classification_covers_every_class() {
|
||||||
let cases: Vec<(Scripted, &str, bool)> = vec![
|
let cases: Vec<(Scripted, &str, bool)> = vec![
|
||||||
(status(404, ""), "other", false),
|
(status(404, ""), "not_found", false),
|
||||||
(status(404, "<Error><Code>NoSuchKey</Code></Error>"), "not_found", false),
|
|
||||||
(status(404, "<Error><Code>NoSuchBucket</Code></Error>"), "other", false),
|
|
||||||
(status(404, "<Error><Code>NoSuchVersion</Code></Error>"), "other", false),
|
|
||||||
(status(403, ACCESS_DENIED_BODY), "access_denied", false),
|
(status(403, ACCESS_DENIED_BODY), "access_denied", false),
|
||||||
(status(401, ""), "access_denied", false),
|
(status(401, ""), "access_denied", false),
|
||||||
(status(429, ""), "throttled", true),
|
(status(429, ""), "throttled", true),
|
||||||
@@ -1668,35 +1512,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (client, requests) = scripted_client(&spec(None), vec![status(404, ""), status(200, "")]).await;
|
// HEAD carries no error body, so the classification must work from the
|
||||||
|
// status alone as well.
|
||||||
|
let (client, _) = scripted_client(&spec(None), vec![status(404, "")]).await;
|
||||||
assert!(matches!(client.head_object("missing").await, Err(SourceError::NotFound)));
|
assert!(matches!(client.head_object("missing").await, Err(SourceError::NotFound)));
|
||||||
assert_eq!(recorded(&requests).len(), 2, "ambiguous HEAD 404 must check the bucket");
|
|
||||||
let (client, _) = scripted_client(&spec(None), vec![status(404, ""), status(404, "")]).await;
|
|
||||||
assert!(matches!(client.head_object("missing").await, Err(SourceError::Other(_))));
|
|
||||||
let (client, _) = scripted_client(&spec(None), vec![status(404, ""), status(403, "")]).await;
|
|
||||||
assert!(matches!(client.head_object("missing").await, Err(SourceError::AccessDenied)));
|
|
||||||
let (client, _) = scripted_client(&spec(None), vec![status(403, "")]).await;
|
let (client, _) = scripted_client(&spec(None), vec![status(403, "")]).await;
|
||||||
assert!(matches!(client.head_object("secret").await, Err(SourceError::AccessDenied)));
|
assert!(matches!(client.head_object("secret").await, Err(SourceError::AccessDenied)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn source_listing_rejects_missing_and_negative_sizes() {
|
|
||||||
for size in [None, Some(-1)] {
|
|
||||||
let object = SdkObject::builder().key("key").set_size(size).build();
|
|
||||||
assert!(matches!(s3_source_object(object), Err(SourceError::Other(_))));
|
|
||||||
}
|
|
||||||
assert!(matches!(
|
|
||||||
s3_source_object(SdkObject::builder().size(0).build()),
|
|
||||||
Err(SourceError::Other(_))
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
s3_source_object(SdkObject::builder().key("empty").size(0).build())
|
|
||||||
.expect("empty object")
|
|
||||||
.size,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn source_client_debug_redacts_credentials() {
|
async fn source_client_debug_redacts_credentials() {
|
||||||
let (client, _) = scripted_client(&spec(Some("data/")), Vec::new()).await;
|
let (client, _) = scripted_client(&spec(Some("data/")), Vec::new()).await;
|
||||||
@@ -1763,133 +1586,7 @@ mod tests {
|
|||||||
assert_eq!(resolve_path_style(PathStyle::VirtualHost, Minio, "10.0.0.1"), PathStyle::VirtualHost);
|
assert_eq!(resolve_path_style(PathStyle::VirtualHost, Minio, "10.0.0.1"), PathStyle::VirtualHost);
|
||||||
assert_eq!(resolve_path_style(PathStyle::Path, Aws, "s3.amazonaws.com"), PathStyle::Path);
|
assert_eq!(resolve_path_style(PathStyle::Path, Aws, "s3.amazonaws.com"), PathStyle::Path);
|
||||||
assert_eq!(SourceProvider::from_label(" AWS "), Some(Aws));
|
assert_eq!(SourceProvider::from_label(" AWS "), Some(Aws));
|
||||||
assert_eq!(SourceProvider::from_label(" Azure "), Some(Azure));
|
assert_eq!(SourceProvider::from_label("azure"), None);
|
||||||
assert_eq!(SourceProvider::from_label("gcs_native"), Some(GcsNative));
|
|
||||||
assert_eq!(SourceProvider::from_label("swift"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTRACT_LIST_PAGE_ONE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
||||||
<Name>source-bucket</Name>
|
|
||||||
<IsTruncated>true</IsTruncated>
|
|
||||||
<NextContinuationToken>cursor-1</NextContinuationToken>
|
|
||||||
<Contents>
|
|
||||||
<Key>dir/a.txt</Key>
|
|
||||||
<LastModified>2015-10-21T07:28:00.000Z</LastModified>
|
|
||||||
<ETag>"5d41402abc4b2a76b9719d911017c592"</ETag>
|
|
||||||
<Size>5</Size>
|
|
||||||
<StorageClass>STANDARD</StorageClass>
|
|
||||||
</Contents>
|
|
||||||
<CommonPrefixes><Prefix>dir/sub/</Prefix></CommonPrefixes>
|
|
||||||
</ListBucketResult>"#;
|
|
||||||
|
|
||||||
const CONTRACT_LIST_PAGE_TWO: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
||||||
<Name>source-bucket</Name>
|
|
||||||
<IsTruncated>false</IsTruncated>
|
|
||||||
<Contents>
|
|
||||||
<Key>dir/b.txt</Key>
|
|
||||||
<LastModified>2015-10-21T07:28:00.000Z</LastModified>
|
|
||||||
<ETag>"7d41402abc4b2a76b9719d911017c592"</ETag>
|
|
||||||
<Size>7</Size>
|
|
||||||
</Contents>
|
|
||||||
</ListBucketResult>"#;
|
|
||||||
|
|
||||||
const CONTRACT_TAGGING: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Tagging xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><TagSet>
|
|
||||||
<Tag><Key>env</Key><Value>prod</Value></Tag>
|
|
||||||
</TagSet></Tagging>"#;
|
|
||||||
|
|
||||||
fn contract_object_headers(content_length: u64) -> Vec<(&'static str, String)> {
|
|
||||||
vec![
|
|
||||||
("etag", format!("\"{OBJECT_MD5}\"")),
|
|
||||||
("content-length", content_length.to_string()),
|
|
||||||
("content-type", "text/plain".to_string()),
|
|
||||||
("last-modified", "Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
|
|
||||||
("x-amz-meta-owner", "alice".to_string()),
|
|
||||||
("x-amz-storage-class", "STANDARD".to_string()),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The S3 backend behind the scripted connector, without the prefix-mapping
|
|
||||||
/// client on top: the contract is a property of the backend itself.
|
|
||||||
async fn scripted_s3_backend(responses: Vec<Scripted>) -> (S3SourceBackend, Recorded) {
|
|
||||||
let spec = spec(None);
|
|
||||||
let requests: Recorded = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let connector = SharedHttpConnector::new(ScriptedConnector {
|
|
||||||
requests: Arc::clone(&requests),
|
|
||||||
responses: Arc::new(Mutex::new(responses.into_iter().collect())),
|
|
||||||
});
|
|
||||||
let http_client = http_client_fn(move |_settings, _components| connector.clone());
|
|
||||||
let endpoint = spec.endpoint_spec().expect("test spec endpoint should parse");
|
|
||||||
let config = build_remote_s3_config(&endpoint)
|
|
||||||
.await
|
|
||||||
.expect("test spec should build")
|
|
||||||
.http_client(http_client)
|
|
||||||
.interceptor(SourceProxyMarkerInterceptor::new());
|
|
||||||
(
|
|
||||||
S3SourceBackend {
|
|
||||||
client: S3Client::from_conf(config.build()),
|
|
||||||
bucket: spec.bucket.clone(),
|
|
||||||
},
|
|
||||||
requests,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn s3_backend_satisfies_the_shared_backend_contract() {
|
|
||||||
let mut ranged = contract_object_headers(3);
|
|
||||||
ranged.push(("content-range", "bytes 1-3/5".to_string()));
|
|
||||||
let (backend, requests) = scripted_s3_backend(vec![
|
|
||||||
ok(contract_object_headers(5), ""),
|
|
||||||
ok(contract_object_headers(5), "hello"),
|
|
||||||
ok(ranged, "ell"),
|
|
||||||
ok(Vec::new(), CONTRACT_LIST_PAGE_ONE),
|
|
||||||
ok(Vec::new(), CONTRACT_LIST_PAGE_TWO),
|
|
||||||
ok(Vec::new(), CONTRACT_TAGGING),
|
|
||||||
ok(Vec::new(), ""),
|
|
||||||
status(404, ""),
|
|
||||||
// An object HEAD 404 requires the existing S3 bucket HEAD probe.
|
|
||||||
ok(Vec::new(), ""),
|
|
||||||
status(403, ACCESS_DENIED_BODY),
|
|
||||||
])
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_backend_contract(
|
|
||||||
&backend,
|
|
||||||
BackendCapabilities {
|
|
||||||
etag_is_opaque: false,
|
|
||||||
supports_start_after: true,
|
|
||||||
supports_tagging: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let requests = recorded(&requests);
|
|
||||||
let actual: Vec<_> = requests
|
|
||||||
.iter()
|
|
||||||
.map(|request| {
|
|
||||||
(
|
|
||||||
request.method.as_str(),
|
|
||||||
url::Url::parse(&request.uri).expect("recorded S3 URL").path().to_string(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let expected = [
|
|
||||||
("HEAD", "/source-bucket/dir/a.txt"),
|
|
||||||
("GET", "/source-bucket/dir/a.txt"),
|
|
||||||
("GET", "/source-bucket/dir/a.txt"),
|
|
||||||
("GET", "/source-bucket/"),
|
|
||||||
("GET", "/source-bucket/"),
|
|
||||||
("GET", "/source-bucket/dir/a.txt"),
|
|
||||||
("HEAD", "/source-bucket/"),
|
|
||||||
("HEAD", "/source-bucket/missing"),
|
|
||||||
("HEAD", "/source-bucket/"),
|
|
||||||
("HEAD", "/source-bucket/secret"),
|
|
||||||
];
|
|
||||||
assert_eq!(actual, expected.map(|(method, path)| (method, path.to_string())));
|
|
||||||
for request in &requests {
|
|
||||||
assert_outbound_markers(request);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prefix_client(prefix: Option<String>) -> SourceClient {
|
fn prefix_client(prefix: Option<String>) -> SourceClient {
|
||||||
+37
-299
@@ -19,12 +19,13 @@
|
|||||||
//! [`SourceClient`], a circuit breaker, a negative cache, a per-key
|
//! [`SourceClient`], a circuit breaker, a negative cache, a per-key
|
||||||
//! singleflight table, a pull concurrency limit and counters. Its lifecycle
|
//! singleflight table, a pull concurrency limit and counters. Its lifecycle
|
||||||
//! follows the bucket metadata cache through the publish hook registered in
|
//! follows the bucket metadata cache through the publish hook registered in
|
||||||
//! [`BUCKET_CONFIG_PUBLISH_HOOK`]; the hook fires on every cache install
|
//! [`ON_DEMAND_MIGRATION_CONFIG_HOOK`]; the hook fires on every cache install
|
||||||
//! path (initial load, admin update, peer reload, refresh loop, lazy load).
|
//! path (initial load, admin update, peer reload, refresh loop, lazy load).
|
||||||
//!
|
//!
|
||||||
//! Change detection compares the config by value (`PartialEq`) rather than
|
//! Change detection compares the config by value (`PartialEq`) rather than
|
||||||
//! by `updated_at`. The bucket incarnation is part of this comparison:
|
//! by `updated_at`: the hook does not carry the timestamp, fetching it would
|
||||||
//! recreating a bucket must cancel old work even with identical configuration.
|
//! re-enter the metadata system from inside its own publish path, and a
|
||||||
|
//! byte-identical config never needs a new client anyway.
|
||||||
//!
|
//!
|
||||||
//! Client construction is async (TLS material may be read from disk), so
|
//! Client construction is async (TLS material may be read from disk), so
|
||||||
//! the hook does not build inline: `publish` removes state synchronously and
|
//! the hook does not build inline: `publish` removes state synchronously and
|
||||||
@@ -40,19 +41,17 @@
|
|||||||
|
|
||||||
use super::backfill::{PriorityPullPermits, PullPermit, PullPriority};
|
use super::backfill::{PriorityPullPermits, PullPermit, PullPriority};
|
||||||
use super::breaker::{Breaker, BreakerState, BreakerTransition, BreakerVerdict};
|
use super::breaker::{Breaker, BreakerState, BreakerTransition, BreakerVerdict};
|
||||||
use super::config::{OnDemandMigrationConfig, PathStyle as ConfigPathStyle, Provider, SourceConfig};
|
use super::config::{
|
||||||
|
ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig, PathStyle as ConfigPathStyle, Provider, SourceConfig,
|
||||||
|
};
|
||||||
use super::list_through::{SOURCE_LIST_RATE_PER_SEC, SourceListRateLimiter};
|
use super::list_through::{SOURCE_LIST_RATE_PER_SEC, SourceListRateLimiter};
|
||||||
use super::negative_cache::NegativeCache;
|
use super::negative_cache::NegativeCache;
|
||||||
use super::pull::{OdmWriteBack, PullQueue};
|
use super::pull::{OdmWriteBack, PullQueue};
|
||||||
use super::source_client::{
|
use super::source_client::{SourceClient, SourceClientSpec, SourceError, SourceProvider, SourceTimeouts};
|
||||||
AzureAuth, AzureSourceSpec, GcsSourceSpec, SourceBackendSpec, SourceClient, SourceClientSpec, SourceError, SourceProvider,
|
|
||||||
SourceTimeouts,
|
|
||||||
};
|
|
||||||
use super::stats::{GaugeGuard, OdmStats, OdmStatsSnapshot, PullFailureReason};
|
use super::stats::{GaugeGuard, OdmStats, OdmStatsSnapshot, PullFailureReason};
|
||||||
use super::storage_api::remote_s3_client::{
|
use crate::bucket::remote_s3_client::{
|
||||||
PathStyle as ClientPathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3RetryPolicy,
|
PathStyle as ClientPathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3RetryPolicy,
|
||||||
};
|
};
|
||||||
use super::storage_api::{BUCKET_CONFIG_PUBLISH_HOOK, BUCKET_ON_DEMAND_MIGRATION_CONFIG};
|
|
||||||
use parking_lot::{Mutex, RwLock};
|
use parking_lot::{Mutex, RwLock};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -83,8 +82,6 @@ pub static GLOBAL_ON_DEMAND_MIGRATION_SYS: OnceLock<OnDemandMigrationSys> = Once
|
|||||||
/// `resolve` as [`OdmLookup::Unavailable`] and through status snapshots.
|
/// `resolve` as [`OdmLookup::Unavailable`] and through status snapshots.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||||
pub enum OdmStateError {
|
pub enum OdmStateError {
|
||||||
#[error("the {0} backend is not included in this build")]
|
|
||||||
BackendNotCompiled(&'static str),
|
|
||||||
/// `source.credentials` is `null`; the shared client builder has no
|
/// `source.credentials` is `null`; the shared client builder has no
|
||||||
/// anonymous mode yet (rustfs/backlog#2149 follow-up).
|
/// anonymous mode yet (rustfs/backlog#2149 follow-up).
|
||||||
#[error("anonymous source access is not supported yet; configure source credentials")]
|
#[error("anonymous source access is not supported yet; configure source credentials")]
|
||||||
@@ -269,7 +266,6 @@ impl Drop for InflightEntryGuard<'_> {
|
|||||||
/// config change (counters excepted), removed when the config goes away.
|
/// config change (counters excepted), removed when the config goes away.
|
||||||
pub struct BucketOdmState {
|
pub struct BucketOdmState {
|
||||||
bucket: String,
|
bucket: String,
|
||||||
incarnation_id: uuid::Uuid,
|
|
||||||
config: OnDemandMigrationConfig,
|
config: OnDemandMigrationConfig,
|
||||||
applied_at: OffsetDateTime,
|
applied_at: OffsetDateTime,
|
||||||
endpoint_host: String,
|
endpoint_host: String,
|
||||||
@@ -307,24 +303,21 @@ impl BucketOdmState {
|
|||||||
async fn build(
|
async fn build(
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
config: &OnDemandMigrationConfig,
|
config: &OnDemandMigrationConfig,
|
||||||
incarnation_id: uuid::Uuid,
|
|
||||||
stats: Arc<OdmStats>,
|
stats: Arc<OdmStats>,
|
||||||
write_back: Option<Arc<dyn OdmWriteBack>>,
|
write_back: Option<Arc<dyn OdmWriteBack>>,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
let spec = source_client_spec(config);
|
let spec = source_client_spec(config);
|
||||||
let client = if config.source.credentials.is_none() && !config.source.provider.is_native() {
|
let client = if config.source.credentials.is_none() {
|
||||||
Err(OdmStateError::AnonymousUnsupported)
|
Err(OdmStateError::AnonymousUnsupported)
|
||||||
} else {
|
} else {
|
||||||
SourceClient::new(&spec).await.map(Arc::new).map_err(|err| match err {
|
SourceClient::new(&spec).await.map(Arc::new).map_err(|err| match err {
|
||||||
RemoteS3ClientError::MissingCredentials => OdmStateError::AnonymousUnsupported,
|
RemoteS3ClientError::MissingCredentials => OdmStateError::AnonymousUnsupported,
|
||||||
RemoteS3ClientError::BackendNotCompiled(provider) => OdmStateError::BackendNotCompiled(provider),
|
|
||||||
other => OdmStateError::ClientBuild(other.to_string()),
|
other => OdmStateError::ClientBuild(other.to_string()),
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let policy = &config.policy;
|
let policy = &config.policy;
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
bucket: bucket.to_string(),
|
bucket: bucket.to_string(),
|
||||||
incarnation_id,
|
|
||||||
endpoint_host: endpoint_host(&config.source),
|
endpoint_host: endpoint_host(&config.source),
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
applied_at: OffsetDateTime::now_utc(),
|
applied_at: OffsetDateTime::now_utc(),
|
||||||
@@ -342,18 +335,10 @@ impl BucketOdmState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn filter_incarnation(self: Arc<Self>, incarnation_id: uuid::Uuid) -> Option<Arc<Self>> {
|
|
||||||
(self.incarnation_id == incarnation_id && !self.is_cancelled()).then_some(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bucket(&self) -> &str {
|
pub fn bucket(&self) -> &str {
|
||||||
&self.bucket
|
&self.bucket
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn incarnation_id(&self) -> uuid::Uuid {
|
|
||||||
self.incarnation_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn config(&self) -> &OnDemandMigrationConfig {
|
pub fn config(&self) -> &OnDemandMigrationConfig {
|
||||||
&self.config
|
&self.config
|
||||||
}
|
}
|
||||||
@@ -634,7 +619,6 @@ pub fn source_client_spec(config: &OnDemandMigrationConfig) -> SourceClientSpec
|
|||||||
// load on a source that is already failing.
|
// load on a source that is already failing.
|
||||||
retry: RemoteS3RetryPolicy::Disabled,
|
retry: RemoteS3RetryPolicy::Disabled,
|
||||||
bandwidth_limit: policy.bandwidth_limit_bytes_per_sec.and_then(NonZeroU64::new),
|
bandwidth_limit: policy.bandwidth_limit_bytes_per_sec.and_then(NonZeroU64::new),
|
||||||
backend: source_backend_spec(source),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,31 +630,6 @@ fn source_provider(provider: Provider) -> SourceProvider {
|
|||||||
Provider::Rustfs => SourceProvider::Rustfs,
|
Provider::Rustfs => SourceProvider::Rustfs,
|
||||||
Provider::R2 => SourceProvider::R2,
|
Provider::R2 => SourceProvider::R2,
|
||||||
Provider::Gcs => SourceProvider::Gcs,
|
Provider::Gcs => SourceProvider::Gcs,
|
||||||
Provider::Azure => SourceProvider::Azure,
|
|
||||||
Provider::GcsNative => SourceProvider::GcsNative,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which backend the client builds. A native provider whose block is missing
|
|
||||||
/// falls back to the S3 spec, where the builder reports the missing
|
|
||||||
/// credentials: the config layer already refuses to store that shape, so this
|
|
||||||
/// only covers a config written by an older or hand-edited build.
|
|
||||||
pub fn source_backend_spec(source: &SourceConfig) -> SourceBackendSpec {
|
|
||||||
match (source.provider, source.azure.as_ref(), source.gcs.as_ref()) {
|
|
||||||
(Provider::Azure, Some(azure), _) => SourceBackendSpec::Azure(AzureSourceSpec {
|
|
||||||
account: azure.account.clone(),
|
|
||||||
auth: match (&azure.account_key, &azure.sas_token) {
|
|
||||||
(Some(key), _) => AzureAuth::SharedKey(key.clone()),
|
|
||||||
(None, Some(sas)) => AzureAuth::Sas(sas.clone()),
|
|
||||||
// Refused by `SourceConfig::validate`; an empty shared key
|
|
||||||
// fails closed at the builder rather than signing with none.
|
|
||||||
(None, None) => AzureAuth::SharedKey(String::new()),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
(Provider::GcsNative, _, Some(gcs)) => SourceBackendSpec::Gcs(GcsSourceSpec {
|
|
||||||
service_account_json: gcs.service_account_json.clone(),
|
|
||||||
}),
|
|
||||||
_ => SourceBackendSpec::S3,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,51 +708,21 @@ impl OnDemandMigrationSys {
|
|||||||
/// Registers `publish` as the bucket-metadata publish hook. Returns
|
/// Registers `publish` as the bucket-metadata publish hook. Returns
|
||||||
/// `false` when a hook was already registered.
|
/// `false` when a hook was already registered.
|
||||||
pub fn register_config_hook(&'static self) -> bool {
|
pub fn register_config_hook(&'static self) -> bool {
|
||||||
BUCKET_CONFIG_PUBLISH_HOOK
|
ON_DEMAND_MIGRATION_CONFIG_HOOK
|
||||||
.set(Box::new(move |bucket, config_file, stored| {
|
.set(Box::new(move |bucket, config| self.publish(bucket, config)))
|
||||||
if config_file == BUCKET_ON_DEMAND_MIGRATION_CONFIG {
|
|
||||||
self.publish_stored(bucket, stored.map(|(bytes, _, incarnation)| (bytes, incarnation)));
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Corrupt persisted bytes withdraw state synchronously, just like deletion.
|
|
||||||
fn publish_stored(&'static self, bucket: &str, stored: Option<(&[u8], uuid::Uuid)>) {
|
|
||||||
let incarnation_id = stored.map(|(_, id)| id).unwrap_or_default();
|
|
||||||
match stored.map(|(bytes, _)| OnDemandMigrationConfig::from_json(bytes)).transpose() {
|
|
||||||
Ok(config) => self.publish_for_incarnation(bucket, incarnation_id, config.as_ref()),
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
event = EVENT_ODM_BUCKET_STATE_APPLIED,
|
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
|
||||||
subsystem = LOG_SUBSYSTEM_ON_DEMAND_MIGRATION,
|
|
||||||
result = "invalid",
|
|
||||||
bucket = %bucket,
|
|
||||||
error = %err,
|
|
||||||
"Failed to parse on-demand migration config"
|
|
||||||
);
|
|
||||||
self.publish_for_incarnation(bucket, incarnation_id, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hook entry point: removals apply immediately, installs are spawned
|
/// Hook entry point: removals apply immediately, installs are spawned
|
||||||
/// (client construction is async). Requires a Tokio runtime for the
|
/// (client construction is async). Requires a Tokio runtime for the
|
||||||
/// install path; without one the config is logged and skipped.
|
/// install path; without one the config is logged and skipped.
|
||||||
pub fn publish_for_incarnation(
|
pub fn publish(&'static self, bucket: &str, config: Option<&OnDemandMigrationConfig>) {
|
||||||
&'static self,
|
let generation = self.next_generation();
|
||||||
bucket: &str,
|
let Some(config) = self.desired(config) else {
|
||||||
incarnation_id: uuid::Uuid,
|
|
||||||
config: Option<&OnDemandMigrationConfig>,
|
|
||||||
) {
|
|
||||||
let config = self.desired(config).filter(|_| !incarnation_id.is_nil());
|
|
||||||
let generation = self.reserve_generation(bucket, config.is_some());
|
|
||||||
let Some(config) = config else {
|
|
||||||
self.remove_with_generation(bucket, generation);
|
self.remove_with_generation(bucket, generation);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if self.is_unchanged(bucket, incarnation_id, config, generation) {
|
if self.is_unchanged(bucket, config, generation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Ok(handle) = tokio::runtime::Handle::try_current() else {
|
let Ok(handle) = tokio::runtime::Handle::try_current() else {
|
||||||
@@ -812,49 +741,31 @@ impl OnDemandMigrationSys {
|
|||||||
let bucket = bucket.to_string();
|
let bucket = bucket.to_string();
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
self.apply_with_generation(&bucket, incarnation_id, Some(&config), generation)
|
self.apply_with_generation(&bucket, Some(&config), generation).await;
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Installs, rebuilds, or removes the bucket state for `config`.
|
/// Installs, rebuilds, or removes the bucket state for `config`.
|
||||||
/// Idempotent: the same config on an installed bucket is a no-op.
|
/// Idempotent: the same config on an installed bucket is a no-op.
|
||||||
#[cfg(test)]
|
|
||||||
pub async fn apply(&self, bucket: &str, config: Option<&OnDemandMigrationConfig>) -> ApplyOutcome {
|
pub async fn apply(&self, bucket: &str, config: Option<&OnDemandMigrationConfig>) -> ApplyOutcome {
|
||||||
self.apply_for_incarnation(bucket, uuid::Uuid::from_u128(1), config).await
|
let generation = self.next_generation();
|
||||||
}
|
self.apply_with_generation(bucket, config, generation).await
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn publish(&'static self, bucket: &str, config: Option<&OnDemandMigrationConfig>) {
|
|
||||||
self.publish_for_incarnation(bucket, uuid::Uuid::from_u128(1), config);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn apply_for_incarnation(
|
|
||||||
&self,
|
|
||||||
bucket: &str,
|
|
||||||
incarnation_id: uuid::Uuid,
|
|
||||||
config: Option<&OnDemandMigrationConfig>,
|
|
||||||
) -> ApplyOutcome {
|
|
||||||
let config = self.desired(config).filter(|_| !incarnation_id.is_nil());
|
|
||||||
let generation = self.reserve_generation(bucket, config.is_some());
|
|
||||||
self.apply_with_generation(bucket, incarnation_id, config, generation).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn apply_with_generation(
|
async fn apply_with_generation(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
incarnation_id: uuid::Uuid,
|
|
||||||
config: Option<&OnDemandMigrationConfig>,
|
config: Option<&OnDemandMigrationConfig>,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
) -> ApplyOutcome {
|
) -> ApplyOutcome {
|
||||||
let Some(config) = self.desired(config) else {
|
let Some(config) = self.desired(config) else {
|
||||||
return self.remove_with_generation(bucket, generation);
|
return self.remove_with_generation(bucket, generation);
|
||||||
};
|
};
|
||||||
if self.is_unchanged(bucket, incarnation_id, config, generation) {
|
if self.is_unchanged(bucket, config, generation) {
|
||||||
return ApplyOutcome::Unchanged;
|
return ApplyOutcome::Unchanged;
|
||||||
}
|
}
|
||||||
let stats = self.state(bucket).map(|state| Arc::clone(&state.stats)).unwrap_or_default();
|
let stats = self.state(bucket).map(|state| Arc::clone(&state.stats)).unwrap_or_default();
|
||||||
let state = BucketOdmState::build(bucket, config, incarnation_id, stats, self.write_back()).await;
|
let state = BucketOdmState::build(bucket, config, stats, self.write_back()).await;
|
||||||
|
|
||||||
let (outcome, previous) = {
|
let (outcome, previous) = {
|
||||||
let mut buckets = self.buckets.write();
|
let mut buckets = self.buckets.write();
|
||||||
@@ -898,13 +809,12 @@ impl OnDemandMigrationSys {
|
|||||||
|
|
||||||
/// Removes a bucket's state (idempotent), cancelling its token.
|
/// Removes a bucket's state (idempotent), cancelling its token.
|
||||||
pub fn remove(&self, bucket: &str) -> ApplyOutcome {
|
pub fn remove(&self, bucket: &str) -> ApplyOutcome {
|
||||||
let generation = self.reserve_generation(bucket, false);
|
let generation = self.next_generation();
|
||||||
self.remove_with_generation(bucket, generation)
|
self.remove_with_generation(bucket, generation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot lookup: module switch, bucket state, prefix filter,
|
/// One-shot lookup: module switch, bucket state, prefix filter,
|
||||||
/// client availability, negative cache, breaker, in that order.
|
/// client availability, negative cache, breaker, in that order.
|
||||||
#[cfg(test)]
|
|
||||||
pub fn resolve(&self, bucket: &str, key: &str) -> Option<OdmLookup> {
|
pub fn resolve(&self, bucket: &str, key: &str) -> Option<OdmLookup> {
|
||||||
if !self.is_module_enabled() {
|
if !self.is_module_enabled() {
|
||||||
return None;
|
return None;
|
||||||
@@ -912,13 +822,6 @@ impl OnDemandMigrationSys {
|
|||||||
self.state(bucket)?.resolve_key(key)
|
self.state(bucket)?.resolve_key(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_for_incarnation(&self, bucket: &str, key: &str, incarnation_id: uuid::Uuid) -> Option<OdmLookup> {
|
|
||||||
if !self.is_module_enabled() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.state(bucket)?.filter_incarnation(incarnation_id)?.resolve_key(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state(&self, bucket: &str) -> Option<Arc<BucketOdmState>> {
|
pub fn state(&self, bucket: &str) -> Option<Arc<BucketOdmState>> {
|
||||||
self.buckets.read().get(bucket).and_then(|slot| slot.state.clone())
|
self.buckets.read().get(bucket).and_then(|slot| slot.state.clone())
|
||||||
}
|
}
|
||||||
@@ -947,17 +850,8 @@ impl OnDemandMigrationSys {
|
|||||||
snapshots
|
snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reserve_generation(&self, bucket: &str, installing: bool) -> u64 {
|
fn next_generation(&self) -> u64 {
|
||||||
// Reserve a desired install before its async client build, under the
|
self.generation.fetch_add(1, Ordering::Relaxed) + 1
|
||||||
// same lock that orders removals. Unconfigured buckets need no slot.
|
|
||||||
let mut buckets = self.buckets.write();
|
|
||||||
let generation = self.generation.fetch_add(1, Ordering::Relaxed) + 1;
|
|
||||||
if installing {
|
|
||||||
buckets.entry(bucket.to_string()).or_default().generation = generation;
|
|
||||||
} else if let Some(slot) = buckets.get_mut(bucket) {
|
|
||||||
slot.generation = generation;
|
|
||||||
}
|
|
||||||
generation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn desired<'c>(&self, config: Option<&'c OnDemandMigrationConfig>) -> Option<&'c OnDemandMigrationConfig> {
|
fn desired<'c>(&self, config: Option<&'c OnDemandMigrationConfig>) -> Option<&'c OnDemandMigrationConfig> {
|
||||||
@@ -966,26 +860,15 @@ impl OnDemandMigrationSys {
|
|||||||
|
|
||||||
/// Claims `generation` for the bucket when the installed state already
|
/// Claims `generation` for the bucket when the installed state already
|
||||||
/// matches `config` and has a usable client.
|
/// matches `config` and has a usable client.
|
||||||
fn is_unchanged(&self, bucket: &str, incarnation_id: uuid::Uuid, config: &OnDemandMigrationConfig, generation: u64) -> bool {
|
fn is_unchanged(&self, bucket: &str, config: &OnDemandMigrationConfig, generation: u64) -> bool {
|
||||||
let mut buckets = self.buckets.write();
|
let mut buckets = self.buckets.write();
|
||||||
let Some(slot) = buckets.get_mut(bucket) else {
|
let Some(slot) = buckets.get_mut(bucket) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if slot.generation > generation {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if slot
|
|
||||||
.state
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|state| state.incarnation_id != incarnation_id)
|
|
||||||
&& let Some(previous) = slot.state.take()
|
|
||||||
{
|
|
||||||
previous.cancel.cancel();
|
|
||||||
}
|
|
||||||
let unchanged = slot
|
let unchanged = slot
|
||||||
.state
|
.state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|state| state.client.is_ok() && state.incarnation_id == incarnation_id && state.config == *config);
|
.is_some_and(|state| state.client.is_ok() && state.config == *config);
|
||||||
if unchanged && slot.generation < generation {
|
if unchanged && slot.generation < generation {
|
||||||
slot.generation = generation;
|
slot.generation = generation;
|
||||||
}
|
}
|
||||||
@@ -1025,8 +908,8 @@ impl OnDemandMigrationSys {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::on_demand_migration::breaker::BREAKER_FAILURE_THRESHOLD;
|
use crate::bucket::on_demand_migration::breaker::BREAKER_FAILURE_THRESHOLD;
|
||||||
use crate::on_demand_migration::config::{FilterConfig, PolicyConfig, SourceCredentials, SourceTimeout, TlsConfig};
|
use crate::bucket::on_demand_migration::config::{FilterConfig, PolicyConfig, SourceCredentials, SourceTimeout, TlsConfig};
|
||||||
use std::sync::atomic::AtomicUsize;
|
use std::sync::atomic::AtomicUsize;
|
||||||
use tokio::sync::Barrier;
|
use tokio::sync::Barrier;
|
||||||
|
|
||||||
@@ -1046,8 +929,6 @@ mod tests {
|
|||||||
session_token: None,
|
session_token: None,
|
||||||
}),
|
}),
|
||||||
tls: TlsConfig::default(),
|
tls: TlsConfig::default(),
|
||||||
azure: None,
|
|
||||||
gcs: None,
|
|
||||||
},
|
},
|
||||||
filter: FilterConfig {
|
filter: FilterConfig {
|
||||||
prefix: prefix.map(str::to_string),
|
prefix: prefix.map(str::to_string),
|
||||||
@@ -1164,45 +1045,6 @@ mod tests {
|
|||||||
assert_eq!(sys.apply("b", Some(&cfg)).await, ApplyOutcome::Rebuilt);
|
assert_eq!(sys.apply("b", Some(&cfg)).await, ApplyOutcome::Rebuilt);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn native_azure_uses_provider_credentials_without_s3_credentials() {
|
|
||||||
let sys = enabled_sys();
|
|
||||||
let mut cfg = config(None);
|
|
||||||
cfg.source.provider = Provider::Azure;
|
|
||||||
cfg.source.endpoint = None;
|
|
||||||
cfg.source.credentials = None;
|
|
||||||
cfg.source.azure = Some(super::super::config::AzureSourceConfig {
|
|
||||||
account: "legacyaccount".to_string(),
|
|
||||||
account_key: Some("c2VjcmV0LWtleQ==".to_string()),
|
|
||||||
sas_token: None,
|
|
||||||
});
|
|
||||||
assert_eq!(sys.apply("b", Some(&cfg)).await, ApplyOutcome::Installed);
|
|
||||||
let state = ready_state(sys.resolve("b", "k"));
|
|
||||||
assert!(state.client().is_ok(), "native credentials must not be classified as anonymous S3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "gcs"))]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn gcs_backend_not_compiled_is_unavailable_not_anonymous() {
|
|
||||||
let sys = enabled_sys();
|
|
||||||
let mut cfg = config(None);
|
|
||||||
cfg.source.provider = Provider::GcsNative;
|
|
||||||
cfg.source.credentials = None;
|
|
||||||
cfg.source.gcs = Some(super::super::config::GcsSourceConfig {
|
|
||||||
service_account_json: "{}".to_string(),
|
|
||||||
});
|
|
||||||
let encoded = cfg.to_json().expect("GCS config is serializable without the backend");
|
|
||||||
let restored: OnDemandMigrationConfig = serde_json::from_slice(&encoded).expect("GCS config stays readable");
|
|
||||||
assert_eq!(restored, cfg);
|
|
||||||
assert_eq!(sys.apply("b", Some(&cfg)).await, ApplyOutcome::Installed);
|
|
||||||
match sys.resolve("b", "k") {
|
|
||||||
Some(OdmLookup::Unavailable { error, .. }) => {
|
|
||||||
assert_eq!(error, OdmStateError::BackendNotCompiled("gcs_native"));
|
|
||||||
}
|
|
||||||
other => panic!("expected unavailable backend, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn singleflight_admits_one_leader_per_key() {
|
async fn singleflight_admits_one_leader_per_key() {
|
||||||
let sys = enabled_sys();
|
let sys = enabled_sys();
|
||||||
@@ -1418,128 +1260,24 @@ mod tests {
|
|||||||
assert!(state.is_cancelled());
|
assert!(state.is_cancelled());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn identical_config_on_recreated_bucket_cancels_old_state() {
|
|
||||||
let sys = enabled_sys();
|
|
||||||
let cfg = config(None);
|
|
||||||
let old_id = uuid::Uuid::new_v4();
|
|
||||||
let new_id = uuid::Uuid::new_v4();
|
|
||||||
sys.apply_for_incarnation("recreated", old_id, Some(&cfg)).await;
|
|
||||||
let old = sys.state("recreated").expect("old state installed");
|
|
||||||
assert!(sys.resolve_for_incarnation("recreated", "key", new_id).is_none());
|
|
||||||
sys.apply_for_incarnation("recreated", new_id, Some(&cfg)).await;
|
|
||||||
let replacement = sys.state("recreated").expect("replacement state installed");
|
|
||||||
assert!(old.is_cancelled());
|
|
||||||
assert!(!Arc::ptr_eq(&old, &replacement));
|
|
||||||
assert_eq!(replacement.incarnation_id(), new_id);
|
|
||||||
assert!(sys.resolve_for_incarnation("recreated", "key", old_id).is_none());
|
|
||||||
assert!(sys.resolve_for_incarnation("recreated", "key", new_id).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn changed_delete_marker_policy_withdraws_the_captured_lookup() {
|
|
||||||
let sys = enabled_sys();
|
|
||||||
let incarnation = uuid::Uuid::new_v4();
|
|
||||||
let mut cfg = config(None);
|
|
||||||
cfg.policy.respect_local_delete_marker = false;
|
|
||||||
sys.apply_for_incarnation("policy-snapshot", incarnation, Some(&cfg)).await;
|
|
||||||
let captured = sys.state("policy-snapshot").expect("policy A installed");
|
|
||||||
assert!(!captured.config().policy.respect_local_delete_marker);
|
|
||||||
|
|
||||||
cfg.policy.respect_local_delete_marker = true;
|
|
||||||
sys.apply_for_incarnation("policy-snapshot", incarnation, Some(&cfg)).await;
|
|
||||||
let replacement = sys.state("policy-snapshot").expect("policy B installed");
|
|
||||||
assert!(replacement.config().policy.respect_local_delete_marker);
|
|
||||||
assert!(captured.is_cancelled());
|
|
||||||
assert!(
|
|
||||||
captured
|
|
||||||
.filter_incarnation(incarnation)
|
|
||||||
.and_then(|state| state.resolve_key("key"))
|
|
||||||
.is_none(),
|
|
||||||
"a request that evaluated policy A cannot continue through policy B"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
replacement
|
|
||||||
.clone()
|
|
||||||
.filter_incarnation(incarnation)
|
|
||||||
.and_then(|state| state.resolve_key("key"))
|
|
||||||
.is_some()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
replacement
|
|
||||||
.stats()
|
|
||||||
.snapshot(replacement.breaker().state())
|
|
||||||
.source_latency
|
|
||||||
.count,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn missing_incarnation_cannot_install_or_retain_source_state() {
|
|
||||||
let sys: &'static OnDemandMigrationSys = Box::leak(Box::new(enabled_sys()));
|
|
||||||
let cfg = config(None);
|
|
||||||
assert_eq!(
|
|
||||||
sys.apply_for_incarnation("missing", uuid::Uuid::nil(), Some(&cfg)).await,
|
|
||||||
ApplyOutcome::NotDesired
|
|
||||||
);
|
|
||||||
sys.publish_for_incarnation("missing", uuid::Uuid::nil(), Some(&cfg));
|
|
||||||
assert!(sys.state("missing").is_none());
|
|
||||||
|
|
||||||
sys.apply_for_incarnation("missing", uuid::Uuid::new_v4(), Some(&cfg)).await;
|
|
||||||
let state = sys.state("missing").expect("valid identity installed");
|
|
||||||
sys.publish_for_incarnation("missing", uuid::Uuid::nil(), Some(&cfg));
|
|
||||||
assert!(sys.state("missing").is_none());
|
|
||||||
assert!(state.is_cancelled());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn corrupt_stored_config_withdraws_runtime_state() {
|
|
||||||
let sys: &'static OnDemandMigrationSys = Box::leak(Box::new(enabled_sys()));
|
|
||||||
let cfg = config(None);
|
|
||||||
assert_eq!(sys.apply("corrupt", Some(&cfg)).await, ApplyOutcome::Installed);
|
|
||||||
let state = sys.state("corrupt").expect("state installed");
|
|
||||||
sys.publish_stored("corrupt", Some((b"not-json", uuid::Uuid::from_u128(1))));
|
|
||||||
assert!(sys.state("corrupt").is_none(), "corruption cannot keep an older source active");
|
|
||||||
assert!(state.is_cancelled(), "corruption cancels in-flight work");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn absent_config_updates_do_not_allocate_bucket_slots() {
|
|
||||||
let sys = enabled_sys();
|
|
||||||
for index in 0..1000 {
|
|
||||||
let bucket = format!("unconfigured-{index}");
|
|
||||||
assert_eq!(sys.apply(&bucket, None).await, ApplyOutcome::NotDesired);
|
|
||||||
assert_eq!(sys.remove(&bucket), ApplyOutcome::NotDesired);
|
|
||||||
}
|
|
||||||
assert!(sys.buckets.read().is_empty(), "unconfigured buckets must not accumulate tombstones");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stale_install_cannot_overwrite_a_later_removal() {
|
async fn stale_install_cannot_overwrite_a_later_removal() {
|
||||||
let sys = enabled_sys();
|
let sys = enabled_sys();
|
||||||
let cfg = config(None);
|
let cfg = config(None);
|
||||||
let older = sys.reserve_generation("b", true);
|
let older = sys.next_generation();
|
||||||
let newer = sys.reserve_generation("b", false);
|
let newer = sys.next_generation();
|
||||||
assert_eq!(sys.remove_with_generation("b", newer), ApplyOutcome::NotDesired);
|
assert_eq!(sys.remove_with_generation("b", newer), ApplyOutcome::NotDesired);
|
||||||
assert_eq!(
|
// The removal above did not create a slot; simulate an install that
|
||||||
sys.apply_with_generation("b", uuid::Uuid::from_u128(1), Some(&cfg), older)
|
// started before it and finishes after.
|
||||||
.await,
|
sys.apply_with_generation("b", Some(&cfg), older).await;
|
||||||
ApplyOutcome::Superseded
|
assert!(sys.state("b").is_some(), "no slot yet, so the older install lands");
|
||||||
);
|
|
||||||
assert!(sys.state("b").is_none(), "removal must supersede an in-flight first install");
|
|
||||||
|
|
||||||
assert_eq!(sys.apply("b", Some(&cfg)).await, ApplyOutcome::Installed);
|
|
||||||
let installed = sys.state("b").unwrap();
|
let installed = sys.state("b").unwrap();
|
||||||
let older = sys.reserve_generation("b", true);
|
let older = sys.next_generation();
|
||||||
let newer = sys.reserve_generation("b", false);
|
let newer = sys.next_generation();
|
||||||
assert_eq!(sys.remove_with_generation("b", newer), ApplyOutcome::Removed);
|
assert_eq!(sys.remove_with_generation("b", newer), ApplyOutcome::Removed);
|
||||||
assert!(installed.is_cancelled());
|
assert!(installed.is_cancelled());
|
||||||
assert_eq!(
|
assert_eq!(sys.apply_with_generation("b", Some(&cfg), older).await, ApplyOutcome::Superseded);
|
||||||
sys.apply_with_generation("b", uuid::Uuid::from_u128(1), Some(&cfg), older)
|
|
||||||
.await,
|
|
||||||
ApplyOutcome::Superseded
|
|
||||||
);
|
|
||||||
assert!(sys.state("b").is_none(), "the stale install is discarded");
|
assert!(sys.state("b").is_none(), "the stale install is discarded");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,8 +180,6 @@ 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}")]
|
||||||
@@ -283,7 +281,9 @@ 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 async fn build_remote_s3_config(spec: &RemoteS3EndpointSpec) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
|
pub(crate) async fn build_remote_s3_config(
|
||||||
|
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 fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
|
pub(crate) 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ pub use rustfs_replication::{
|
|||||||
pub(crate) use rustfs_replication::{
|
pub(crate) use rustfs_replication::{
|
||||||
ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject, delete_marker_purge_mrf_entry,
|
ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject, delete_marker_purge_mrf_entry,
|
||||||
delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision,
|
delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision,
|
||||||
delete_replication_object_opts, delete_replication_target_version_id, heal_uses_delete_replication_path,
|
delete_replication_object_opts, heal_uses_delete_replication_path, is_object_lock_denied_delete,
|
||||||
is_object_lock_denied_delete, is_retryable_delete_replication_head_error, is_version_delete_replication,
|
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match,
|
||||||
replicate_delete_outcome, replication_etags_match, replication_multipart_complete_actual_size,
|
replication_multipart_complete_actual_size, replication_multipart_part_plan, replication_single_put_size_error,
|
||||||
replication_multipart_part_plan, replication_single_put_size_error, resync_existing_delete_replication_info,
|
resync_existing_delete_replication_info, resync_target_for_object, should_retry_delete_marker_purge,
|
||||||
resync_target_for_object, should_retry_delete_marker_purge, single_part_replica_etag_mismatch,
|
single_part_replica_etag_mismatch, target_delete_version_id,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -882,20 +882,6 @@ fn reconstructed_heal_delete_info(
|
|||||||
) -> DeletedObjectReplicationInfo {
|
) -> DeletedObjectReplicationInfo {
|
||||||
let mut rstate = oi.replication_state();
|
let mut rstate = oi.replication_state();
|
||||||
rstate.replicate_decision_str = dsc.to_string();
|
rstate.replicate_decision_str = dsc.to_string();
|
||||||
// The caller hands us a blank ObjectInfo (the source marker may already be
|
|
||||||
// gone), so the state above carries no target-assigned marker version ids.
|
|
||||||
// Restore them from the journal: `delete_marker_purge_version_id` must hit
|
|
||||||
// the id the target reported, not fall back to the source marker id, which
|
|
||||||
// a target that mints its own ids answers with an idempotent 204 that would
|
|
||||||
// acknowledge the intent while the real marker stays behind (backlog#2290).
|
|
||||||
// The corrupt flag rides along so a refusal stays a refusal after restart.
|
|
||||||
for (arn, version_id) in &entry.target_delete_marker_version_ids {
|
|
||||||
rstate
|
|
||||||
.target_delete_marker_version_ids
|
|
||||||
.entry(arn.clone())
|
|
||||||
.or_insert_with(|| version_id.clone());
|
|
||||||
}
|
|
||||||
rstate.target_delete_marker_version_ids_corrupt |= entry.target_delete_marker_version_ids_corrupt;
|
|
||||||
|
|
||||||
let delete_marker_mtime = entry
|
let delete_marker_mtime = entry
|
||||||
.delete_marker_mtime
|
.delete_marker_mtime
|
||||||
@@ -6615,87 +6601,4 @@ mod tests {
|
|||||||
replacement_data
|
replacement_data
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// backlog#2290: a delete-marker purge intent that survives a restart
|
|
||||||
/// through the MRF journal addresses the marker version the TARGET
|
|
||||||
/// assigned, exactly as the live watcher does (see the
|
|
||||||
/// `requires_delayed_purge` spawn). The journal carries the per-ARN ids
|
|
||||||
/// (`targetDeleteMarkerVersionIDs`) and replay restores them into the
|
|
||||||
/// reconstructed replication state; without that the replay would fall
|
|
||||||
/// back to the source marker id, which a target that mints its own ids
|
|
||||||
/// answers with an idempotent 204 — the entry would be acknowledged while
|
|
||||||
/// the real marker stayed behind.
|
|
||||||
#[test]
|
|
||||||
fn mrf_delete_marker_purge_replay_preserves_target_assigned_marker_version() {
|
|
||||||
use super::super::replication_object_decision_boundary::{delete_marker_purge_mrf_entry, delete_marker_purge_version_id};
|
|
||||||
|
|
||||||
let arn = "arn:minio:replication::generic-target:photos".to_string();
|
|
||||||
let source_marker = uuid::Uuid::new_v4();
|
|
||||||
let remote_marker = "remote-assigned-marker-version".to_string();
|
|
||||||
|
|
||||||
let live_oi = ObjectInfo {
|
|
||||||
bucket: "photos".to_string(),
|
|
||||||
name: "obj".to_string(),
|
|
||||||
version_id: Some(source_marker),
|
|
||||||
delete_marker: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut live_state = live_oi.replication_state();
|
|
||||||
live_state.replicate_decision_str = replicate_decision_for_admitted_targets(std::slice::from_ref(&arn)).to_string();
|
|
||||||
live_state
|
|
||||||
.target_delete_marker_version_ids
|
|
||||||
.insert(arn.clone(), remote_marker.clone());
|
|
||||||
let live = DeletedObjectReplicationInfo {
|
|
||||||
delete_object: ReplicationDeletedObject {
|
|
||||||
object_name: "obj".to_string(),
|
|
||||||
delete_marker: true,
|
|
||||||
delete_marker_version_id: Some(source_marker),
|
|
||||||
replication_state: Some(live_state),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
bucket: "photos".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
delete_marker_purge_version_id(live.delete_object.replication_state.as_ref(), &arn, source_marker),
|
|
||||||
Some(Some(remote_marker.clone())),
|
|
||||||
"the live purge addresses the recorded target version"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Watch window exhausted: persist the intent, restart, replay it.
|
|
||||||
let entry = delete_marker_purge_mrf_entry(&live, vec![arn.clone()]);
|
|
||||||
let replay_oi = ObjectInfo {
|
|
||||||
bucket: entry.bucket.clone(),
|
|
||||||
name: entry.object.clone(),
|
|
||||||
version_id: entry.version_id,
|
|
||||||
delete_marker: entry.delete_marker,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let dsc = replicate_decision_for_admitted_targets(&entry.target_arns);
|
|
||||||
let replayed = reconstructed_heal_delete_info(&entry, &replay_oi, &dsc);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
delete_marker_purge_version_id(replayed.delete_object.replication_state.as_ref(), &arn, source_marker),
|
|
||||||
Some(Some(remote_marker)),
|
|
||||||
"the MRF replay must address the target-assigned marker version, not source marker {source_marker}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// A refusal (inconsistent recorded ids) must stay a refusal across the
|
|
||||||
// journal round trip instead of degrading into the source-id fallback.
|
|
||||||
let mut refused = live;
|
|
||||||
refused
|
|
||||||
.delete_object
|
|
||||||
.replication_state
|
|
||||||
.as_mut()
|
|
||||||
.expect("state was set above")
|
|
||||||
.target_delete_marker_version_ids_corrupt = true;
|
|
||||||
let entry = delete_marker_purge_mrf_entry(&refused, vec![arn.clone()]);
|
|
||||||
assert!(entry.target_delete_marker_version_ids_corrupt);
|
|
||||||
let replayed = reconstructed_heal_delete_info(&entry, &replay_oi, &dsc);
|
|
||||||
assert_eq!(
|
|
||||||
delete_marker_purge_version_id(replayed.delete_object.replication_state.as_ref(), &arn, source_marker),
|
|
||||||
None,
|
|
||||||
"the MRF replay must keep refusing to guess when the recorded ids were inconsistent"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ use super::replication_msgp_boundary::ReplicationMsgpCodec;
|
|||||||
use super::replication_object_config::{ReplicationConfig, get_replication_config, must_replicate};
|
use super::replication_object_config::{ReplicationConfig, get_replication_config, must_replicate};
|
||||||
use super::replication_object_decision_boundary::{
|
use super::replication_object_decision_boundary::{
|
||||||
MustReplicateOptions, ReplicationMultipartPartInput, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
|
MustReplicateOptions, ReplicationMultipartPartInput, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
|
||||||
delete_replication_creates_marker, delete_replication_target_version_id, heal_uses_delete_replication_path,
|
delete_replication_creates_marker, heal_uses_delete_replication_path, is_object_lock_denied_delete,
|
||||||
is_object_lock_denied_delete, is_retryable_delete_replication_head_error, is_version_delete_replication,
|
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match,
|
||||||
replicate_delete_outcome, replication_etags_match, replication_multipart_complete_actual_size,
|
replication_multipart_complete_actual_size, replication_multipart_part_plan, replication_single_put_size_error,
|
||||||
replication_multipart_part_plan, replication_single_put_size_error, resync_existing_delete_replication_info,
|
resync_existing_delete_replication_info, should_retry_delete_marker_purge, single_part_replica_etag_mismatch,
|
||||||
should_retry_delete_marker_purge, single_part_replica_etag_mismatch,
|
target_delete_version_id,
|
||||||
};
|
};
|
||||||
use super::replication_queue_boundary::{DeletedObjectReplicationInfo, ReplicationQueueAdmission};
|
use super::replication_queue_boundary::{DeletedObjectReplicationInfo, ReplicationQueueAdmission};
|
||||||
use super::replication_resync_boundary::ResyncStatusType;
|
use super::replication_resync_boundary::ResyncStatusType;
|
||||||
@@ -2051,11 +2051,7 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
|
|||||||
|
|
||||||
let is_version_purge = is_version_delete_replication(&dobj.delete_object);
|
let is_version_purge = is_version_delete_replication(&dobj.delete_object);
|
||||||
|
|
||||||
// The watcher exists to purge a replicated marker once the SOURCE marker
|
let requires_delayed_purge = should_retry_delete_marker_purge(&dobj.delete_object);
|
||||||
// vanishes. A version purge is that purge already (its failures reach the
|
|
||||||
// journal as a purge entry), so it must not spawn a second watcher that
|
|
||||||
// journals a duplicate intent (backlog#2290).
|
|
||||||
let requires_delayed_purge = should_retry_delete_marker_purge(&dobj.delete_object) && !is_version_purge;
|
|
||||||
|
|
||||||
let (replication_status, prev_status) = if !is_version_purge {
|
let (replication_status, prev_status) = if !is_version_purge {
|
||||||
(
|
(
|
||||||
@@ -2765,6 +2761,12 @@ fn unavailable_delete_target_info(dobj: &DeletedObjectReplicationInfo, arn: &str
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_client: Arc<TargetClient>) -> ReplicatedTargetInfo {
|
async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_client: Arc<TargetClient>) -> ReplicatedTargetInfo {
|
||||||
|
let version_id = if let Some(version_id) = &dobj.delete_object.delete_marker_version_id {
|
||||||
|
version_id.to_owned()
|
||||||
|
} else {
|
||||||
|
dobj.delete_object.version_id.unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
let mut rinfo = dobj
|
let mut rinfo = dobj
|
||||||
.delete_object
|
.delete_object
|
||||||
.replication_state
|
.replication_state
|
||||||
@@ -2797,25 +2799,7 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
|||||||
return rinfo;
|
return rinfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purging a replicated delete marker addresses the version the target
|
let version_id = target_delete_version_id(version_id, is_version_purge);
|
||||||
// assigned (recorded when the marker was created there); see
|
|
||||||
// `delete_replication_target_version_id`. A corrupt record is a failure,
|
|
||||||
// not a guess: the entry stays visible until the metadata is repaired.
|
|
||||||
let Some(version_id) = delete_replication_target_version_id(&dobj.delete_object, &tgt_client.arn) else {
|
|
||||||
warn!(
|
|
||||||
event = EVENT_DELETE_MARKER_PURGE_FAILED,
|
|
||||||
component = LOG_COMPONENT_ECSTORE,
|
|
||||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
|
||||||
bucket = tgt_client.bucket,
|
|
||||||
object = dobj.delete_object.object_name,
|
|
||||||
arn = %tgt_client.arn,
|
|
||||||
reason = "recorded_target_version_inconsistent",
|
|
||||||
"Replicated version purge refused: recorded target delete-marker version metadata is inconsistent"
|
|
||||||
);
|
|
||||||
rinfo.version_purge_status = VersionPurgeStatusType::Failed;
|
|
||||||
rinfo.error = Some("recorded target delete-marker version metadata is inconsistent".to_string());
|
|
||||||
return rinfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
if dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
|
if dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
|
||||||
match head_object_for_worker(
|
match head_object_for_worker(
|
||||||
|
|||||||
@@ -68,10 +68,7 @@ use std::{
|
|||||||
},
|
},
|
||||||
time::SystemTime,
|
time::SystemTime,
|
||||||
};
|
};
|
||||||
use tokio::{
|
use tokio::{net::TcpStream, time::Duration};
|
||||||
net::TcpStream,
|
|
||||||
time::{Duration, timeout},
|
|
||||||
};
|
|
||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
use tonic::service::interceptor::InterceptedService;
|
use tonic::service::interceptor::InterceptedService;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
@@ -877,16 +874,6 @@ impl PeerRestClient {
|
|||||||
self.offline.store(false, Ordering::Release);
|
self.offline.store(false, Ordering::Release);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prepare a retry without allowing connection-cache cleanup to extend the
|
|
||||||
/// caller's probe deadline. The offline gate is cleared even when eviction
|
|
||||||
/// times out so a cancelled cleanup cannot strand the peer in fast-fail
|
|
||||||
/// mode; a later request can perform a fresh eviction if needed.
|
|
||||||
pub async fn prepare_retry_with_timeout(&self, timeout_duration: Duration) -> bool {
|
|
||||||
let evicted = timeout(timeout_duration, self.evict_connection()).await.is_ok();
|
|
||||||
self.offline.store(false, Ordering::Release);
|
|
||||||
evicted
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this failure means the peer is unreachable, so it should be
|
/// Whether this failure means the peer is unreachable, so it should be
|
||||||
/// gated offline and its connection evicted.
|
/// gated offline and its connection evicted.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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" | "control_id"))
|
.filter(|id_kind| matches!(*id_kind, "operation_id" | "transaction_id" | "job_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,9 +3023,8 @@ 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" | "control_id" if !is_sha256_checksum(id) => {
|
"operation_id" if !is_sha256_checksum(id) => {
|
||||||
let id_label = id_kind.trim_end_matches("_id");
|
return Err(Error::other(format!("durable ILM receipt path `{path}` has an invalid operation 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")));
|
||||||
@@ -19301,8 +19300,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,
|
||||||
parse_decommission_durable_ilm_receipt_path, pool_meta_has_active_decommission, publish_pool_meta_updates,
|
pool_meta_has_active_decommission, publish_pool_meta_updates, read_pool_meta_replica,
|
||||||
read_pool_meta_replica, reconcile_decommission_meta_buckets, reconcile_decommission_unresolved_entries_for_completion,
|
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,
|
||||||
@@ -20170,26 +20169,6 @@ 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,17 +99,11 @@ 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";
|
||||||
|
|||||||
@@ -324,70 +324,6 @@ impl DiskStoreRenameDataExt for LocalDiskWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LocalDiskWrapper {
|
impl LocalDiskWrapper {
|
||||||
pub(in crate::disk) async fn delete_version_with_namespace_owner(
|
|
||||||
&self,
|
|
||||||
volume: &str,
|
|
||||||
path: &str,
|
|
||||||
fi: FileInfo,
|
|
||||||
force_del_marker: bool,
|
|
||||||
opts: DeleteOptions,
|
|
||||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
self.track_disk_health_mutation(
|
|
||||||
"delete_version",
|
|
||||||
DiskMetricMutation::Delete,
|
|
||||||
|| async {
|
|
||||||
Box::pin(
|
|
||||||
self.disk
|
|
||||||
.delete_version_with_namespace_owner(volume, path, fi, force_del_marker, opts, namespace_owner),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
},
|
|
||||||
get_max_timeout_duration(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(in crate::disk) async fn delete_with_namespace_owner(
|
|
||||||
&self,
|
|
||||||
volume: &str,
|
|
||||||
path: &str,
|
|
||||||
opts: DeleteOptions,
|
|
||||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
|
||||||
) -> Result<()> {
|
|
||||||
self.track_disk_health_mutation(
|
|
||||||
"delete",
|
|
||||||
DiskMetricMutation::Delete,
|
|
||||||
|| async { Box::pin(self.disk.delete_with_namespace_owner(volume, path, opts, namespace_owner)).await },
|
|
||||||
get_max_timeout_duration(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
pub(in crate::disk) async fn rename_data_observed(
|
||||||
&self,
|
&self,
|
||||||
src_volume: &str,
|
src_volume: &str,
|
||||||
@@ -397,34 +333,6 @@ impl LocalDiskWrapper {
|
|||||||
dst_path: &str,
|
dst_path: &str,
|
||||||
external_guard: Option<Arc<dyn Send + Sync>>,
|
external_guard: Option<Arc<dyn Send + Sync>>,
|
||||||
) -> super::RenameDataObservation {
|
) -> 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();
|
||||||
@@ -449,14 +357,12 @@ impl LocalDiskWrapper {
|
|||||||
DiskMetricMutation::Write,
|
DiskMetricMutation::Write,
|
||||||
|| async {
|
|| async {
|
||||||
// Preserve the former DiskAPI future's single boxing boundary.
|
// Preserve the former DiskAPI future's single boxing boundary.
|
||||||
let observed = Box::pin(operation.disk.rename_data_observed(
|
let observed =
|
||||||
&src_volume,
|
Box::pin(
|
||||||
&src_path,
|
operation
|
||||||
&fi,
|
.disk
|
||||||
&dst_volume,
|
.rename_data_observed(&src_volume, &src_path, &fi, &dst_volume, &dst_path),
|
||||||
&dst_path,
|
)
|
||||||
namespace_owner,
|
|
||||||
))
|
|
||||||
.await;
|
.await;
|
||||||
preflight_rejection = observed.preflight_rejection;
|
preflight_rejection = observed.preflight_rejection;
|
||||||
observed.result
|
observed.result
|
||||||
@@ -1395,7 +1301,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,12 +218,10 @@ 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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user