mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-04 19:25:40 +00:00
Compare commits
216 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57f7678bc1 | |||
| 65ed86f76e | |||
| 3da3bcd9ee | |||
| 20e4fd7de6 | |||
| da28f8c843 | |||
| 507447da12 | |||
| 81014fd233 | |||
| ff3c5a4989 | |||
| 4d226998e2 | |||
| f6bed1a73a | |||
| 7ff578ff20 | |||
| f16a30b231 | |||
| 6f717b2752 | |||
| 3654c147e2 | |||
| a9373288f2 | |||
| fdb28e611f | |||
| 5f739944a3 | |||
| 2bc88418f5 | |||
| 9d10d69a6d | |||
| 9863f4848d | |||
| 7dfc2ee5f0 | |||
| 80c88a9031 | |||
| 3005efe845 | |||
| 3a914b429d | |||
| 8a8616b8f7 | |||
| 62e66baf89 | |||
| 53cabe9274 | |||
| 0f272ddb14 | |||
| 0181a583a6 | |||
| 0713a723cd | |||
| a6cb34c7a4 | |||
| 703766677b | |||
| f7c51271ee | |||
| 0e6ee3bf62 | |||
| 3ab7a1921f | |||
| 86ebcb325c | |||
| 8023cf3e26 | |||
| 54a7e9f307 | |||
| 5e58b1d3a2 | |||
| df30dff1a7 | |||
| d011ec9952 | |||
| 1747ed0292 | |||
| 74be040c62 | |||
| df4fdef1d8 | |||
| 759a395fec | |||
| d04611ba38 | |||
| 37344a84da | |||
| c1070f1447 | |||
| 8bf569899a | |||
| 98f7e63396 | |||
| 07dce1cab0 | |||
| b727e669db | |||
| 0fe6cc3641 | |||
| 5be9d255e6 | |||
| 817adb127c | |||
| e3349f5f30 | |||
| a5bde8b0af | |||
| a23d4b05a3 | |||
| 183b5c9ede | |||
| 1ab6405ac9 | |||
| 9e0663cbba | |||
| ba20af77bb | |||
| 2231633ae1 | |||
| 2e2bc814b1 | |||
| 1dd81cf276 | |||
| 01db1f6644 | |||
| 7e1f261e38 | |||
| 99f85ca2b1 | |||
| afc66b7182 | |||
| 922552083f | |||
| 32eb116cbc | |||
| 68f47b9219 | |||
| de7422b508 | |||
| 1bbfa71b11 | |||
| c066faf07a | |||
| ca8bbbf8f3 | |||
| bc789332b6 | |||
| b422d1fea9 | |||
| 87bc9d14ea | |||
| fddaeba247 | |||
| b46a8164f8 | |||
| b1faaafb1f | |||
| 0a975f2fe2 | |||
| ceeff52229 | |||
| e04e15aed1 | |||
| afa84fa988 | |||
| 7aaed4d67b | |||
| 36e07e104a | |||
| b03804566c | |||
| 40a2470feb | |||
| 7dcfdb3320 | |||
| 397dbcf102 | |||
| 99073938ae | |||
| d22991f33b | |||
| 194c8643c0 | |||
| 5720c5c748 | |||
| 1941189499 | |||
| bba934723a | |||
| cebe57a2f0 | |||
| 6a8a8a1eaf | |||
| 833cc51534 | |||
| 43450df589 | |||
| 394394cdfc | |||
| af896dc427 | |||
| 297ff4688c | |||
| b9b2aa0b76 | |||
| 1dcdfe4817 | |||
| 6e26769265 | |||
| bd66fa9dca | |||
| 1aea7541c8 | |||
| 9e6d34785b | |||
| 03aecc5c3e | |||
| 45fe54e389 | |||
| 2ed5c297ac | |||
| 23ab078c56 | |||
| 80c629bfe0 | |||
| 47304cc68d | |||
| cee84561e7 | |||
| b09ce8e6b5 | |||
| a45951260a | |||
| a41134eb8a | |||
| 35ce8cdb80 | |||
| 0b1a588da5 | |||
| f6c6736a01 | |||
| ab44ae7e83 | |||
| b0256e3453 | |||
| 14a77f9d79 | |||
| 436a1be899 | |||
| 1ea1dfa0a1 | |||
| c45a8c35c4 | |||
| 4932d1dedf | |||
| 041af14143 | |||
| e44007012b | |||
| e3ca1ca54c | |||
| ec0a65703a | |||
| 0d1e40ee73 | |||
| 281e40f1cc | |||
| 7541bb2c5d | |||
| 25dd879cf4 | |||
| af1ebbfb8e | |||
| 3e3eb4d8d5 | |||
| 48b6548988 | |||
| 655f6ae452 | |||
| 61821a6f3e | |||
| 896781a52b | |||
| 612dd38fea | |||
| ff28b79088 | |||
| 35456bcede | |||
| 9d4ccb7884 | |||
| 9a22cb85f3 | |||
| 6c67086d0b | |||
| ea01cd339c | |||
| bb37841362 | |||
| 59a7194d7f | |||
| f647ada320 | |||
| 589a954478 | |||
| 1d606e1cf6 | |||
| dc2e25b48c | |||
| d690f5d60d | |||
| 3eca80e37d | |||
| 45a2ccb734 | |||
| c876df53f5 | |||
| 769da6d81f | |||
| ca46ae9e56 | |||
| 7df0920c80 | |||
| c4ac11d22e | |||
| 602ed2cbcd | |||
| b6c3108e53 | |||
| 8ecd8f2520 | |||
| e6234d3714 | |||
| 042a0c3014 | |||
| 87333f7b24 | |||
| 9945c67f7e | |||
| fca1514aac | |||
| 47ad69b691 | |||
| 489408c0b0 | |||
| 1b3744a1da | |||
| 9244eb36ed | |||
| 442298d5f7 | |||
| be7d35d441 | |||
| ec1cd606d3 | |||
| 16af688a7a | |||
| 37b23a16da | |||
| 006e9b7d28 | |||
| d214c27583 | |||
| 8fd364a99c | |||
| c2d8488728 | |||
| 1370434f3a | |||
| 5dde2c188c | |||
| 2f9c75d04f | |||
| 9ee7b1221d | |||
| fcc3c7fb6b | |||
| 01dc55ee5b | |||
| 3d24526704 | |||
| 51532e19fb | |||
| 931ff60182 | |||
| 07212c4e26 | |||
| 4932af080b | |||
| d6f9a7c462 | |||
| 7345b49cf6 | |||
| 4753e35035 | |||
| 96239fc034 | |||
| b428875bed | |||
| cf362282f0 | |||
| b2a2e637a5 | |||
| 0c18012442 | |||
| ee39e4fccb | |||
| 90ab2e24c3 | |||
| 21e5b3dc64 | |||
| 1e8c8d4cd5 | |||
| ff3ad30f0c | |||
| 47a3f5ef01 | |||
| a22fa7461d | |||
| 814ab5bbf3 | |||
| 498205b7ec | |||
| c235f7c05d |
@@ -22,3 +22,17 @@
|
||||
fixtures and encrypted migration data.
|
||||
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
|
||||
condition, and default toward reading old data safely.
|
||||
|
||||
## Outbound targets
|
||||
|
||||
- A change to what the replication or migration client sends by default
|
||||
(checksum policy, payload framing, headers, version-id addressing) is judged
|
||||
against every target class, not the one it fixes. Name each target-side rule
|
||||
the current default satisfies — checksum required with Object Lock
|
||||
parameters, `aws-chunked` decoding, version-id adoption, ETag equals content
|
||||
MD5 — and show which cell of
|
||||
`crates/e2e_test/src/replication_target_matrix_test.rs` covers each.
|
||||
- A test that asserts the fix ("no trailer header") is not evidence; the
|
||||
matrix cell that asserts the target accepted and stored the object is.
|
||||
- Every new environment escape hatch appears in
|
||||
`docs/operations/replication-outbound-transport.md` in the same diff.
|
||||
|
||||
@@ -50,10 +50,11 @@ consider adding it to the script's `checked_files` list.
|
||||
|
||||
## `check_doc_paths.sh`
|
||||
|
||||
Instruction/architecture docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`,
|
||||
`docs/architecture/*.md`) must not reference repo file paths that no longer
|
||||
exist. If your refactor moved code, update the docs that point at it — the
|
||||
error message lists `doc -> stale-path` pairs.
|
||||
Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
|
||||
Markdown file under `docs/` (architecture, operations, testing, index) must not
|
||||
reference repo file paths that no longer exist. If your refactor moved code,
|
||||
update the docs that point at it — the error message lists `doc -> stale-path`
|
||||
pairs. Cite paths plus symbol names, never line numbers (see `docs/README.md`).
|
||||
|
||||
## `check_no_planning_docs.sh`
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
|
||||
### S3 object actions, copy, multipart, and upload policy validation
|
||||
|
||||
- `GHSA-g8w9-qw9q-fghr`: a valid presigned `PutObject` accepted extra `x-amz-tagging`, website redirect, and storage-class headers omitted from `SignedHeaders`. Lesson: a presigned URL is a bounded capability; reject `x-amz-*` headers that are not cryptographically bound by the signature so unsigned metadata cannot change authorization, lifecycle, redirect, cost, or durability semantics.
|
||||
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
|
||||
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
|
||||
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
||||
@@ -119,7 +120,7 @@ Use these targeted searches when a diff touches security-sensitive code:
|
||||
```bash
|
||||
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
|
||||
rg -n "authorize_operation|FtpsDriver|SftpDriver|RETR|MKD|SIZE|MDTM|CreateBucket|GetObject|HeadObject" crates/protocols rustfs
|
||||
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|content-length-range|starts-with" rustfs crates
|
||||
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|presign|SignedHeaders|content-length-range|starts-with" rustfs crates
|
||||
rg -n "ListBucketVersions|GetObjectVersion|versionId|VersionId|ExistingObjectTag|ForAllValues|ForAnyValue|POLICY_PLUGIN|opa" rustfs crates
|
||||
rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
|
||||
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
|
||||
@@ -136,6 +137,7 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
|
||||
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases, and assert denied paths do not reach the storage backend.
|
||||
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
||||
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, and storage-class headers; require rejection before storage access, and verify explicitly signed equivalents still work.
|
||||
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
|
||||
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
|
||||
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=d6aa36cfaae2c4d8590482c7e47138c5965b335b34a75f50d11ffc3366e9021e
|
||||
sha256-linux=96db8060fce98addda4f69092d297ca236bec4892d820617a26a261eedac61b0
|
||||
sha256-darwin=a881fd7d3f5cb94654221ca85b8b30cce1b95e608824a55a15339cbc294e6d34
|
||||
sha256-linux=e9a8d64e73f627c4d26c236dbbba690c9ee03a9e26d42a4244515b4439365535
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
sha256=9b9bc336b43b70d0e06e0adb5455bf035bb18945d85d60936eb6fe4d48e0e680
|
||||
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
|
||||
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
sha256=87c05c46d611ea7ed3feb5f7276bda8e5a0f70d72165d305d73a457907e7ba79
|
||||
@@ -1 +1 @@
|
||||
sha256=8d5517f5f2fc32d561782dfccd51b7f746f5e25b2835e37e100c883f7f18777d
|
||||
sha256=95c8adc016bbc0df9fb2afa24a108bcdf6567ec4d0518725a6cae301593ab556
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=294350518743cac8d7c41880a2835216e4b697908d7b0b1bc92b62816d94c59d
|
||||
sha256=a2542dc86bbff56b2177efc621785c56fa7e8d813b209b7d935e1e41a9f0ad15
|
||||
|
||||
@@ -23,4 +23,4 @@ coverage: core-deps ## Workspace line coverage (cargo-llvm-cov + nextest; slow,
|
||||
@mkdir -p target/llvm-cov
|
||||
cargo llvm-cov report --lcov --output-path target/llvm-cov/lcov.info
|
||||
cargo llvm-cov report --json --output-path target/llvm-cov/coverage.json
|
||||
python3 scripts/coverage_per_crate.py target/llvm-cov/coverage.json
|
||||
$(RUSTFS_PYTHON_BIN) scripts/coverage_per_crate.py target/llvm-cov/coverage.json
|
||||
|
||||
@@ -88,7 +88,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
|
||||
.PHONY: test-wiring-check
|
||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||
@echo "🧪 Checking test wiring..."
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
||||
|
||||
.PHONY: log-analyzer-rules-check
|
||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||
|
||||
@@ -35,13 +35,14 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_pinned_paired_abba_bench.sh
|
||||
./scripts/test_manual_transition_runbooks.sh
|
||||
./scripts/test_fuzz_runner.sh
|
||||
./scripts/test_python_bin.sh
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_security_coverage.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/s3-tests/test_report_compat.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||
|
||||
.PHONY: test
|
||||
|
||||
+165
-24
@@ -46,6 +46,11 @@ e2e-reliability = { max-threads = 1 }
|
||||
e2e-inline-boundaries = { max-threads = 1 }
|
||||
e2e-cluster-nightly = { max-threads = 1 }
|
||||
|
||||
# Deep async storage futures are composed into tests across several crates.
|
||||
# Keep the test stack bounded but above libtest's 2 MiB default.
|
||||
[scripts.setup.ecstore-base-stack]
|
||||
command = ['sh', '-c', 'echo RUST_MIN_STACK=4194304 >> "$NEXTEST_ENV"']
|
||||
|
||||
# These exact regression scenarios build deep async storage futures that exceed
|
||||
# libtest's 2 MiB spawned-thread stack on Linux. Give only their test processes
|
||||
# the same 32 MiB stack already used by the crate's dedicated large-stack tests.
|
||||
@@ -60,9 +65,13 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||
|
||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|prepared_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)))$/)'
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||
setup = 'lifecycle-large-stack'
|
||||
@@ -80,6 +89,29 @@ test-group = 'ecstore-serial-flaky'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the heal result-report tests. Every test in the module builds a
|
||||
# real-disk (TempDir-backed) hermetic erasure set and drives MiB-scale writes
|
||||
# plus deep-scan heal — the same load-sensitive cross-disk IO shape as the
|
||||
# crash_consistency scenarios above. Under a heavily parallel run a single
|
||||
# disk's IO can fail while write quorum still holds, which flips per-disk
|
||||
# readback and aggregate-outcome assertions nondeterministically (different
|
||||
# tests each round; all pass standalone). Preventive serialization only, no
|
||||
# retries. The matching ci-profile override is after [profile.ci].
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::heal::heal_result_report_tests::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the metadata-cache generation-retirement pair. Both carry
|
||||
# #[serial(metadata_cache_invalidation_probe)] — a no-op across nextest's
|
||||
# process boundary — and assert get_object_metadata_cache generation
|
||||
# semantics on a 4-disk hermetic set, the same load-sensitive shape that
|
||||
# forced the transition matrix tests into this group. Preventive
|
||||
# serialization only, no retries. The matching ci-profile override is after
|
||||
# [profile.ci].
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(retires_cached_snapshot)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# The production-handler relocation regression builds an isolated 8-disk,
|
||||
# 2-pool store and commits a 72 MiB multipart object. Keep that cross-disk IO
|
||||
# from overlapping the ecstore commit fixtures above.
|
||||
@@ -100,12 +132,29 @@ test-group = 'embedded-test-ports'
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the transition matrix tests. They build a 4-disk hermetic erasure
|
||||
# set, populate the get_object_metadata_cache, and assert generation lifecycle
|
||||
# semantics. serial_test's #[serial] has no effect across nextest's process
|
||||
# boundary, so concurrent execution races the shared metadata-cache generation
|
||||
# counter and causes spurious "metadata read should publish the generation"
|
||||
# panics. Preventive serialization, no retries.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(set_disk::transition_matrix_tests::)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# The durable ILM decommission regressions build isolated multi-pool stores and
|
||||
# deliberately take source or target disks offline while checking fencing.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Decommission entry and marker/barrier tests share process-wide fault hooks and
|
||||
# deterministic commit barriers. Keep the whole init decommission family in one
|
||||
# nextest group; serial_test alone cannot isolate separate test processes.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^store::init::tests::(decommission_|suspended_.*decommission)$/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the bucket-incarnation / lifecycle-fence tests. They drive
|
||||
# init_bucket_metadata_sys and bucket_metadata_sys_of, i.e. process-global
|
||||
# OnceLock state that serial_test's #[serial] cannot protect across nextest's
|
||||
@@ -157,9 +206,13 @@ fail-fast = false
|
||||
path = "junit.xml"
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|prepared_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)))$/)'
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||
setup = 'lifecycle-large-stack'
|
||||
@@ -220,6 +273,20 @@ test-group = 'e2e-reliability'
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the heal result-report tests under the ci profile too (see the
|
||||
# matching default-profile override near the top). Not a quarantine: no
|
||||
# retries, just serialized real-disk heal IO.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::heal::heal_result_report_tests::/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the metadata-cache generation-retirement pair under the ci
|
||||
# profile too (see the matching default-profile override near the top). Not a
|
||||
# quarantine: no retries.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(retires_cached_snapshot)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Match the default-profile embedded test isolation without quarantining or
|
||||
# retrying failures in CI.
|
||||
[[profile.ci.overrides]]
|
||||
@@ -232,10 +299,20 @@ test-group = 'embedded-test-ports'
|
||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the transition matrix tests under the ci profile too (see the
|
||||
# matching default-profile override near the top). No retries.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(set_disk::transition_matrix_tests::)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^store::init::tests::(decommission_|suspended_.*decommission)$/)'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
|
||||
# Serialize the bucket-incarnation / lifecycle-fence tests under the ci profile
|
||||
# too (see the matching default-profile override near the top). No retries.
|
||||
[[profile.ci.overrides]]
|
||||
@@ -278,7 +355,8 @@ test-group = 'ecstore-serial-flaky'
|
||||
# allowlist", so any new replication test lands in nightly by default (never
|
||||
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
||||
# regexes byte-identical. The committed profile selection digests make changes
|
||||
# visible in CI; current counts live in docs/testing/e2e-suite-inventory.md.
|
||||
# visible in CI; list current membership with `cargo nextest list -p e2e_test
|
||||
# --profile <profile>` (platform-dependent; see docs/testing/README.md).
|
||||
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
|
||||
# (#4724) because they set a loopback (127.0.0.1) replication target that the
|
||||
# SSRF egress guard rejected on every PR after repl-1 (#4712). That is fixed —
|
||||
@@ -316,13 +394,32 @@ test-group = 'ecstore-serial-flaky'
|
||||
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
|
||||
# Single-node servers on random ports with isolated temp dirs — meets the
|
||||
# admission criteria unchanged.
|
||||
#
|
||||
# On-demand migration GA (backlog#2163 ODM-16): three named cases join the
|
||||
# lane, one per user-visible contract of the feature — a GET miss that pulls
|
||||
# the object and persists it locally, a HEAD miss that answers from the source
|
||||
# and stores nothing, and the admin config/status pair that must redact the
|
||||
# source secret. Each spawns one single-node rustfs server plus the in-process
|
||||
# fake S3 source (`fake_s3_target`, already in the first clause), so they meet
|
||||
# the admission criteria unchanged; measured at 15.8 s / 15.8 s / 15.9 s, which
|
||||
# is entirely the shared server startup and overlaps the lane's other tests.
|
||||
# The rest of `on_demand_migration::{get_basic,interaction,backfill,
|
||||
# harness_self}_test` stays in e2e-full and the fault / concurrency /
|
||||
# real-source modules stay in e2e-nightly; this is an allowlist, not a module
|
||||
# clause, so a new ODM test never lands here silently.
|
||||
#
|
||||
# Scanner authoritative usage publication (backlog#2213): data_usage_test is
|
||||
# the PR-lane e2e coverage for scanner usage snapshots consumed by quota and
|
||||
# admin surfaces. It uses the same single-node, random-port, isolated-temp-dir
|
||||
# fixture as the existing smoke modules.
|
||||
[profile.e2e-smoke]
|
||||
default-filter = """
|
||||
package(e2e_test) & (
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
|
||||
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
||||
| test(/^reliant::lifecycle::/)
|
||||
| test(/^reliant::tiering::/)
|
||||
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
@@ -362,6 +459,10 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
# until it is explicitly promoted to the fast PR subset — no replication test
|
||||
# is ever silently left out of CI.
|
||||
#
|
||||
# replication_target_matrix_test (the outbound target matrix: every object
|
||||
# shape against every remote-target failure mode the fake target models) runs
|
||||
# here in full; its expectation table pins known-red cells to open issues.
|
||||
#
|
||||
# #[serial] does NOT serialize under nextest (process-per-test; see the file
|
||||
# header). These tests need no cross-test serialization: each spawns its own
|
||||
# server(s) on random ports with isolated temp dirs, so they are parallel-safe
|
||||
@@ -379,7 +480,7 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
[profile.e2e-repl-nightly]
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& test(/^replication_extension_test::/)
|
||||
& (test(/^replication_extension_test::/) | test(/^replication_target_matrix_test::/))
|
||||
& !test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
||||
"""
|
||||
fail-fast = false
|
||||
@@ -392,15 +493,29 @@ path = "junit.xml"
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-nightly profile — destructive multi-process cluster fault domains
|
||||
# ---------------------------------------------------------------------------
|
||||
# These seven modules are deliberately outside e2e-full's merge budget. Each
|
||||
# These eight modules are deliberately outside e2e-full's merge budget. Each
|
||||
# starts a real multi-process or multi-disk topology and exercises node/disk
|
||||
# loss, quorum, cleanup, notification fan-in, or admin-timeout behavior. The
|
||||
# consolidated nightly workflow runs them serially to avoid resource
|
||||
# starvation; failures are never retried.
|
||||
#
|
||||
# heal_erasure_disk_rebuild_test also runs in e2e-full so core heal rebuild
|
||||
# regressions are caught no later than the merge/main lane. It remains here for
|
||||
# nightly serial coverage with the other cluster fault domains.
|
||||
#
|
||||
# On-demand migration (backlog#2158 ODM-11) joins by the second clause: the
|
||||
# fault matrix waits out the 30 s circuit-breaker window, the concurrency
|
||||
# matrix drives 100-deep bursts, and the real-source cases start a second
|
||||
# (loop guard: a third) RustFS process. They are too slow or too heavy for
|
||||
# the merge budget; `on_demand_migration::{get_basic,interaction}_test` stay
|
||||
# in e2e-full, which excludes exactly these three modules.
|
||||
[profile.e2e-nightly]
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& (
|
||||
test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
| test(/^on_demand_migration::(concurrency_test|fault_test|real_source_test)::/)
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
|
||||
@@ -411,6 +526,34 @@ path = "junit.xml"
|
||||
filter = 'package(e2e_test)'
|
||||
test-group = 'e2e-cluster-nightly'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
|
||||
# ---------------------------------------------------------------------------
|
||||
# backlog#2167. Report-only, scheduled, never a required check; wired by
|
||||
# .github/workflows/on-demand-migration-interop.yml.
|
||||
#
|
||||
# The four cases in `on_demand_migration::interop_test` take their source from
|
||||
# the environment (`RUSTFS_ODM_INTEROP_*`, documented on the constants in
|
||||
# `crates/e2e_test/src/on_demand_migration/common.rs`), so the same bodies run
|
||||
# against the in-process fake source locally and against a MinIO container or a
|
||||
# real cloud provider in the lane. The cloud jobs narrow this profile with their
|
||||
# own `-E` filter to the three-case minimum (GET miss, HEAD miss, merged list
|
||||
# pagination) and pass `--no-tests=fail` so a rename cannot silently select
|
||||
# nothing; the MinIO job runs the whole profile, backfill included.
|
||||
#
|
||||
# These cases are deliberately absent from every other lane: without an
|
||||
# interop source they only re-prove what `get_basic_test` and
|
||||
# `list_through_test` already cover in e2e-smoke and e2e-full. The committed
|
||||
# selection digest is the guard against a rename dropping one of them.
|
||||
[profile.e2e-odm-interop]
|
||||
default-filter = 'package(e2e_test) & test(/^on_demand_migration::interop_test::/)'
|
||||
fail-fast = false
|
||||
|
||||
[profile.e2e-odm-interop.junit]
|
||||
# Emitted to target/nextest/e2e-odm-interop/junit.xml; the lane uploads it and
|
||||
# reconciles it against the per-case JSON report entries.
|
||||
path = "junit.xml"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-protocols profile — serial protocol lane
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -431,16 +574,23 @@ path = "junit.xml"
|
||||
# quota, checksum, encryption,
|
||||
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
|
||||
# skips. Budget <= 45 min; authority for the suite count is `cargo nextest list
|
||||
# --profile e2e-full` (see docs/testing/e2e-suite-inventory.md).
|
||||
# --profile e2e-full -p e2e_test` (platform-dependent; see docs/testing/README.md).
|
||||
#
|
||||
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
|
||||
# * protocols:: — FTPS/SFTP/WebDAV, run from the dedicated protocol profile
|
||||
# with one worker because the suite owns fixed ports.
|
||||
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
|
||||
# * cluster suites that spin up a RustFSTestClusterEnvironment
|
||||
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
|
||||
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
|
||||
# object_lambda) — too heavy for the merge budget; they run in the
|
||||
# e2e-nightly serial cluster-fault lane.
|
||||
# namespace_lock_quorum, admin_timeout_regression, object_lambda) — too
|
||||
# heavy for the merge budget; they run in the e2e-nightly serial
|
||||
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
|
||||
# excluded here because backlog#2213 promotes core heal rebuild coverage to
|
||||
# this merge/main lane while retaining nightly coverage.
|
||||
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
|
||||
# cases, which are meaningless without a source: they run in the dedicated
|
||||
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
|
||||
# MinIO container or a real cloud provider. Excluding them here also keeps
|
||||
# this profile's committed selection digest stable.
|
||||
# * replication_extension_test — repl-1 already splits it into the PR
|
||||
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
|
||||
# it for those, so e2e-full does not double-run it.
|
||||
@@ -452,23 +602,14 @@ path = "junit.xml"
|
||||
# parallel-safe — the same property e2e-smoke relies on. The exceptions are the
|
||||
# 4-disk reliability / degraded-read fault-injection tests and the fixed-port
|
||||
# Vault tests, both serialized below.
|
||||
# KNOWN-FAILURE EXCLUSIONS (characterization run 29381309848, 2026-07-15:
|
||||
# 341 ran / 32 failed on the suites' first automated run ever). Deterministic
|
||||
# product failures cannot be quarantined away with retries, so each family is
|
||||
# excluded here with its tracking issue, under the same discipline as the
|
||||
# ci-profile quarantine (docs/testing/README.md): every entry MUST cite one
|
||||
# OPEN issue, and the fixing PR MUST delete the exclusion. The passing
|
||||
# negative-path siblings of each family stay in as regression guards.
|
||||
# * rustfs#4843 — over-limit archive entry paths hard-reject the whole
|
||||
# archive even under ignore-errors semantics.
|
||||
[profile.e2e-full]
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& !test(/^protocols::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^replication_extension_test::/)
|
||||
& !test(/^multipart_auth_test::test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled$/)
|
||||
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(ignores_invalid_entries_when_requested|supports_standard_headers_with_combined_extract_options)$/)
|
||||
& !test(/^replication_target_matrix_test::/)
|
||||
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
||||
"""
|
||||
fail-fast = false
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
name: On-demand migration interop report
|
||||
description: >-
|
||||
Merge the per-case JSON entries an on-demand-migration interop run wrote with
|
||||
the nextest JUnit result into one provider report, and summarise it.
|
||||
|
||||
inputs:
|
||||
provider:
|
||||
description: Provider the run addressed (minio, aws, r2, gcs).
|
||||
required: true
|
||||
cases-dir:
|
||||
description: Directory the cases wrote their JSON entries into.
|
||||
required: true
|
||||
junit:
|
||||
description: nextest JUnit XML of the run.
|
||||
required: true
|
||||
output:
|
||||
description: Path of the merged JSON report to write.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# The JUnit file is authoritative for which cases ran and how they ended:
|
||||
# a case that fails or panics never reaches its own report entry, so
|
||||
# trusting the entries alone would silently shorten the report exactly when
|
||||
# something went wrong. The entries only add what JUnit cannot know — the
|
||||
# source request accounting and the bucket's migration counters.
|
||||
- name: Merge interop case reports
|
||||
shell: bash
|
||||
env:
|
||||
ODM_REPORT_PROVIDER: ${{ inputs.provider }}
|
||||
ODM_REPORT_CASES_DIR: ${{ inputs.cases-dir }}
|
||||
ODM_REPORT_JUNIT: ${{ inputs.junit }}
|
||||
ODM_REPORT_OUTPUT: ${{ inputs.output }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import xml.etree.ElementTree as ElementTree
|
||||
|
||||
provider = os.environ["ODM_REPORT_PROVIDER"]
|
||||
cases_dir = pathlib.Path(os.environ["ODM_REPORT_CASES_DIR"])
|
||||
junit = pathlib.Path(os.environ["ODM_REPORT_JUNIT"])
|
||||
output = pathlib.Path(os.environ["ODM_REPORT_OUTPUT"])
|
||||
|
||||
entries = {}
|
||||
if cases_dir.is_dir():
|
||||
for path in sorted(cases_dir.glob("*.json")):
|
||||
entry = json.loads(path.read_text())
|
||||
entries[entry["case"]] = entry
|
||||
|
||||
cases = []
|
||||
for case in ElementTree.parse(junit).getroot().iter("testcase"):
|
||||
name = case.get("name", "")
|
||||
failed = [child for child in case if child.tag in ("failure", "error")]
|
||||
skipped = [child for child in case if child.tag == "skipped"]
|
||||
outcome = "failed" if failed else "skipped" if skipped else "passed"
|
||||
entry = entries.get(name.rsplit("::", 1)[-1], {})
|
||||
cases.append(
|
||||
{
|
||||
"name": name,
|
||||
"outcome": outcome,
|
||||
"junit_duration_ms": round(float(case.get("time", "0")) * 1000),
|
||||
"case_duration_ms": entry.get("duration_ms"),
|
||||
"source_requests": entry.get("source_requests"),
|
||||
"odm_counters": entry.get("odm_counters"),
|
||||
}
|
||||
)
|
||||
|
||||
report = {
|
||||
"provider": provider,
|
||||
"repository": os.environ.get("GITHUB_REPOSITORY", ""),
|
||||
"sha": os.environ.get("GITHUB_SHA", ""),
|
||||
"run_id": os.environ.get("GITHUB_RUN_ID", ""),
|
||||
"cases": cases,
|
||||
"totals": {
|
||||
"cases": len(cases),
|
||||
"passed": sum(1 for case in cases if case["outcome"] == "passed"),
|
||||
"failed": sum(1 for case in cases if case["outcome"] == "failed"),
|
||||
},
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
summary = [f"### On-demand migration interop: `{provider}`", "", "| Case | Outcome | Duration | Source requests |", "|---|---|---|---|"]
|
||||
for case in cases:
|
||||
requests = case["source_requests"]
|
||||
counted = f"{requests['total']} ({requests['counted_by']})" if requests else "not reported"
|
||||
summary.append(f"| `{case['name']}` | {case['outcome']} | {case['junit_duration_ms']} ms | {counted} |")
|
||||
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(summary) + "\n\n")
|
||||
|
||||
# A passed case with no entry of its own means the harness stopped
|
||||
# writing one: the report would keep looking complete while silently
|
||||
# losing its request accounting.
|
||||
unreported = [case["name"] for case in cases if case["outcome"] == "passed" and case["source_requests"] is None]
|
||||
if unreported:
|
||||
raise SystemExit(f"passed cases wrote no interop report entry: {', '.join(unreported)}")
|
||||
PY
|
||||
@@ -24,8 +24,11 @@ on:
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/release/package_versions.sh'
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened, closed ]
|
||||
@@ -37,8 +40,11 @@ on:
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/release/package_versions.sh'
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
schedule:
|
||||
# Daily, not weekly. This schedule exists to catch RustSec advisories
|
||||
@@ -146,6 +152,12 @@ jobs:
|
||||
- name: Check performance A/B workflow trust boundary
|
||||
run: ./scripts/security/check_performance_ab_workflow.sh
|
||||
|
||||
- name: Check tier evidence workflow isolation
|
||||
run: ./scripts/security/check_tier_artifact_workflow.sh
|
||||
|
||||
- name: Check package version contract
|
||||
run: ./scripts/test_package_versions.sh
|
||||
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -244,7 +244,7 @@ jobs:
|
||||
needs: [ build-check, prepare-platform-matrix ]
|
||||
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 150
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
# Release binaries ship without dial9 telemetry and therefore do not need
|
||||
@@ -408,9 +408,9 @@ jobs:
|
||||
|
||||
if [[ "${{ matrix.cross }}" == "true" ]]; then
|
||||
# All cross targets in the matrix are Linux; zigbuild handles them.
|
||||
cargo zigbuild --release --target ${{ matrix.target }} -p rustfs --bins
|
||||
cargo zigbuild --release --target ${{ matrix.target }} -p rustfs --bin rustfs
|
||||
else
|
||||
cargo build --release --target ${{ matrix.target }} -p rustfs --bins
|
||||
cargo build --release --target ${{ matrix.target }} -p rustfs --bin rustfs
|
||||
fi
|
||||
|
||||
- name: Create release package
|
||||
@@ -1065,7 +1065,7 @@ jobs:
|
||||
while IFS= read -r preview_tag; do
|
||||
[[ -n "$preview_tag" ]] || continue
|
||||
echo "🧹 Deleting preview release $preview_tag (tag kept)"
|
||||
gh release delete "$preview_tag" --yes
|
||||
gh release delete "$preview_tag" --repo "${GITHUB_REPOSITORY}" --yes
|
||||
DELETED=$((DELETED + 1))
|
||||
done < <(
|
||||
jq -r --arg tag "$TAG" '
|
||||
|
||||
@@ -49,8 +49,20 @@ env:
|
||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||
|
||||
jobs:
|
||||
direct-upgrade:
|
||||
name: Direct upgrade from rc.2
|
||||
upgrade:
|
||||
name: ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Direct upgrade from rc.2
|
||||
cache_key: e2e-direct-upgrade
|
||||
test: direct_upgrade_from_rc2_preserves_object_contracts
|
||||
artifact: direct-upgrade
|
||||
- name: Mixed-version rolling upgrade from rc.2
|
||||
cache_key: e2e-mixed-version-upgrade
|
||||
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
||||
artifact: mixed-version-upgrade
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -64,7 +76,7 @@ jobs:
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: e2e-direct-upgrade
|
||||
cache-shared-key: ${{ matrix.cache_key }}
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: "false"
|
||||
|
||||
@@ -89,17 +101,17 @@ jobs:
|
||||
cargo build --locked -p rustfs --bin rustfs
|
||||
: > target/debug/rustfs.features
|
||||
|
||||
- name: Run direct-upgrade compatibility test
|
||||
- name: Run upgrade compatibility test
|
||||
run: |
|
||||
cargo test --locked -p e2e_test \
|
||||
upgrade_compatibility_test::direct_upgrade_from_rc2_preserves_object_contracts \
|
||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||
-- --ignored --exact --nocapture
|
||||
|
||||
- name: Upload server logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: direct-upgrade-server-logs-${{ github.run_number }}
|
||||
name: ${{ matrix.artifact }}-server-logs-${{ github.run_number }}
|
||||
path: ${{ runner.temp }}/rustfs-upgrade-logs
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# On-demand migration provider interop (rustfs/backlog#2167, ODM-20).
|
||||
#
|
||||
# The in-process fake source that the merge-gate ODM suite runs against covers
|
||||
# the protocol semantics, but real implementations differ in path-style vs
|
||||
# virtual-host addressing, region handling, ETag shape, list pagination and
|
||||
# rate limiting. This lane runs the same case bodies
|
||||
# (crates/e2e_test/src/on_demand_migration/interop_test.rs) against real
|
||||
# sources; the source is injected through RUSTFS_ODM_INTEROP_* environment
|
||||
# variables, so nothing about the cases is duplicated per provider.
|
||||
#
|
||||
# Report-only and scheduled. It is never a required check and must not be
|
||||
# promoted to one: it depends on third-party endpoints and on repository
|
||||
# secrets that a fork does not have.
|
||||
#
|
||||
# Jobs:
|
||||
# * minio-source runs the whole e2e-odm-interop profile — read-through,
|
||||
# HEAD passthrough, merged list pagination and a backfill — against a
|
||||
# pinned MinIO container. The backfill is sized at 5,000 objects here: the
|
||||
# fake source retains at most 4,096 object versions and 4,096 journal
|
||||
# entries, so the merge-gate backfill coverage cannot go past that, and a
|
||||
# real source is where a production-shaped batch belongs.
|
||||
# * cloud-source runs the three-case minimum (GET miss, HEAD miss, merged
|
||||
# list pagination) against AWS S3, Cloudflare R2 and the GCS XML
|
||||
# interoperability API. Each provider is skipped with a summary note when
|
||||
# its ODM_INTEROP_* repository secrets are absent, which is the normal
|
||||
# state on a fork and in any clone of this repository.
|
||||
#
|
||||
# Every job uploads one JSON report per provider naming the cases, their
|
||||
# timings and the source request accounting.
|
||||
name: on-demand-migration-interop
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Nightly at 05:23 UTC, offset from the other nightly lanes.
|
||||
- cron: "23 5 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
# The three cases a cloud provider is asked for. Named individually rather
|
||||
# than by module so adding a fourth case does not silently start billing a
|
||||
# cloud account for it.
|
||||
CLOUD_CASE_FILTER: >-
|
||||
package(e2e_test) & test(/^on_demand_migration::interop_test::(interop_get_miss_pulls_from_the_source_and_serves_locally|interop_head_miss_answers_from_the_source_without_persisting|interop_list_through_pages_the_source_namespace)$/)
|
||||
CLOUD_CASE_COUNT: "3"
|
||||
|
||||
jobs:
|
||||
minio-source:
|
||||
name: MinIO source (read-through, list-through, backfill)
|
||||
# Skip on forks: needs this repository's runners and is not a contributor
|
||||
# gate.
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
# Fixed credentials of the container this job starts and throws away;
|
||||
# not a secret and deliberately not read from one, so the lane runs
|
||||
# unattended in any clone that enables it.
|
||||
MINIO_ROOT_USER: rustfsodminterop
|
||||
MINIO_ROOT_PASSWORD: rustfsodminteropsecret
|
||||
RUSTFS_ODM_INTEROP_PROVIDER: minio
|
||||
RUSTFS_ODM_INTEROP_ENDPOINT: http://127.0.0.1:9100
|
||||
RUSTFS_ODM_INTEROP_REGION: auto
|
||||
RUSTFS_ODM_INTEROP_BUCKET: odm-interop-source
|
||||
RUSTFS_ODM_INTEROP_PATH_STYLE: path
|
||||
RUSTFS_ODM_INTEROP_ACCESS_KEY: rustfsodminterop
|
||||
RUSTFS_ODM_INTEROP_SECRET_KEY: rustfsodminteropsecret
|
||||
RUSTFS_ODM_INTEROP_BACKFILL_OBJECTS: "5000"
|
||||
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/minio/cases
|
||||
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/minio/selection.json
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: ci-odm-interop
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Start MinIO source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/odm-interop/minio
|
||||
docker run -d --name rustfs-odm-interop-minio \
|
||||
-e "MINIO_ROOT_USER=${MINIO_ROOT_USER}" \
|
||||
-e "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" \
|
||||
-p 9100:9000 \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
|
||||
for _ in $(seq 1 120); do
|
||||
curl -fsS http://127.0.0.1:9100/minio/health/live >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://127.0.0.1:9100/minio/health/live
|
||||
|
||||
# The harness never creates a bucket, so that pointing it at a cloud
|
||||
# account cannot create one there either. The source bucket for the
|
||||
# container is created here instead.
|
||||
- name: Create the MinIO source bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ env.MINIO_ROOT_USER }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ env.MINIO_ROOT_PASSWORD }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
aws --endpoint-url "${RUSTFS_ODM_INTEROP_ENDPOINT}" \
|
||||
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
||||
|
||||
- name: Build the RustFS binary under test
|
||||
run: cargo build --locked -p rustfs --bins
|
||||
|
||||
# The lane selects tests by module, so a rename would quietly shrink it.
|
||||
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
||||
# closed on that.
|
||||
- name: Verify interop lane membership
|
||||
run: |
|
||||
cargo nextest list --profile e2e-odm-interop -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
||||
|
||||
- name: Run the interop cases against MinIO
|
||||
run: cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
||||
|
||||
- name: Build the MinIO interop report
|
||||
if: always()
|
||||
uses: ./.github/actions/odm-interop-report
|
||||
with:
|
||||
provider: minio
|
||||
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
|
||||
junit: target/nextest/e2e-odm-interop/junit.xml
|
||||
output: artifacts/odm-interop/minio/report.json
|
||||
|
||||
- name: Collect MinIO logs
|
||||
if: always()
|
||||
run: |
|
||||
docker logs --tail 500 rustfs-odm-interop-minio \
|
||||
> artifacts/odm-interop/minio/minio.log 2>&1 || true
|
||||
|
||||
- name: Stop MinIO source
|
||||
if: always()
|
||||
run: docker rm -f rustfs-odm-interop-minio >/dev/null 2>&1 || true
|
||||
|
||||
- name: Upload the MinIO interop report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: odm-interop-minio-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/odm-interop/minio
|
||||
target/nextest/e2e-odm-interop/junit.xml
|
||||
retention-days: 14
|
||||
|
||||
# Unlike a production migration source, which needs read access only, the
|
||||
# credentials here also seed the objects each case reads back, so they need
|
||||
# write and delete on the interop bucket. Every run seeds under
|
||||
# `odm-interop/<case>/<uuid>/` and deletes what it seeded when the case
|
||||
# passes; give the bucket an expiration lifecycle rule so the prefixes a
|
||||
# failing case leaves behind cannot accumulate.
|
||||
cloud-source:
|
||||
name: ${{ matrix.provider }} source (three-case minimum)
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- provider: aws
|
||||
secret_prefix: AWS
|
||||
path_style: virtual
|
||||
- provider: r2
|
||||
secret_prefix: R2
|
||||
path_style: virtual
|
||||
- provider: gcs
|
||||
secret_prefix: GCS_HMAC
|
||||
path_style: virtual
|
||||
env:
|
||||
RUSTFS_ODM_INTEROP_PROVIDER: ${{ matrix.provider }}
|
||||
RUSTFS_ODM_INTEROP_PATH_STYLE: ${{ matrix.path_style }}
|
||||
RUSTFS_ODM_INTEROP_ENDPOINT: ${{ secrets[format('ODM_INTEROP_{0}_ENDPOINT', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_REGION: ${{ secrets[format('ODM_INTEROP_{0}_REGION', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_BUCKET: ${{ secrets[format('ODM_INTEROP_{0}_BUCKET', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_ACCESS_KEY: ${{ secrets[format('ODM_INTEROP_{0}_ACCESS_KEY_ID', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_SECRET_KEY: ${{ secrets[format('ODM_INTEROP_{0}_SECRET_ACCESS_KEY', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/cases
|
||||
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/selection.json
|
||||
steps:
|
||||
# Absent secrets are the normal state, not a failure: the lane reports
|
||||
# which providers it could reach and skips the rest. An empty value is
|
||||
# what an unset repository secret expands to, so it is checked, not the
|
||||
# secret's existence.
|
||||
- name: Check for provider credentials
|
||||
id: credentials
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${RUSTFS_ODM_INTEROP_ENDPOINT}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_REGION}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_BUCKET}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_ACCESS_KEY}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_SECRET_KEY}" ]; then
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### On-demand migration interop: \`${{ matrix.provider }}\`"
|
||||
echo
|
||||
echo "Skipped: the \`ODM_INTEROP_${{ matrix.secret_prefix }}_*\` repository secrets"
|
||||
echo "(\`_ENDPOINT\`, \`_REGION\`, \`_BUCKET\`, \`_ACCESS_KEY_ID\`, \`_SECRET_ACCESS_KEY\`)"
|
||||
echo "are not configured, so no real \`${{ matrix.provider }}\` source was reached."
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: ci-odm-interop
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build the RustFS binary under test
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: cargo build --locked -p rustfs --bins
|
||||
|
||||
# A filterset that matches nothing is valid, so the count is asserted
|
||||
# rather than inferred from a green run.
|
||||
- name: Verify the three-case minimum still selects three cases
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$(dirname "${NEXTEST_LISTING}")"
|
||||
cargo nextest list --profile e2e-odm-interop -p e2e_test \
|
||||
-E "${CLOUD_CASE_FILTER}" --message-format json > "${NEXTEST_LISTING}"
|
||||
selected="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(sum(1 for suite in d.get("rust-suites", {}).values() for test in suite.get("testcases", {}).values() if test.get("filter-match", {}).get("status") == "matches"))' "${NEXTEST_LISTING}")"
|
||||
echo "cloud interop cases selected: ${selected}"
|
||||
if [ "${selected}" != "${CLOUD_CASE_COUNT}" ]; then
|
||||
echo "::error::CLOUD_CASE_FILTER selected ${selected} cases, expected ${CLOUD_CASE_COUNT}; the interop cases were renamed or moved. Context: rustfs/backlog#2167."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run the three-case minimum
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: |
|
||||
cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
||||
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
||||
|
||||
- name: Build the ${{ matrix.provider }} interop report
|
||||
if: always() && steps.credentials.outputs.present == 'true'
|
||||
uses: ./.github/actions/odm-interop-report
|
||||
with:
|
||||
provider: ${{ matrix.provider }}
|
||||
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
|
||||
junit: target/nextest/e2e-odm-interop/junit.xml
|
||||
output: artifacts/odm-interop/${{ matrix.provider }}/report.json
|
||||
|
||||
- name: Upload the ${{ matrix.provider }} interop report
|
||||
if: always() && steps.credentials.outputs.present == 'true'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: odm-interop-${{ matrix.provider }}-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/odm-interop/${{ matrix.provider }}
|
||||
target/nextest/e2e-odm-interop/junit.xml
|
||||
retention-days: 14
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [minio-source, cloud-source]
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+160
-90
@@ -21,10 +21,10 @@
|
||||
# - workflow_run: automatically package after "Build and Release" completes
|
||||
# for a release tag (the mac/windows/linux binaries are already uploaded
|
||||
# to the GitHub release before packaging starts)
|
||||
# - workflow_dispatch: manual fallback (backfill / re-run) with optional tag/run_id
|
||||
# - workflow_dispatch: manual fallback with a release tag and/or exact build run ID
|
||||
#
|
||||
# Flow:
|
||||
# 1. Resolve the triggering Build workflow run for the release tag
|
||||
# 1. Resolve and validate the selected Build workflow run and source identity
|
||||
# 2. Download Linux binaries (x86_64-gnu, aarch64-gnu) from build artifacts
|
||||
# 3. Build DEB packages for amd64 and arm64
|
||||
# 4. Build RPM packages for x86_64 and aarch64
|
||||
@@ -51,7 +51,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
build_run_id:
|
||||
description: "Build workflow run ID (overrides tag lookup)"
|
||||
description: "Build workflow run ID (when combined with tag, both must identify the same release commit)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
@@ -82,6 +82,9 @@ jobs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
build_type: ${{ steps.resolve.outputs.build_type }}
|
||||
build_run_id: ${{ steps.resolve.outputs.build_run_id }}
|
||||
build_run_number: ${{ steps.resolve.outputs.build_run_number }}
|
||||
head_sha: ${{ steps.resolve.outputs.head_sha }}
|
||||
dev_sequence: ${{ steps.resolve.outputs.dev_sequence }}
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
steps:
|
||||
- name: Resolve build run
|
||||
@@ -89,90 +92,129 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
INPUT_RUN_ID: ${{ github.event.inputs.build_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Determine tag
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
TAG="${HEAD_BRANCH}"
|
||||
elif [[ -n "$INPUT_TAG" ]]; then
|
||||
TAG="$INPUT_TAG"
|
||||
fail() {
|
||||
echo "❌ $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
TAG=""
|
||||
BUILD_RUN_ID=""
|
||||
case "$EVENT_NAME" in
|
||||
workflow_run)
|
||||
TAG="$HEAD_BRANCH"
|
||||
BUILD_RUN_ID="$WORKFLOW_RUN_ID"
|
||||
;;
|
||||
workflow_dispatch)
|
||||
TAG="$INPUT_TAG"
|
||||
BUILD_RUN_ID="$INPUT_RUN_ID"
|
||||
;;
|
||||
*) fail "unsupported event: $EVENT_NAME" ;;
|
||||
esac
|
||||
|
||||
# Validate and classify tags before using them in API paths or logs.
|
||||
semver_core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
|
||||
prerelease_id='(alpha|beta|rc)\.(0|[1-9][0-9]*)'
|
||||
if [[ -n "$TAG" ]]; then
|
||||
if [[ "$TAG" =~ ^${semver_core}-${prerelease_id}-preview\.(0|[1-9][0-9]*)$ ]]; then
|
||||
BUILD_TYPE=preview
|
||||
elif [[ "$TAG" =~ ^${semver_core}-${prerelease_id}$ ]]; then
|
||||
BUILD_TYPE=prerelease
|
||||
elif [[ "$TAG" =~ ^${semver_core}$ ]]; then
|
||||
BUILD_TYPE=release
|
||||
else
|
||||
fail "tag is not a supported strict package version"
|
||||
fi
|
||||
else
|
||||
TAG=""
|
||||
BUILD_TYPE=development
|
||||
fi
|
||||
|
||||
echo "Tag: ${TAG:-<none>}"
|
||||
|
||||
# Determine build run ID
|
||||
BUILD_RUN_ID=""
|
||||
|
||||
if [[ -n "$INPUT_RUN_ID" ]]; then
|
||||
# Explicit run ID takes priority
|
||||
BUILD_RUN_ID="$INPUT_RUN_ID"
|
||||
echo "Using explicit build run ID: $BUILD_RUN_ID"
|
||||
|
||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
# Use the Build and Release run that triggered this workflow
|
||||
BUILD_RUN_ID="${WORKFLOW_RUN_ID}"
|
||||
echo "Using triggering workflow run: $BUILD_RUN_ID"
|
||||
|
||||
if [[ -n "$BUILD_RUN_ID" ]]; then
|
||||
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "build run ID must be a positive decimal integer"
|
||||
echo "Using selected build run: $BUILD_RUN_ID"
|
||||
elif [[ -n "$TAG" ]]; then
|
||||
# Find the build run that produced this tag
|
||||
echo "Looking for build run for tag: $TAG"
|
||||
BUILD_RUN_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?branch=${TAG}&status=success&per_page=1" \
|
||||
--jq '.workflow_runs[0].id' 2>/dev/null || echo "")
|
||||
BUILD_RUN_ID=$(gh api --method GET \
|
||||
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch="$TAG" -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
|
||||
# Tag might not be a branch; try event=push with head_branch matching
|
||||
BUILD_RUN_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?event=push&status=success&per_page=100" \
|
||||
--jq ".workflow_runs[] | select(.head_branch == \"$TAG\") | .id" 2>/dev/null | head -1 || echo "")
|
||||
fi
|
||||
|
||||
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
|
||||
echo "❌ No successful build run found for tag: $TAG"
|
||||
exit 1
|
||||
if [[ -z "$BUILD_RUN_ID" ]]; then
|
||||
BUILD_RUN_ID=$(gh api --method GET \
|
||||
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f event=push -f status=success -F per_page=100 2>/dev/null |
|
||||
jq -r --arg tag "$TAG" \
|
||||
'[.workflow_runs[] | select(.head_branch == $tag)][0].id // empty' || true)
|
||||
fi
|
||||
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful build run found for tag"
|
||||
echo "Found build run: $BUILD_RUN_ID"
|
||||
|
||||
else
|
||||
# No tag — latest successful main build
|
||||
echo "No tag specified, looking for latest main build"
|
||||
BUILD_RUN_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?branch=main&status=success&per_page=1" \
|
||||
--jq '.workflow_runs[0].id' 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
|
||||
echo "❌ No successful main build found"
|
||||
exit 1
|
||||
fi
|
||||
BUILD_RUN_ID=$(gh api --method GET \
|
||||
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||
-f branch=main -f status=success -F per_page=1 \
|
||||
--jq '.workflow_runs[0].id // empty' 2>/dev/null || true)
|
||||
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful main build found"
|
||||
echo "Latest main build: $BUILD_RUN_ID"
|
||||
fi
|
||||
|
||||
# Determine version and build type
|
||||
# Fetch once and use the same immutable run metadata for identity,
|
||||
# ordering, workflow provenance, and release-channel validation.
|
||||
RUN_JSON=$(gh api "repos/${REPOSITORY}/actions/runs/${BUILD_RUN_ID}") ||
|
||||
fail "cannot read selected build run"
|
||||
RUN_ID=$(jq -r '.id // empty' <<<"$RUN_JSON")
|
||||
RUN_NUMBER=$(jq -r '.run_number // empty' <<<"$RUN_JSON")
|
||||
RUN_STATUS=$(jq -r '.status // empty' <<<"$RUN_JSON")
|
||||
RUN_CONCLUSION=$(jq -r '.conclusion // empty' <<<"$RUN_JSON")
|
||||
RUN_PATH=$(jq -r '.path // empty' <<<"$RUN_JSON")
|
||||
HEAD_SHA=$(jq -r '.head_sha // empty' <<<"$RUN_JSON")
|
||||
RUN_HEAD_BRANCH=$(jq -r '.head_branch // empty' <<<"$RUN_JSON")
|
||||
|
||||
[[ "$RUN_ID" == "$BUILD_RUN_ID" ]] || fail "run metadata ID mismatch"
|
||||
[[ "$RUN_NUMBER" =~ ^[1-9][0-9]*$ ]] || fail "build run number must be a positive decimal integer"
|
||||
[[ "$RUN_STATUS" == completed && "$RUN_CONCLUSION" == success ]] || fail "selected build run is not successful"
|
||||
[[ "$RUN_PATH" == .github/workflows/build.yml ]] || fail "selected run is not Build and Release"
|
||||
[[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || fail "selected build run has an invalid head SHA"
|
||||
[[ "$RUN_HEAD_BRANCH" != *$'\n'* && -n "$RUN_HEAD_BRANCH" ]] || fail "selected build run has an invalid head branch"
|
||||
|
||||
if [[ -n "$TAG" ]]; then
|
||||
[[ "$RUN_HEAD_BRANCH" == "$TAG" ]] || fail "tag and build run head branch do not match"
|
||||
|
||||
TAG_REF_JSON=$(gh api "repos/${REPOSITORY}/git/ref/tags/${TAG}") ||
|
||||
fail "cannot resolve release tag ref"
|
||||
TAG_OBJECT_TYPE=$(jq -r '.object.type // empty' <<<"$TAG_REF_JSON")
|
||||
TAG_OBJECT_SHA=$(jq -r '.object.sha // empty' <<<"$TAG_REF_JSON")
|
||||
depth=0
|
||||
while [[ "$TAG_OBJECT_TYPE" == tag && $depth -lt 5 ]]; do
|
||||
TAG_OBJECT_JSON=$(gh api "repos/${REPOSITORY}/git/tags/${TAG_OBJECT_SHA}") ||
|
||||
fail "cannot peel annotated release tag"
|
||||
TAG_OBJECT_TYPE=$(jq -r '.object.type // empty' <<<"$TAG_OBJECT_JSON")
|
||||
TAG_OBJECT_SHA=$(jq -r '.object.sha // empty' <<<"$TAG_OBJECT_JSON")
|
||||
depth=$((depth + 1))
|
||||
done
|
||||
[[ "$TAG_OBJECT_TYPE" == commit && "$TAG_OBJECT_SHA" =~ ^[0-9a-f]{40}$ ]] ||
|
||||
fail "release tag does not resolve to a commit"
|
||||
[[ "$TAG_OBJECT_SHA" == "$HEAD_SHA" ]] || fail "release tag commit and build run head SHA do not match"
|
||||
VERSION="$TAG"
|
||||
if [[ "$TAG" == *"-preview"* ]]; then
|
||||
BUILD_TYPE="preview"
|
||||
elif [[ "$TAG" == *"alpha"* || "$TAG" == *"beta"* || "$TAG" == *"rc"* ]]; then
|
||||
BUILD_TYPE="prerelease"
|
||||
else
|
||||
BUILD_TYPE="release"
|
||||
fi
|
||||
DEV_SEQUENCE=""
|
||||
else
|
||||
SHORT_SHA=$(gh api "repos/${{ github.repository }}/actions/runs/${BUILD_RUN_ID}" \
|
||||
--jq '.head_sha' 2>/dev/null | head -c 7)
|
||||
VERSION="dev-${SHORT_SHA}"
|
||||
BUILD_TYPE="development"
|
||||
VERSION="dev-${HEAD_SHA}"
|
||||
DEV_SEQUENCE="$RUN_NUMBER"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "build_type=$BUILD_TYPE"
|
||||
echo "build_run_id=$BUILD_RUN_ID"
|
||||
echo "build_run_number=$RUN_NUMBER"
|
||||
echo "head_sha=$HEAD_SHA"
|
||||
echo "dev_sequence=$DEV_SEQUENCE"
|
||||
echo "tag=${TAG}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -180,6 +222,7 @@ jobs:
|
||||
echo " Version: $VERSION"
|
||||
echo " Build type: $BUILD_TYPE"
|
||||
echo " Build run ID: $BUILD_RUN_ID"
|
||||
echo " Build run number: $RUN_NUMBER"
|
||||
|
||||
# Build DEB and RPM packages for each architecture
|
||||
package:
|
||||
@@ -206,6 +249,22 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Normalize package metadata
|
||||
id: versions
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
|
||||
SOURCE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
DEV_SEQUENCE: ${{ needs.resolve.outputs.dev_sequence }}
|
||||
DEB_ARCH: ${{ matrix.deb_arch }}
|
||||
RPM_ARCH: ${{ matrix.rpm_arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
normalized=$(./scripts/release/package_versions.sh \
|
||||
"$BUILD_TYPE" "$SOURCE_VERSION" "$DEV_SEQUENCE" "$DEB_ARCH" "$RPM_ARCH")
|
||||
printf '%s\n' "$normalized" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download binary artifact from build run
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
@@ -245,18 +304,16 @@ jobs:
|
||||
- name: Build DEB package
|
||||
id: deb
|
||||
shell: bash
|
||||
env:
|
||||
DEB_VERSION: ${{ steps.versions.outputs.deb_version }}
|
||||
DEB_ARCH: ${{ matrix.deb_arch }}
|
||||
DEB_FILE: ${{ steps.versions.outputs.deb_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${{ needs.resolve.outputs.version }}"
|
||||
DEB_ARCH="${{ matrix.deb_arch }}"
|
||||
# DEB version: replace - with ~ (1.0.0-beta.12 -> 1.0.0~beta.12)
|
||||
# Use a variable for ~ to prevent tilde expansion by bash
|
||||
TILDE='~'
|
||||
DEB_VERSION="${VERSION/-/$TILDE}"
|
||||
PKG_DIR="rustfs_${DEB_VERSION}_${DEB_ARCH}"
|
||||
PKG_DIR="${DEB_FILE%.deb}"
|
||||
|
||||
echo "Building DEB: ${PKG_DIR}.deb"
|
||||
echo "Building DEB: ${DEB_FILE}"
|
||||
|
||||
mkdir -p "${PKG_DIR}/DEBIAN"
|
||||
mkdir -p "${PKG_DIR}/usr/bin"
|
||||
@@ -333,9 +390,12 @@ jobs:
|
||||
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||
cp README.md "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||
|
||||
fakeroot dpkg-deb --build "${PKG_DIR}"
|
||||
fakeroot dpkg-deb --build "${PKG_DIR}" "$DEB_FILE"
|
||||
|
||||
DEB_FILE="${PKG_DIR}.deb"
|
||||
[[ $(dpkg-deb -f "$DEB_FILE" Package) == rustfs ]]
|
||||
[[ $(dpkg-deb -f "$DEB_FILE" Version) == "$DEB_VERSION" ]]
|
||||
[[ $(dpkg-deb -f "$DEB_FILE" Architecture) == "$DEB_ARCH" ]]
|
||||
dpkg-deb --fsys-tarfile "$DEB_FILE" | tar -tf - | grep -Fx './usr/bin/rustfs' >/dev/null
|
||||
stat --printf='%n %s bytes\n' "$DEB_FILE"
|
||||
echo "deb_file=$DEB_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ DEB built: $DEB_FILE"
|
||||
@@ -343,16 +403,19 @@ jobs:
|
||||
- name: Build RPM package
|
||||
id: rpm
|
||||
shell: bash
|
||||
env:
|
||||
RPM_VERSION: ${{ steps.versions.outputs.rpm_version }}
|
||||
RPM_RELEASE: ${{ steps.versions.outputs.rpm_release }}
|
||||
RPM_ARCH: ${{ matrix.rpm_arch }}
|
||||
RPM_FILE: ${{ steps.versions.outputs.rpm_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${{ needs.resolve.outputs.version }}"
|
||||
RPM_ARCH="${{ matrix.rpm_arch }}"
|
||||
|
||||
echo "Building RPM for ${RPM_ARCH}"
|
||||
|
||||
sudo apt-get update && sudo apt-get install -y ruby ruby-dev build-essential
|
||||
sudo apt-get update && sudo apt-get install -y ruby ruby-dev build-essential rpm
|
||||
sudo gem install fpm
|
||||
./scripts/test_package_versions.sh --require-package-managers
|
||||
|
||||
# Create config file for fpm (DEB build creates it in its package dir structure,
|
||||
# but fpm needs the file to exist before packaging)
|
||||
@@ -367,8 +430,10 @@ jobs:
|
||||
|
||||
fpm -s dir -t rpm \
|
||||
--name rustfs \
|
||||
--version "$VERSION" \
|
||||
--version "$RPM_VERSION" \
|
||||
--iteration "$RPM_RELEASE" \
|
||||
--architecture "$RPM_ARCH" \
|
||||
--package "$RPM_FILE" \
|
||||
--depends "glibc >= 2.31" \
|
||||
--maintainer "RustFS Team <support@rustfs.com>" \
|
||||
--description "High-performance distributed object storage" \
|
||||
@@ -410,13 +475,15 @@ jobs:
|
||||
LICENSE=/usr/share/doc/rustfs/LICENSE \
|
||||
README.md=/usr/share/doc/rustfs/README.md
|
||||
|
||||
RPM_FILE=$(find . -maxdepth 1 -type f -name 'rustfs-*.rpm' -print | head -1)
|
||||
RPM_FILE="${RPM_FILE#./}"
|
||||
if [[ -z "$RPM_FILE" ]]; then
|
||||
if [[ ! -f "$RPM_FILE" ]]; then
|
||||
echo "❌ RPM build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RPM_METADATA=$(rpm -qp --qf '%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n' "$RPM_FILE")
|
||||
EXPECTED_METADATA=$(printf 'rustfs\n%s\n%s\n%s' "$RPM_VERSION" "$RPM_RELEASE" "$RPM_ARCH")
|
||||
[[ "$RPM_METADATA" == "$EXPECTED_METADATA" ]]
|
||||
rpm -qpl "$RPM_FILE" | grep -Fx '/usr/bin/rustfs' >/dev/null
|
||||
stat --printf='%n %s bytes\n' "$RPM_FILE"
|
||||
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ RPM built: $RPM_FILE"
|
||||
@@ -438,6 +505,9 @@ jobs:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
AWS_EC2_METADATA_DISABLED: true
|
||||
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
|
||||
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -455,7 +525,6 @@ jobs:
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
|
||||
BUILD_TYPE="${{ needs.resolve.outputs.build_type }}"
|
||||
if [[ "$BUILD_TYPE" == "development" ]]; then
|
||||
R2_PREFIX="artifacts/rustfs/packages/dev"
|
||||
else
|
||||
@@ -465,9 +534,6 @@ jobs:
|
||||
|
||||
echo "📤 Uploading to $R2_PATH"
|
||||
|
||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
||||
RPM_FILE="${{ steps.rpm.outputs.rpm_file }}"
|
||||
|
||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||
if [[ -n "$f" && -f "$f" ]]; then
|
||||
echo "Uploading: $f"
|
||||
@@ -493,14 +559,13 @@ jobs:
|
||||
if: needs.resolve.outputs.tag != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${{ needs.resolve.outputs.tag }}"
|
||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
||||
RPM_FILE="${{ steps.rpm.outputs.rpm_file }}"
|
||||
|
||||
# Upload the packages, then refresh the release checksums so the new
|
||||
# assets are covered, matching the binary release flow.
|
||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||
@@ -552,14 +617,19 @@ jobs:
|
||||
steps:
|
||||
- name: Print summary
|
||||
shell: bash
|
||||
env:
|
||||
SUMMARY_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SUMMARY_BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
|
||||
SUMMARY_BUILD_RUN_ID: ${{ needs.resolve.outputs.build_run_id }}
|
||||
SUMMARY_PACKAGE_STATUS: ${{ needs.package.result }}
|
||||
run: |
|
||||
{
|
||||
echo "## 📦 Package Summary"
|
||||
echo ""
|
||||
echo "| Item | Value |"
|
||||
echo "|------|-------|"
|
||||
echo "| Version | \`${{ needs.resolve.outputs.version }}\` |"
|
||||
echo "| Build Type | ${{ needs.resolve.outputs.build_type }} |"
|
||||
echo "| Build Run | #${{ needs.resolve.outputs.build_run_id }} |"
|
||||
echo "| Package Status | ${{ needs.package.result }} |"
|
||||
echo "| Version | \`${SUMMARY_VERSION}\` |"
|
||||
echo "| Build Type | ${SUMMARY_BUILD_TYPE} |"
|
||||
echo "| Build Run | #${SUMMARY_BUILD_RUN_ID} |"
|
||||
echo "| Package Status | ${SUMMARY_PACKAGE_STATUS} |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Functional chain driver: runs the ten functional suites in a fixed order
|
||||
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
|
||||
# replication, with performance on its own runner in parallel) and guarantees
|
||||
# the chain keeps moving even when individual suites fail.
|
||||
#
|
||||
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
|
||||
# only chain-triggered runs forward to the next suite via repository_dispatch,
|
||||
# so a standalone run never drags the rest of the chain behind it.
|
||||
#
|
||||
# Why not workflow_run chaining: GitHub does not guarantee delivery of
|
||||
# workflow_run events (they are fire-and-forget), and the head-SHA filter made
|
||||
# newly added suites (storage) unable to trigger at all. Explicit
|
||||
# repository_dispatch handoffs are verifiable and re-drivable.
|
||||
|
||||
name: RustFS Functional Chain
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
# Entry point: start the chain after the nightly build completes. The
|
||||
# build's own conclusion does not gate the chain; each suite reports its
|
||||
# own result to rustfs/backlog and the dashboard.
|
||||
workflows: ["Nightly GNU Build"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
start-chain:
|
||||
name: Start functional chain (upgrade first)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'schedule') }}
|
||||
steps:
|
||||
- name: Dispatch first suite (upgrade)
|
||||
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 start the functional chain" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-upgrade' \
|
||||
-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'
|
||||
@@ -23,6 +23,11 @@ on:
|
||||
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the storage suite finishes. Heal runs
|
||||
# exactly once per chain; the pool expansion workflow no longer embeds
|
||||
# its own heal pass.
|
||||
types: [rustfs-chain-heal]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -43,24 +48,39 @@ env:
|
||||
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
|
||||
jobs:
|
||||
heal-test:
|
||||
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
|
||||
# Manual-only standalone run. Nightly chain already runs heal in
|
||||
# rustfs-pool-expand-test.yml to avoid duplicate heal executions.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
# Standalone manual run, or one link of the nightly functional chain
|
||||
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: main
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -70,11 +90,24 @@ jobs:
|
||||
warp --version || true
|
||||
df -h /data | tail -1
|
||||
|
||||
- name: Reset test environment (before)
|
||||
- name: Cleanup environment (before)
|
||||
if: ${{ inputs.cleanup_before != 'false' }}
|
||||
run: |
|
||||
chmod +x auto-testing/rustfs_heal_test.sh
|
||||
./auto-testing/rustfs_heal_test.sh --reset -y
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||
'
|
||||
done
|
||||
|
||||
- name: Install RustFS package & start cluster
|
||||
run: |
|
||||
@@ -97,14 +130,129 @@ jobs:
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Run heal test (write -> outage -> heal -> verify)
|
||||
id: test
|
||||
run: |
|
||||
./auto-testing/rustfs_heal_test.sh \
|
||||
--steps "3,4,5,6,7" -y \
|
||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||
--stop-node-gb "${{ inputs.stop_node_gb }}" \
|
||||
--warp-stop-gb "${{ inputs.warp_stop_gb }}" \
|
||||
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
||||
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
||||
--log-file /tmp/rustfs-heal-test.log
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-heal-test.log
|
||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
{
|
||||
echo "# RustFS heal test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
||||
SUITE: heal
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'heal'
|
||||
SUITE_LABEL: 'Heal'
|
||||
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: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
@@ -115,10 +263,73 @@ jobs:
|
||||
/tmp/rustfs-warp.*.log
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Reset test environment (after)
|
||||
- name: Cleanup environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
run: |
|
||||
./auto-testing/rustfs_heal_test.sh --reset -y
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: Pool expansion)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-pool' \
|
||||
-F 'client_payload[from_suite]=heal'; then
|
||||
echo "dispatched next suite Pool expansion (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Pool expansion after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after heal (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **heal** to **Pool expansion** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-pool'"
|
||||
echo "- 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-pool'"
|
||||
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
|
||||
if: failure()
|
||||
|
||||
@@ -11,10 +11,21 @@ on:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
workflow_run:
|
||||
# Strict shared-environment order: run after S3 compatibility test succeeds.
|
||||
workflows: ["RustFS S3 Compatibility Test"]
|
||||
types: [completed]
|
||||
enforce_sse_key_policy:
|
||||
description: 'Enable RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY (runs KMS-401/402)'
|
||||
type: boolean
|
||||
default: false
|
||||
frame_v2:
|
||||
description: 'Enable RUSTFS_ENCRYPTION_FRAME_V2 (runs KMS-318)'
|
||||
type: boolean
|
||||
default: false
|
||||
config_secret:
|
||||
description: 'Set RUSTFS_KMS_CONFIG_SECRET (runs KMS-107 config sealing)'
|
||||
required: false
|
||||
type: string
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the S3 compatibility suite finishes.
|
||||
types: [rustfs-chain-kms]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -38,17 +49,29 @@ env:
|
||||
jobs:
|
||||
kms-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: main
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -86,6 +109,7 @@ jobs:
|
||||
|
||||
- name: Run KMS suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-kms.log
|
||||
run: |
|
||||
@@ -94,6 +118,19 @@ jobs:
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
ARGS=(--all-topologies --backends "local,vault-kv2" -y --log-file "${LOG_FILE}")
|
||||
EXTRA_ENV=""
|
||||
if [ "${{ inputs.enforce_sse_key_policy }}" = "true" ]; then
|
||||
EXTRA_ENV+="RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY=true"$'\n'
|
||||
fi
|
||||
if [ "${{ inputs.frame_v2 }}" = "true" ]; then
|
||||
EXTRA_ENV+="RUSTFS_ENCRYPTION_FRAME_V2=true"$'\n'
|
||||
fi
|
||||
if [ -n "${{ inputs.config_secret }}" ]; then
|
||||
EXTRA_ENV+="RUSTFS_KMS_CONFIG_SECRET=${{ inputs.config_secret }}"$'\n'
|
||||
fi
|
||||
if [ -n "${EXTRA_ENV}" ]; then
|
||||
ARGS+=(--extra-env "${EXTRA_ENV}")
|
||||
fi
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
@@ -119,12 +156,65 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-kms-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-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 ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
@@ -133,6 +223,92 @@ jobs:
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-kms-report.md
|
||||
SUITE: kms
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'kms'
|
||||
SUITE_LABEL: 'KMS'
|
||||
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: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
@@ -162,6 +338,55 @@ jobs:
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: Tier)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-tier' \
|
||||
-F 'client_payload[from_suite]=kms'; then
|
||||
echo "dispatched next suite Tier (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 Tier after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after kms (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **kms** to **Tier** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-tier'"
|
||||
echo "- 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-tier'"
|
||||
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
|
||||
if: failure()
|
||||
run: |
|
||||
|
||||
@@ -48,10 +48,10 @@ on:
|
||||
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
workflow_run:
|
||||
# Run after the nightly build completes; the nightly deb is what the test installs.
|
||||
workflows: ["Nightly GNU Build"]
|
||||
types: [completed]
|
||||
repository_dispatch:
|
||||
# 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]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -84,19 +84,33 @@ env:
|
||||
jobs:
|
||||
performance-test:
|
||||
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
|
||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||
# Skipped when nightly failed.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: main
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -212,6 +226,65 @@ jobs:
|
||||
echo "created ${REPORT_PATH} in rustfs/dashboard"
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.benchmark.outcome == 'failure' || steps.benchmark.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'performance'
|
||||
SUITE_LABEL: 'Performance'
|
||||
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: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs & results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: RustFS Pool Expansion / Heal Test
|
||||
name: RustFS Pool Expansion Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -25,18 +25,14 @@ on:
|
||||
description: 'warp write duration (e.g. 5m, 10m)'
|
||||
required: false
|
||||
default: '10m'
|
||||
warp_concurrent:
|
||||
description: 'Pool fill: concurrent warp operations'
|
||||
required: false
|
||||
default: '32'
|
||||
run_decommission:
|
||||
description: 'Run the pool decommission step (3-pool topology only)'
|
||||
type: boolean
|
||||
default: true
|
||||
stop_node_gb:
|
||||
description: 'Heal: stop the outage node when surviving nodes reach N GiB'
|
||||
required: false
|
||||
default: '15'
|
||||
warp_stop_gb:
|
||||
description: 'Heal: stop warp when surviving nodes reach N GiB'
|
||||
required: false
|
||||
default: '40'
|
||||
cleanup_before:
|
||||
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||
type: boolean
|
||||
@@ -45,17 +41,16 @@ on:
|
||||
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
workflow_run:
|
||||
# Strict shared-environment order: run after tier test succeeds.
|
||||
workflows: ["RustFS Tier Test"]
|
||||
types: [completed]
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the heal suite finishes.
|
||||
types: [rustfs-chain-pool]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Only one test run at a time: every job mutates the same shared test
|
||||
# Only one test run at a time: the job mutates the same shared test
|
||||
# environment (vm000/vm001/vm002), so concurrent runs must not clobber each
|
||||
# other. Jobs inside a run are chained with needs to serialize them.
|
||||
# other.
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
@@ -70,25 +65,55 @@ env:
|
||||
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
# 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.
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
|
||||
jobs:
|
||||
# Pool expansion: dispatched by the heal suite's chain handoff. Heal
|
||||
# itself lives in rustfs-heal-test.yml and runs exactly once per chain.
|
||||
pool-expansion-test:
|
||||
name: Pool expansion / decommission test
|
||||
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
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
env:
|
||||
RUSTFS_POOL_ADMIN_ENDPOINT: ${{ secrets.RUSTFS_POOL_ADMIN_ENDPOINT || vars.RUSTFS_POOL_ADMIN_ENDPOINT || 'http://rustfs-node1:9000' }}
|
||||
RUSTFS_POOL_PROXY_ENDPOINT: http://127.0.0.1:19000
|
||||
RUSTFS_POOL_WARP_ENDPOINT: http://127.0.0.1:19000
|
||||
RUSTFS_SHARED_PROXY_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||
RUSTFS_POOL_NODE_ENDPOINTS: ${{ secrets.RUSTFS_POOL_NODE_ENDPOINTS || vars.RUSTFS_POOL_NODE_ENDPOINTS || 'http://rustfs-node1:9000 http://rustfs-node2:9000 http://rustfs-node3:9000' }}
|
||||
steps:
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: main
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Initialize pool test artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARTIFACT_DIR="${RUNNER_TEMP}/rustfs-pool-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
echo "POOL_ARTIFACT_DIR=${ARTIFACT_DIR}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -98,15 +123,32 @@ jobs:
|
||||
warp --version || true
|
||||
df -h /data | tail -1
|
||||
|
||||
- name: Reset test environment (before)
|
||||
- name: Cleanup environment (before)
|
||||
if: ${{ inputs.cleanup_before != 'false' }}
|
||||
run: |
|
||||
chmod +x auto-testing/rustfs_pool_expand.sh
|
||||
./auto-testing/rustfs_pool_expand.sh --reset -y
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||
'
|
||||
done
|
||||
|
||||
- name: Install RustFS package & start first pool
|
||||
- name: Install RustFS package & start cluster
|
||||
run: |
|
||||
ARGS=(--steps "1,2,3" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||
ARGS=(--steps "1,2,3" -y \
|
||||
--admin-endpoint "${RUSTFS_POOL_ADMIN_ENDPOINT}" \
|
||||
--warp-endpoint "${RUSTFS_POOL_WARP_ENDPOINT}" \
|
||||
--node-endpoints "${RUSTFS_POOL_NODE_ENDPOINTS}" \
|
||||
--log-file "${POOL_ARTIFACT_DIR}/pool-test.log")
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||
@@ -118,7 +160,11 @@ jobs:
|
||||
|
||||
- name: Preflight checks
|
||||
run: |
|
||||
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||
ARGS=(--preflight \
|
||||
--admin-endpoint "${RUSTFS_POOL_ADMIN_ENDPOINT}" \
|
||||
--warp-endpoint "${RUSTFS_POOL_WARP_ENDPOINT}" \
|
||||
--node-endpoints "${RUSTFS_POOL_NODE_ENDPOINTS}" \
|
||||
--log-file "${POOL_ARTIFACT_DIR}/pool-test.log")
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||
@@ -128,6 +174,60 @@ jobs:
|
||||
fi
|
||||
./auto-testing/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||
|
||||
- name: Reset dedicated pool proxy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RUSTFS_POOL_NGINX_CONFIG_PATH=/etc/nginx/conf.d/rustfs-pool-test.conf \
|
||||
RUSTFS_POOL_NGINX_LISTEN="${RUSTFS_POOL_PROXY_ENDPOINT#http://}" \
|
||||
RUSTFS_POOL_NGINX_ACCESS_LOG=/var/log/nginx/rustfs-pool-test-access.log \
|
||||
RUSTFS_POOL_NGINX_ERROR_LOG=/var/log/nginx/rustfs-pool-test-error.log \
|
||||
./auto-testing/rustfs_pool_nginx_stage.sh cleanup
|
||||
|
||||
- name: Capture pool test baseline
|
||||
run: |
|
||||
set -uo pipefail
|
||||
BASELINE_FILE="${POOL_ARTIFACT_DIR}/pool-baseline.log"
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
read -r -a DIRECT_ENDPOINTS <<< "${RUSTFS_POOL_NODE_ENDPOINTS}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
failed=0
|
||||
: > "${BASELINE_FILE}"
|
||||
|
||||
if [ "${#DIRECT_ENDPOINTS[@]}" -lt "${#NODES[@]}" ]; then
|
||||
echo "not enough direct endpoints for the configured nodes" | tee -a "${BASELINE_FILE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for index in "${!NODES[@]}"; do
|
||||
node="${NODES[$index]}"
|
||||
endpoint="${DIRECT_ENDPOINTS[$index]}"
|
||||
body_file="${POOL_ARTIFACT_DIR}/ready-baseline-$((index + 1)).body"
|
||||
{
|
||||
echo "--- node=${node} endpoint=${endpoint} ---"
|
||||
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||
"${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
echo "--- rustfs version ---"
|
||||
rustfs --version
|
||||
echo "--- systemd state ---"
|
||||
${SUDO} systemctl show rustfs --no-pager \
|
||||
--property=ActiveState,SubState,Result,ExecMainPID,ExecMainStartTimestamp,NRestarts
|
||||
'; then
|
||||
echo "baseline collection failed for ${node}"
|
||||
failed=1
|
||||
fi
|
||||
curl -sS --connect-timeout 5 --max-time 15 -o "${body_file}" \
|
||||
-w "baseline_ready=${endpoint} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
|
||||
"${endpoint%/}/health/ready" || true
|
||||
echo "--- readiness body ---"
|
||||
cat "${body_file}" 2>/dev/null || true
|
||||
echo
|
||||
} >> "${BASELINE_FILE}" 2>&1
|
||||
done
|
||||
|
||||
[ "${failed}" -eq 0 ] || exit 1
|
||||
|
||||
- name: Run pool expansion & decommission test
|
||||
id: pool_test
|
||||
run: |
|
||||
@@ -140,10 +240,16 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
ARGS=(--steps "$STEPS" --with-warp -y \
|
||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||
--admin-endpoint "${RUSTFS_POOL_ADMIN_ENDPOINT}" \
|
||||
--warp-endpoint "${RUSTFS_POOL_WARP_ENDPOINT}" \
|
||||
--node-endpoints "${RUSTFS_POOL_NODE_ENDPOINTS}" \
|
||||
--storage-threshold "${{ inputs.storage_threshold || '50' }}" \
|
||||
--warp-duration "${{ inputs.warp_duration || '10m' }}" \
|
||||
--log-file /tmp/rustfs-pool-test.log)
|
||||
--warp-concurrent "${{ inputs.warp_concurrent || '32' }}" \
|
||||
--log-file "${POOL_ARTIFACT_DIR}/pool-test.log")
|
||||
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
|
||||
ARGS+=(--proxy-endpoint "${RUSTFS_POOL_PROXY_ENDPOINT}")
|
||||
fi
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||
@@ -151,22 +257,391 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||
RUSTFS_WARP_LOG_FILE="${POOL_ARTIFACT_DIR}/warp.log" \
|
||||
RUSTFS_PROXY_STAGE_HOOK=./auto-testing/rustfs_pool_nginx_stage.sh \
|
||||
RUSTFS_POOL_NGINX_CONFIG_PATH=/etc/nginx/conf.d/rustfs-pool-test.conf \
|
||||
RUSTFS_POOL_NGINX_LISTEN="${RUSTFS_POOL_PROXY_ENDPOINT#http://}" \
|
||||
RUSTFS_POOL_NGINX_ACCESS_LOG=/var/log/nginx/rustfs-pool-test-access.log \
|
||||
RUSTFS_POOL_NGINX_ERROR_LOG=/var/log/nginx/rustfs-pool-test-error.log \
|
||||
./auto-testing/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||
|
||||
- name: Collect pool test diagnostics
|
||||
if: always()
|
||||
run: |
|
||||
set -uo pipefail
|
||||
ARTIFACT_DIR="${POOL_ARTIFACT_DIR:-${RUNNER_TEMP}/rustfs-pool-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}}"
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
echo "POOL_ARTIFACT_DIR=${ARTIFACT_DIR}" >> "${GITHUB_ENV}"
|
||||
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)=).*/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(proxy_set_header[[:space:]]+Authorization[[:space:]]+).*/\1[REDACTED];/Ig' \
|
||||
-e 's/^.*(password|secret|token).*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO=()
|
||||
else
|
||||
SUDO=(sudo -n)
|
||||
fi
|
||||
|
||||
{
|
||||
echo "captured_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "run_id=${GITHUB_RUN_ID}"
|
||||
echo "run_attempt=${GITHUB_RUN_ATTEMPT}"
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
"${SUDO[@]}" nginx -T 2>&1 || echo "nginx -T failed"
|
||||
else
|
||||
echo "nginx is not installed on the runner"
|
||||
fi
|
||||
} | redact > "${ARTIFACT_DIR}/nginx-config-redacted.txt"
|
||||
|
||||
for log_path in \
|
||||
/var/log/nginx/access.log \
|
||||
/var/log/nginx/error.log \
|
||||
/var/log/nginx/rustfs-pool-test-access.log \
|
||||
/var/log/nginx/rustfs-pool-test-error.log; do
|
||||
log_name="$(basename "${log_path}")"
|
||||
if "${SUDO[@]}" test -r "${log_path}" 2>/dev/null; then
|
||||
"${SUDO[@]}" cat "${log_path}" 2>&1 | redact \
|
||||
> "${ARTIFACT_DIR}/nginx-${log_name%.log}-redacted.log"
|
||||
else
|
||||
echo "unavailable: ${log_path}" > "${ARTIFACT_DIR}/nginx-${log_name%.log}-redacted.log"
|
||||
fi
|
||||
done
|
||||
"${SUDO[@]}" journalctl -u nginx --no-pager -n 5000 2>&1 | redact \
|
||||
> "${ARTIFACT_DIR}/nginx-journal-redacted.log" || true
|
||||
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
safe_node="${node//[^A-Za-z0-9_.-]/_}"
|
||||
{
|
||||
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||
"${SSH_USER}@${node}" '
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
echo "--- rustfs version ---"
|
||||
rustfs --version 2>&1 || true
|
||||
echo "--- systemd state ---"
|
||||
${SUDO} systemctl show rustfs --no-pager \
|
||||
--property=ActiveState,SubState,Result,ExecMainPID,ExecMainStartTimestamp,NRestarts 2>&1 || true
|
||||
echo "--- rustfs journal ---"
|
||||
${SUDO} journalctl -u rustfs --no-pager -n 10000 2>&1 || true
|
||||
echo "--- rustfs file logs ---"
|
||||
if ${SUDO} test -d /var/log/rustfs; then
|
||||
${SUDO} find /var/log/rustfs -maxdepth 2 -type f -print 2>/dev/null | while IFS= read -r file; do
|
||||
echo "--- ${file} (last 5000 lines) ---"
|
||||
${SUDO} tail -n 5000 "${file}" 2>&1 || true
|
||||
done
|
||||
else
|
||||
echo "/var/log/rustfs is unavailable"
|
||||
fi
|
||||
'; then
|
||||
echo "SSH diagnostics failed for ${node}"
|
||||
fi
|
||||
} 2>&1 | redact > "${ARTIFACT_DIR}/${safe_node}-rustfs-redacted.log"
|
||||
done
|
||||
|
||||
: > "${ARTIFACT_DIR}/endpoint-ready-probes.log"
|
||||
read -r -a DIRECT_ENDPOINTS <<< "${RUSTFS_POOL_NODE_ENDPOINTS}"
|
||||
probe_index=0
|
||||
for endpoint in "${DIRECT_ENDPOINTS[@]}"; do
|
||||
probe_index=$((probe_index + 1))
|
||||
curl -sS --connect-timeout 5 --max-time 15 -o "${ARTIFACT_DIR}/ready-direct-${probe_index}.body" \
|
||||
-w "direct[${probe_index}]=${endpoint} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
|
||||
"${endpoint%/}/health/ready" >> "${ARTIFACT_DIR}/endpoint-ready-probes.log" 2>&1 || true
|
||||
done
|
||||
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
|
||||
curl -sS --connect-timeout 5 --max-time 15 -o "${ARTIFACT_DIR}/ready-proxy.body" \
|
||||
-w "proxy=${RUSTFS_POOL_PROXY_ENDPOINT} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
|
||||
"${RUSTFS_POOL_PROXY_ENDPOINT%/}/health/ready" >> "${ARTIFACT_DIR}/endpoint-ready-probes.log" 2>&1 || true
|
||||
fi
|
||||
if [ -n "${RUSTFS_SHARED_PROXY_ENDPOINT}" ]; then
|
||||
curl -sS --connect-timeout 5 --max-time 15 -o "${ARTIFACT_DIR}/ready-shared-proxy.body" \
|
||||
-w "shared_proxy=${RUSTFS_SHARED_PROXY_ENDPOINT} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
|
||||
"${RUSTFS_SHARED_PROXY_ENDPOINT%/}/health/ready" >> "${ARTIFACT_DIR}/endpoint-ready-probes.log" 2>&1 || true
|
||||
fi
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOG_FILE="${POOL_ARTIFACT_DIR}/pool-test.log"
|
||||
REPORT_FILE="${POOL_ARTIFACT_DIR}/pool-report.md"
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
{
|
||||
echo "# RustFS pool expansion test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Warp concurrent: ${{ inputs.warp_concurrent || '32' }}"
|
||||
echo "- Test Step Outcome: ${{ steps.pool_test.outcome }}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Validate pool diagnostic completeness
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
require_nonempty() {
|
||||
if [ ! -s "$1" ]; then
|
||||
echo "required diagnostic is missing or empty: $1" >&2
|
||||
failed=1
|
||||
fi
|
||||
}
|
||||
require_available() {
|
||||
if [ ! -e "$1" ]; then
|
||||
echo "required diagnostic is missing: $1" >&2
|
||||
failed=1
|
||||
elif grep -Fq 'unavailable:' "$1" 2>/dev/null; then
|
||||
echo "required diagnostic could not be collected: $1" >&2
|
||||
failed=1
|
||||
fi
|
||||
}
|
||||
|
||||
require_nonempty "${POOL_ARTIFACT_DIR}/pool-test.log"
|
||||
require_nonempty "${POOL_ARTIFACT_DIR}/warp.log"
|
||||
require_nonempty "${POOL_ARTIFACT_DIR}/pool-report.md"
|
||||
require_nonempty "${POOL_ARTIFACT_DIR}/pool-baseline.log"
|
||||
require_nonempty "${POOL_ARTIFACT_DIR}/nginx-config-redacted.txt"
|
||||
require_nonempty "${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-access-redacted.log"
|
||||
require_available "${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-access-redacted.log"
|
||||
require_available "${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-error-redacted.log"
|
||||
require_nonempty "${POOL_ARTIFACT_DIR}/endpoint-ready-probes.log"
|
||||
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
if grep -Fq 'baseline collection failed' "${POOL_ARTIFACT_DIR}/pool-baseline.log" 2>/dev/null; then
|
||||
echo "one or more node baselines could not be collected" >&2
|
||||
failed=1
|
||||
fi
|
||||
for node in "${NODES[@]}"; do
|
||||
safe_node="${node//[^A-Za-z0-9_.-]/_}"
|
||||
node_log="${POOL_ARTIFACT_DIR}/${safe_node}-rustfs-redacted.log"
|
||||
require_nonempty "${node_log}"
|
||||
if grep -Fq "SSH diagnostics failed for ${node}" "${node_log}" 2>/dev/null; then
|
||||
echo "node diagnostics failed: ${node_log}" >&2
|
||||
failed=1
|
||||
fi
|
||||
if ! grep -Eq '^rustfs @' "${node_log}" 2>/dev/null \
|
||||
|| ! grep -Eq '^NRestarts=[0-9]+$' "${node_log}" 2>/dev/null; then
|
||||
echo "node version or restart evidence is incomplete: ${node_log}" >&2
|
||||
failed=1
|
||||
elif grep -Eq '^NRestarts=[1-9][0-9]*$' "${node_log}"; then
|
||||
echo "RustFS restarted unexpectedly during the run: ${node_log}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! grep -Fq "upstream_status=\"\$upstream_status\"" \
|
||||
"${POOL_ARTIFACT_DIR}/nginx-config-redacted.txt"; then
|
||||
echo "Nginx config does not expose upstream status fields" >&2
|
||||
failed=1
|
||||
fi
|
||||
if ! grep -Eq '^proxy=.* http=200([[:space:]]|$)' "${POOL_ARTIFACT_DIR}/endpoint-ready-probes.log"; then
|
||||
echo "dedicated proxy readiness probe did not return HTTP 200" >&2
|
||||
failed=1
|
||||
fi
|
||||
if grep -Eq 'status=50(2|4)|upstream_status="[^"]*50(2|4)' \
|
||||
"${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-access-redacted.log"; then
|
||||
echo "dedicated proxy access log contains a 502/504 response" >&2
|
||||
failed=1
|
||||
fi
|
||||
if grep -Eiq 'upstream prematurely closed connection|upstream timed out|(connect\(\)|recv\(\)|send\(\)) failed.*upstream|connection reset by peer.*upstream' \
|
||||
"${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-error-redacted.log"; then
|
||||
echo "dedicated proxy error log contains an upstream timeout or connection failure" >&2
|
||||
failed=1
|
||||
fi
|
||||
[ "${failed}" -eq 0 ] || exit 1
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: pool
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REPORT_FILE="${POOL_ARTIFACT_DIR}/pool-report.md"
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'pool'
|
||||
SUITE_LABEL: 'Pool expansion'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '${{ env.POOL_ARTIFACT_DIR }}/pool-report.md'
|
||||
LOG_FILE: '${{ env.POOL_ARTIFACT_DIR }}/pool-test.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-pool-test-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-pool-test*.log
|
||||
/tmp/rustfs-warp.*.log
|
||||
name: rustfs-pool-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/rustfs-pool-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Reset test environment (after)
|
||||
- name: Restore dedicated pool proxy
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RUSTFS_POOL_NGINX_CONFIG_PATH=/etc/nginx/conf.d/rustfs-pool-test.conf \
|
||||
RUSTFS_POOL_NGINX_LISTEN="${RUSTFS_POOL_PROXY_ENDPOINT#http://}" \
|
||||
RUSTFS_POOL_NGINX_ACCESS_LOG=/var/log/nginx/rustfs-pool-test-access.log \
|
||||
RUSTFS_POOL_NGINX_ERROR_LOG=/var/log/nginx/rustfs-pool-test-error.log \
|
||||
./auto-testing/rustfs_pool_nginx_stage.sh cleanup
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
run: |
|
||||
./auto-testing/rustfs_pool_expand.sh --reset -y
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: Security)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-security' \
|
||||
-F 'client_payload[from_suite]=pool'; then
|
||||
echo "dispatched next suite Security (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 Security after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after pool (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **pool** to **Security** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-security'"
|
||||
echo "- 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-security'"
|
||||
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
|
||||
if: failure()
|
||||
@@ -174,82 +649,3 @@ jobs:
|
||||
echo "RustFS pool expansion test failed"
|
||||
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded log artifact for details."
|
||||
|
||||
# Heal regression runs after the pool test regardless of its outcome: a pool
|
||||
# failure must be reported (it makes the run red) but must not block heal.
|
||||
heal-test:
|
||||
name: Heal test (after pool test)
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 480
|
||||
needs: pool-expansion-test
|
||||
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
|
||||
steps:
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: main
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
- name: Reset test environment (before)
|
||||
if: ${{ inputs.cleanup_before != 'false' }}
|
||||
run: |
|
||||
chmod +x auto-testing/rustfs_heal_test.sh
|
||||
./auto-testing/rustfs_heal_test.sh --reset -y
|
||||
|
||||
- name: Install RustFS package & start cluster
|
||||
run: |
|
||||
ARGS=(--steps "1,2" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Preflight checks
|
||||
run: |
|
||||
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Run heal test (write -> outage -> heal -> verify)
|
||||
run: |
|
||||
ARGS=(--steps "3,4,5,6,7" -y \
|
||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
||||
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
||||
--log-file /tmp/rustfs-heal-test.log)
|
||||
if [ -n "${{ inputs.package_url }}" ]; then
|
||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-heal-test-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-heal-test.log
|
||||
/tmp/rustfs-warp.*.log
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Reset test environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
run: |
|
||||
./auto-testing/rustfs_heal_test.sh --reset -y
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS heal test failed"
|
||||
echo "See the uploaded log artifact for details."
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: RustFS Replication Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||
required: false
|
||||
default: '1.0.0-rc.4-preview.1'
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
suite:
|
||||
description: 'Suite to run (all = bucket REP-* then site SITE-*)'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- bucket
|
||||
- site
|
||||
default: all
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the security suite finishes. This is the
|
||||
# last link of the functional chain.
|
||||
types: [rustfs-chain-replication]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# The replication suite uses the same shared VMs as the other functional
|
||||
# tests, so it must serialize with them instead of running in parallel.
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
jobs:
|
||||
replication-test:
|
||||
runs-on: smoke-testing
|
||||
# A failed replication run must not break the chain or the workflow: the
|
||||
# failure is reported to rustfs/backlog instead (see the issue step).
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
jq --version
|
||||
openssl version
|
||||
aws --version
|
||||
df -h /data | tail -1 || true
|
||||
|
||||
- name: Cleanup environment (before)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2 /var/lib/rustfs/kms
|
||||
'
|
||||
done
|
||||
|
||||
- name: Run replication suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-replication.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-replication-test.sh
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
SUITE='${{ inputs.suite }}'
|
||||
ARGS=(-y --log-file "${LOG_FILE}")
|
||||
if [ "${SUITE}" = "all" ] || [ -z "${SUITE}" ] || [ "${SUITE}" = "null" ]; then
|
||||
ARGS+=(--suite all)
|
||||
else
|
||||
ARGS+=(--suite "${SUITE}")
|
||||
fi
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
|
||||
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||
else
|
||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-replication.log
|
||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
RUSTFS_VERSION_INFO="N/A"
|
||||
if [ "${#NODES[@]}" -gt 0 ]; then
|
||||
DETECTED_VERSION="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||
"${SSH_USER}@${NODES[0]}" 'rustfs --version' 2>/dev/null | tr -d '\r' | head -n 1 || true)"
|
||||
if [ -n "${DETECTED_VERSION}" ]; then
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-replication-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
{
|
||||
echo "# RustFS replication test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-replication-report.md
|
||||
SUITE: replication
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'replication'
|
||||
SUITE_LABEL: 'Replication (bucket + site)'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-replication-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-replication.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-replication-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-replication.log
|
||||
/tmp/rustfs-replication-report.md
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2
|
||||
'
|
||||
done
|
||||
|
||||
- name: Chain complete
|
||||
# Replication is the last link of the functional chain: nothing to
|
||||
# dispatch after it. This step just records that the chain finished.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
run: |
|
||||
echo "Functional chain complete: replication (final suite) finished."
|
||||
echo "from_suite=security trigger=${{ github.event_name }} outcome=${{ steps.test.outcome }}"
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS replication suite failed"
|
||||
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded report and log artifacts for details."
|
||||
@@ -11,10 +11,9 @@ on:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
workflow_run:
|
||||
# Run after the nightly build completes; the nightly deb is what the test installs.
|
||||
workflows: ["Nightly GNU Build"]
|
||||
types: [completed]
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the upgrade suite finishes.
|
||||
types: [rustfs-chain-s3]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -33,21 +32,34 @@ env:
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
jobs:
|
||||
s3-compat-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: main
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -76,6 +88,7 @@ jobs:
|
||||
|
||||
- name: Run S3 compatibility suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-s3-compat.log
|
||||
run: |
|
||||
@@ -109,12 +122,79 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
RUSTFS_VERSION_INFO="N/A"
|
||||
if [ "${#NODES[@]}" -gt 0 ]; then
|
||||
DETECTED_VERSION="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||
"${SSH_USER}@${NODES[0]}" 'rustfs --version' 2>/dev/null | tr -d '\r' | head -n 1 || true)"
|
||||
if [ -n "${DETECTED_VERSION}" ]; then
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-s3-compat-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
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 ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
@@ -123,6 +203,92 @@ jobs:
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
|
||||
SUITE: s3
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 's3'
|
||||
SUITE_LABEL: 'S3 compatibility'
|
||||
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: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
@@ -152,6 +318,55 @@ jobs:
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: KMS)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-kms' \
|
||||
-F 'client_payload[from_suite]=s3'; then
|
||||
echo "dispatched next suite KMS (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 KMS after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after s3 (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **s3** to **KMS** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-kms'"
|
||||
echo "- 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-kms'"
|
||||
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
|
||||
if: failure()
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: RustFS Security Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||
required: false
|
||||
default: '1.0.0-rc.4-preview.1'
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
topology:
|
||||
description: 'Topology to run (all = SNSD, SNMD, MNMD)'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- single-single
|
||||
- single-multi
|
||||
- multi-multi
|
||||
default: all
|
||||
oidc_live:
|
||||
description: 'Run the live Keycloak OIDC/SSO gate as part of the suite'
|
||||
type: boolean
|
||||
default: true
|
||||
cleanup_before:
|
||||
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
cleanup_after:
|
||||
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the pool expansion suite finishes.
|
||||
types: [rustfs-chain-security]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# The security suite uses the same shared VMs as the other functional tests,
|
||||
# so it must serialize with them instead of running in parallel.
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
jobs:
|
||||
security-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Checkout repository (for the OIDC live gate script)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
jq --version
|
||||
openssl version
|
||||
aws --version || true
|
||||
docker --version || true
|
||||
df -h /data | tail -1
|
||||
|
||||
- name: Cleanup environment (before)
|
||||
if: ${{ inputs.cleanup_before != 'false' || github.event_name != 'workflow_dispatch' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms
|
||||
'
|
||||
done
|
||||
|
||||
- name: Run security suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
REPORT_FILE: /tmp/rustfs-security-report.md
|
||||
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/scripts/test/oidc_keycloak_live.sh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-security-test.sh
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
TOPOLOGY='${{ inputs.topology }}'
|
||||
ARGS=(-y)
|
||||
if [ "${TOPOLOGY}" = "all" ] || [ -z "${TOPOLOGY}" ] || [ "${TOPOLOGY}" = "null" ]; then
|
||||
ARGS+=(--all-topologies)
|
||||
else
|
||||
ARGS+=(--topology "${TOPOLOGY}")
|
||||
fi
|
||||
if [ "${{ inputs.oidc_live }}" = "true" ] || [ "${{ github.event_name }}" != "workflow_dispatch" ]; then
|
||||
ARGS+=(--oidc-live)
|
||||
fi
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
|
||||
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||
else
|
||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
./auto-testing/rustfs-security-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -f /tmp/rustfs-security-report.md ]; then
|
||||
{
|
||||
echo "# RustFS security test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Test Step Outcome: failure (suite did not produce a report)"
|
||||
} > /tmp/rustfs-security-report.md
|
||||
fi
|
||||
cat /tmp/rustfs-security-report.md >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-security-report.md
|
||||
SUITE: security
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'security'
|
||||
SUITE_LABEL: 'Security'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-security-report.md'
|
||||
LOG_FILE: ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-security-test-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-security-report.md
|
||||
/tmp/rustfs-security.*/*
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: ${{ always() && (inputs.cleanup_after != 'false' || github.event_name != 'workflow_dispatch') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: Replication)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: Replication"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-replication' \
|
||||
-F 'client_payload[from_suite]=security'
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS security test failed"
|
||||
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded report and logs for details."
|
||||
@@ -0,0 +1,389 @@
|
||||
name: RustFS Storage Engine Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||
required: false
|
||||
default: '1.0.0-rc.4-preview.1'
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
topology:
|
||||
description: 'Topology to run (all = SNSD, SNMD, MNMD)'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- single-single
|
||||
- single-multi
|
||||
- multi-multi
|
||||
default: all
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the tier suite finishes.
|
||||
types: [rustfs-chain-storage]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
jobs:
|
||||
storage-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
jq --version
|
||||
openssl version
|
||||
df -h /data | tail -1
|
||||
|
||||
- name: Cleanup environment (before)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||
'
|
||||
done
|
||||
|
||||
- name: Run storage engine suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-storage.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-storage-test.sh
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
TOPOLOGY='${{ inputs.topology }}'
|
||||
ARGS=(-y --log-file "${LOG_FILE}")
|
||||
if [ "${TOPOLOGY}" = "all" ] || [ -z "${TOPOLOGY}" ] || [ "${TOPOLOGY}" = "null" ]; then
|
||||
ARGS+=(--all-topologies)
|
||||
else
|
||||
ARGS+=(--topology "${TOPOLOGY}")
|
||||
fi
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||
else
|
||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-storage.log
|
||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
RUSTFS_VERSION_INFO="N/A"
|
||||
if [ "${#NODES[@]}" -gt 0 ]; then
|
||||
DETECTED_VERSION="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||
"${SSH_USER}@${NODES[0]}" 'rustfs --version' 2>/dev/null | tr -d '\r' | head -n 1 || true)"
|
||||
if [ -n "${DETECTED_VERSION}" ]; then
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-storage-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
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 ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
||||
SUITE: storage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'storage'
|
||||
SUITE_LABEL: 'Storage engine'
|
||||
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: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-storage-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-storage.log
|
||||
/tmp/rustfs-storage-report.md
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: Heal)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-heal' \
|
||||
-F 'client_payload[from_suite]=storage'; then
|
||||
echo "dispatched next suite Heal (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 Heal after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after storage (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **storage** to **Heal** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-heal'"
|
||||
echo "- 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-heal'"
|
||||
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
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS storage engine suite failed"
|
||||
echo "See the uploaded report and log artifacts for details."
|
||||
@@ -11,10 +11,29 @@ on:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
workflow_run:
|
||||
# Strict shared-environment order: run after KMS test succeeds.
|
||||
workflows: ["RustFS KMS Test"]
|
||||
types: [completed]
|
||||
rc_archive_url:
|
||||
description: 'Exact RustFS CLI Linux archive URL.'
|
||||
required: false
|
||||
default: 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz'
|
||||
type: string
|
||||
rc_archive_sha256:
|
||||
description: 'Expected SHA-256 of the RustFS CLI archive.'
|
||||
required: false
|
||||
default: 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df'
|
||||
type: string
|
||||
rc_sha256:
|
||||
description: 'Expected SHA-256 of the extracted RustFS CLI binary.'
|
||||
required: false
|
||||
default: '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df'
|
||||
type: string
|
||||
force_case_failure:
|
||||
description: 'Diagnostic only: rewrite single-single/TIER-101 to FAIL after execution to verify artifact and final-gate behavior.'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the KMS suite finishes.
|
||||
types: [rustfs-chain-tier]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -33,22 +52,157 @@ env:
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
RUSTFS_RC_ARCHIVE_URL: ${{ inputs.rc_archive_url || 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz' }}
|
||||
RUSTFS_RC_ARCHIVE_SHA256: ${{ inputs.rc_archive_sha256 || 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df' }}
|
||||
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
TIER_ARTIFACTS_DIR: /tmp/rustfs-tier-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
jobs:
|
||||
tier-test:
|
||||
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
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout auto-testing scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
repository: rustfs/auto-testing
|
||||
ref: main
|
||||
path: auto-testing
|
||||
persist-credentials: false
|
||||
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
- name: Initialize run evidence directory
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
if ! mkdir -- "${TIER_ARTIFACTS_DIR}"; then
|
||||
echo "refusing to reuse tier evidence path: ${TIER_ARTIFACTS_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
test -d "${TIER_ARTIFACTS_DIR}"
|
||||
test ! -L "${TIER_ARTIFACTS_DIR}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Prepare pinned RustFS CLI
|
||||
id: rc
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
case "${RUSTFS_RC_ARCHIVE_URL}" in
|
||||
https://*) ;;
|
||||
*)
|
||||
echo "RustFS CLI archive URL must use HTTPS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
EXPECTED_ARCHIVE_SHA256="$(printf '%s' "${RUSTFS_RC_ARCHIVE_SHA256}" | tr '[:upper:]' '[:lower:]')"
|
||||
EXPECTED_RC_SHA256="$(printf '%s' "${RUSTFS_EXPECTED_RC_SHA256}" | tr '[:upper:]' '[:lower:]')"
|
||||
if ! [[ "${EXPECTED_ARCHIVE_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "invalid RustFS CLI archive SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "${EXPECTED_RC_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "invalid RustFS CLI binary SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
RC_ARCHIVE="${RC_ROOT}/rustfs-cli.tar.gz"
|
||||
RC_BIN="${RC_ROOT}/rc"
|
||||
if ! mkdir -- "${RC_ROOT}"; then
|
||||
echo "refusing to reuse RustFS CLI directory: ${RC_ROOT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --fail --location --retry 3 --retry-all-errors \
|
||||
--connect-timeout 15 --max-time 180 \
|
||||
--proto '=https' --proto-redir '=https' \
|
||||
--output "${RC_ARCHIVE}" "${RUSTFS_RC_ARCHIVE_URL}"
|
||||
RC_ARCHIVE_SIZE="$(wc -c < "${RC_ARCHIVE}" | tr -d '[:space:]')"
|
||||
if [ "${RC_ARCHIVE_SIZE}" -eq 0 ] || [ "${RC_ARCHIVE_SIZE}" -gt 33554432 ]; then
|
||||
echo "RustFS CLI archive size is outside the accepted range: ${RC_ARCHIVE_SIZE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! ACTUAL_ARCHIVE_SHA256="$(openssl dgst -sha256 -r "${RC_ARCHIVE}" | awk '{print $1}')"; then
|
||||
echo "failed to calculate RustFS CLI archive SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${ACTUAL_ARCHIVE_SHA256}" != "${EXPECTED_ARCHIVE_SHA256}" ]; then
|
||||
echo "RustFS CLI archive SHA-256 mismatch: expected ${EXPECTED_ARCHIVE_SHA256}, got ${ACTUAL_ARCHIVE_SHA256}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE_MEMBERS="$(tar -tzf "${RC_ARCHIVE}")"
|
||||
if ! grep -Fxq 'rc' <<< "${ARCHIVE_MEMBERS}"; then
|
||||
echo "RustFS CLI archive does not contain the rc entry" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! tar -xOzf "${RC_ARCHIVE}" rc > "${RC_BIN}"; then
|
||||
echo "failed to extract the RustFS CLI binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0700 "${RC_BIN}"
|
||||
if ! ACTUAL_RC_SHA256="$(openssl dgst -sha256 -r "${RC_BIN}" | awk '{print $1}')"; then
|
||||
echo "failed to calculate RustFS CLI binary SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${ACTUAL_RC_SHA256}" != "${EXPECTED_RC_SHA256}" ]; then
|
||||
echo "RustFS CLI binary SHA-256 mismatch: expected ${EXPECTED_RC_SHA256}, got ${ACTUAL_RC_SHA256}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! RC_VERSION_OUTPUT="$(timeout 30 "${RC_BIN}" --version 2>&1)"; then
|
||||
echo "failed to execute the pinned RustFS CLI" >&2
|
||||
exit 1
|
||||
fi
|
||||
RC_VERSION="${RC_VERSION_OUTPUT%%$'\n'*}"
|
||||
if [ -z "${RC_VERSION}" ]; then
|
||||
echo "pinned RustFS CLI returned an empty version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg schema_version '1' \
|
||||
--arg generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg archive_url "${RUSTFS_RC_ARCHIVE_URL}" \
|
||||
--arg archive_sha256 "${ACTUAL_ARCHIVE_SHA256}" \
|
||||
--arg archive_size "${RC_ARCHIVE_SIZE}" \
|
||||
--arg path "${RC_BIN}" \
|
||||
--arg version "${RC_VERSION}" \
|
||||
--arg sha256 "${ACTUAL_RC_SHA256}" \
|
||||
'{
|
||||
schema_version: ($schema_version | tonumber),
|
||||
generated_at: $generated_at,
|
||||
archive: {
|
||||
url: $archive_url,
|
||||
sha256: $archive_sha256,
|
||||
size: ($archive_size | tonumber)
|
||||
},
|
||||
binary: {
|
||||
path: $path,
|
||||
version: $version,
|
||||
sha256: $sha256
|
||||
}
|
||||
}' > "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json"
|
||||
printf 'path=%s\n' "${RC_BIN}" >> "${GITHUB_OUTPUT}"
|
||||
echo "RustFS CLI ready: ${RC_VERSION} (${ACTUAL_RC_SHA256})"
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
@@ -60,6 +214,8 @@ jobs:
|
||||
- name: Cleanup environment (before)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
|
||||
sudo rm -f /tmp/rustfs-mosquitto.conf
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
@@ -77,26 +233,55 @@ jobs:
|
||||
|
||||
- name: Ensure MQTT broker + clients
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v mosquitto_sub >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mosquitto mosquitto-clients
|
||||
sudo apt-get install -y mosquitto-clients
|
||||
fi
|
||||
sudo mkdir -p /etc/mosquitto/conf.d
|
||||
printf 'listener 1883 0.0.0.0\nallow_anonymous true\n' | sudo tee /etc/mosquitto/conf.d/rustfs-test.conf >/dev/null
|
||||
sudo systemctl restart mosquitto
|
||||
sleep 2
|
||||
ss -tln 2>/dev/null | grep -q ':1883' || { echo 'mosquitto not listening on 1883'; exit 1; }
|
||||
command -v docker >/dev/null 2>&1 || { echo 'docker not found on runner'; exit 1; }
|
||||
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
|
||||
cat <<'EOF' | sudo tee /tmp/rustfs-mosquitto.conf >/dev/null
|
||||
listener 1883 0.0.0.0
|
||||
allow_anonymous true
|
||||
EOF
|
||||
sudo docker run -d --name rustfs-test-mqtt -p 1883:1883 \
|
||||
-v /tmp/rustfs-mosquitto.conf:/mosquitto/config/mosquitto.conf:ro \
|
||||
eclipse-mosquitto:2 >/dev/null
|
||||
for _ in {1..10}; do
|
||||
if ss -tln 2>/dev/null | grep -q ':1883'; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
ss -tln 2>/dev/null | grep -q ':1883' || {
|
||||
echo 'mosquitto container is not listening on 1883'
|
||||
sudo docker logs rustfs-test-mqtt || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Run tier suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-tier.log
|
||||
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
|
||||
RC_BIN: ${{ steps.rc.outputs.path }}
|
||||
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
|
||||
chmod +x auto-testing/rustfs-tier-test.sh
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
ARGS=(--all-topologies -y --log-file "${LOG_FILE}")
|
||||
PACKAGE_URL="${PACKAGE_URL_INPUT}"
|
||||
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
|
||||
ARGS=(
|
||||
--all-topologies
|
||||
-y
|
||||
--log-file "${LOG_FILE}"
|
||||
--rc-bin "${RC_BIN}"
|
||||
--artifacts-dir "${TIER_ARTIFACTS_DIR}"
|
||||
)
|
||||
if [ -n "${RUSTFS_EXPECTED_RC_SHA256}" ]; then
|
||||
ARGS+=(--expected-rc-sha256 "${RUSTFS_EXPECTED_RC_SHA256}")
|
||||
fi
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
@@ -106,15 +291,34 @@ jobs:
|
||||
fi
|
||||
./auto-testing/rustfs-tier-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-tier.log
|
||||
REPORT_FILE: /tmp/rustfs-tier-report.md
|
||||
- name: Inject diagnostic case failure
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' && inputs.force_case_failure }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
RESULT_FILE="${TIER_ARTIFACTS_DIR}/cases/single-single--TIER-101.json"
|
||||
test -s "${RESULT_FILE}"
|
||||
TMP_FILE="$(mktemp "${TIER_ARTIFACTS_DIR}/cases/.forced.XXXXXX")"
|
||||
jq '.status = "FAIL" | .case_rc = 97' "${RESULT_FILE}" > "${TMP_FILE}"
|
||||
mv "${TMP_FILE}" "${RESULT_FILE}"
|
||||
|
||||
- name: Generate report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
|
||||
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
|
||||
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
TRIGGER_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -d "${TIER_ARTIFACTS_DIR}"
|
||||
test ! -L "${TIER_ARTIFACTS_DIR}"
|
||||
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
|
||||
REPORT_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier-report.md"
|
||||
CASE_TABLE="${TIER_ARTIFACTS_DIR}/rustfs-tier-cases.md"
|
||||
GATE_RC_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier-gate.rc"
|
||||
PACKAGE_URL="${PACKAGE_URL_INPUT}"
|
||||
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||
@@ -122,12 +326,37 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
if RC_BOOTSTRAP_SUMMARY="$(jq -r '.binary | "\(.version) / \(.sha256)"' "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json" 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
RC_BOOTSTRAP_SUMMARY="missing or invalid"
|
||||
fi
|
||||
set +e
|
||||
python3 auto-testing/rustfs_tier_report.py \
|
||||
--results-dir "${TIER_ARTIFACTS_DIR}/cases" \
|
||||
--provenance "${TIER_ARTIFACTS_DIR}/provenance.json" \
|
||||
--output "${CASE_TABLE}"
|
||||
CASE_GATE_RC=$?
|
||||
set -e
|
||||
printf '%s\n' "${CASE_GATE_RC}" > "${GATE_RC_FILE}"
|
||||
if [ ! -s "${CASE_TABLE}" ]; then
|
||||
{
|
||||
echo "## Case Summary"
|
||||
echo ""
|
||||
echo "Structured report generation failed before producing output (exit ${CASE_GATE_RC})."
|
||||
} > "${CASE_TABLE}"
|
||||
fi
|
||||
{
|
||||
echo "# RustFS tier test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${TRIGGER_NAME}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Client bootstrap: ${RC_BOOTSTRAP_SUMMARY}"
|
||||
echo "- Test Step Outcome: ${TEST_OUTCOME}"
|
||||
echo "- Structured Gate Exit: ${CASE_GATE_RC}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
@@ -136,20 +365,77 @@ jobs:
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-report.md
|
||||
SUITE: tier
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: Verify required tier evidence
|
||||
id: evidence_verify
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for name in \
|
||||
rustfs-tier.log \
|
||||
rustfs-tier-report.md \
|
||||
rustfs-tier-cases.md \
|
||||
rustfs-tier-gate.rc \
|
||||
rc-bootstrap.json \
|
||||
provenance.json; do
|
||||
if [ ! -s "${TIER_ARTIFACTS_DIR}/${name}" ]; then
|
||||
echo "required tier evidence is missing or empty: ${name}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
for name in cases logs; do
|
||||
if [ ! -d "${TIER_ARTIFACTS_DIR}/${name}" ]; then
|
||||
echo "required tier evidence directory is missing: ${name}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
if ! find "${TIER_ARTIFACTS_DIR}/cases" -maxdepth 1 -type f -name '*.json' -print -quit 2>/dev/null | grep -q .; then
|
||||
echo "no atomic tier case result was produced" >&2
|
||||
failed=1
|
||||
fi
|
||||
[ "${failed}" -eq 0 ]
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-tier-test-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-tier.log
|
||||
/tmp/rustfs-tier-report.md
|
||||
if-no-files-found: warn
|
||||
name: rustfs-tier-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.TIER_ARTIFACTS_DIR }}/
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
|
||||
sudo rm -f /tmp/rustfs-mosquitto.conf
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
@@ -165,6 +451,167 @@ jobs:
|
||||
'
|
||||
done
|
||||
|
||||
- name: Cleanup pinned RustFS CLI
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
rm -f -- "${RC_ROOT}/rustfs-cli.tar.gz" "${RC_ROOT}/rc"
|
||||
if [ -d "${RC_ROOT}" ]; then
|
||||
rmdir -- "${RC_ROOT}"
|
||||
fi
|
||||
|
||||
- name: Enforce tier suite result
|
||||
id: gate
|
||||
if: always()
|
||||
env:
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
GATE_RC_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-gate.rc
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
|
||||
echo "tier evidence directory initialization is ${EVIDENCE_OUTCOME}, expected success" >&2
|
||||
failed=1
|
||||
fi
|
||||
if [ "${TEST_OUTCOME}" != "success" ]; then
|
||||
echo "tier suite step outcome is ${TEST_OUTCOME}, expected success" >&2
|
||||
failed=1
|
||||
fi
|
||||
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
|
||||
echo "structured gate result is unavailable because evidence initialization failed" >&2
|
||||
elif [ ! -s "${GATE_RC_FILE}" ]; then
|
||||
echo "structured gate result is missing" >&2
|
||||
failed=1
|
||||
else
|
||||
GATE_RC="$(tr -d '[:space:]' < "${GATE_RC_FILE}")"
|
||||
if ! [[ "${GATE_RC}" =~ ^[0-9]+$ ]] || [ "${GATE_RC}" -ne 0 ]; then
|
||||
echo "structured 56-case gate failed with exit ${GATE_RC:-invalid}" >&2
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
[ "${failed}" -eq 0 ]
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled' || steps.evidence_verify.outcome == 'failure' || steps.evidence_verify.outcome == 'cancelled' || steps.gate.outcome == 'failure' || steps.gate.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'tier'
|
||||
SUITE_LABEL: 'Tier'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EVIDENCE_DIR: ${{ env.TIER_ARTIFACTS_DIR }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
VERIFY_OUTCOME: ${{ steps.evidence_verify.outcome }}
|
||||
GATE_OUTCOME: ${{ steps.gate.outcome }}
|
||||
REPORT_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-report.md
|
||||
LOG_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo "- Evidence initialization: ${EVIDENCE_OUTCOME}"
|
||||
echo "- Evidence verification: ${VERIFY_OUTCOME}"
|
||||
echo "- Final gate: ${GATE_OUTCOME}"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
|
||||
echo "(the run evidence directory was rejected; its contents were not read)"
|
||||
elif [ ! -d "${EVIDENCE_DIR}" ] || [ -L "${EVIDENCE_DIR}" ]; then
|
||||
echo "(the run evidence directory is missing or unsafe; its contents were not read)"
|
||||
elif [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: "Continue functional chain (next: Storage engine)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-storage' \
|
||||
-F 'client_payload[from_suite]=tier'; then
|
||||
echo "dispatched next suite Storage engine (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Storage engine after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after tier (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **tier** to **Storage engine** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-storage'"
|
||||
echo "- 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-storage'"
|
||||
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
|
||||
if: failure()
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: RustFS Upgrade Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
from_version:
|
||||
description: 'OLD RustFS release tag, e.g. 1.0.0-rc.3 (its release must ship a .deb asset). Leave empty for the default.'
|
||||
required: false
|
||||
default: '1.0.0-rc.3'
|
||||
from_url:
|
||||
description: 'OLD .deb URL. Overrides from_version.'
|
||||
required: false
|
||||
type: string
|
||||
to_version:
|
||||
description: 'NEW RustFS release tag, e.g. 1.0.0-rc.5 (any version with a .deb asset). Leave empty for latest nightly.'
|
||||
required: false
|
||||
to_url:
|
||||
description: 'NEW .deb URL. Overrides to_version / nightly default.'
|
||||
required: false
|
||||
type: string
|
||||
topology:
|
||||
description: 'Topology to run (all = SNSD, SNMD, MNMD)'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- single-single
|
||||
- single-multi
|
||||
- multi-multi
|
||||
default: all
|
||||
backends:
|
||||
description: 'KMS backends to run (local,vault-kv2)'
|
||||
required: false
|
||||
default: 'local,vault-kv2'
|
||||
cleanup_before:
|
||||
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
cleanup_after:
|
||||
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||
type: boolean
|
||||
default: true
|
||||
repository_dispatch:
|
||||
# Functional-chain entry: dispatched by rustfs-functional-chain.yml.
|
||||
types: [rustfs-chain-upgrade]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
jobs:
|
||||
upgrade-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
jq --version
|
||||
openssl version
|
||||
aws --version || true
|
||||
docker --version || true
|
||||
df -h /data | tail -1
|
||||
|
||||
- name: Cleanup environment (before)
|
||||
if: ${{ inputs.cleanup_before != 'false' || github.event_name != 'workflow_dispatch' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||
'
|
||||
done
|
||||
|
||||
- name: Ensure docker (Vault container)
|
||||
run: |
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker.io
|
||||
fi
|
||||
sudo systemctl enable --now docker
|
||||
docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1
|
||||
|
||||
- name: Run upgrade compatibility suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-upgrade-test.sh
|
||||
FROM_URL='${{ inputs.from_url }}'
|
||||
FROM_VERSION='${{ inputs.from_version }}'
|
||||
TO_URL='${{ inputs.to_url }}'
|
||||
TO_VERSION='${{ inputs.to_version }}'
|
||||
TOPOLOGY='${{ inputs.topology }}'
|
||||
BACKENDS='${{ inputs.backends }}'
|
||||
ARGS=(-y --log-file "${LOG_FILE}")
|
||||
if [ "${TOPOLOGY}" = "all" ] || [ -z "${TOPOLOGY}" ] || [ "${TOPOLOGY}" = "null" ]; then
|
||||
ARGS+=(--all-topologies)
|
||||
else
|
||||
ARGS+=(--topology "${TOPOLOGY}")
|
||||
fi
|
||||
if [ -n "${BACKENDS}" ] && [ "${BACKENDS}" != "null" ]; then
|
||||
ARGS+=(--backends "${BACKENDS}")
|
||||
fi
|
||||
if [ -n "${FROM_URL}" ]; then
|
||||
ARGS+=(--from-url "${FROM_URL}")
|
||||
elif [ -n "${FROM_VERSION}" ] && [ "${FROM_VERSION}" != "null" ]; then
|
||||
ARGS+=(--from-version "${FROM_VERSION}")
|
||||
fi
|
||||
if [ -n "${TO_URL}" ]; then
|
||||
ARGS+=(--to-url "${TO_URL}")
|
||||
elif [ -n "${TO_VERSION}" ] && [ "${TO_VERSION}" != "null" ]; then
|
||||
ARGS+=(--to-version "${TO_VERSION}")
|
||||
else
|
||||
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
# Fail fast with a clear message when a requested release tag has
|
||||
# no .deb asset (e.g. 1.0.0-rc.4 ships only zips), instead of
|
||||
# letting the suite die mid-run on a 404.
|
||||
check_release_asset() {
|
||||
local version="$1" tag asset url
|
||||
[ -n "${version}" ] && [ "${version}" != "null" ] || return 0
|
||||
tag="${version#v}"
|
||||
asset="rustfs_${tag//-/.}_amd64.deb"
|
||||
url="https://github.com/rustfs/rustfs/releases/download/${tag}/${asset}"
|
||||
if ! gh api "repos/rustfs/rustfs/releases/tags/${tag}" --jq '.assets[].name' 2>/dev/null | grep -qxF "${asset}"; then
|
||||
echo "ERROR: release ${tag} has no downloadable asset ${asset}:" >&2
|
||||
echo " ${url}" >&2
|
||||
echo "Pick a tag whose release ships a .deb (check its release assets)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "resolved ${tag} -> ${url}"
|
||||
}
|
||||
if [ -z "${FROM_URL}" ]; then
|
||||
check_release_asset "${FROM_VERSION}"
|
||||
fi
|
||||
if [ -z "${TO_URL}" ]; then
|
||||
check_release_asset "${TO_VERSION}"
|
||||
fi
|
||||
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FROM_URL='${{ inputs.from_url }}'
|
||||
FROM_VERSION='${{ inputs.from_version }}'
|
||||
TO_URL='${{ inputs.to_url }}'
|
||||
TO_VERSION='${{ inputs.to_version }}'
|
||||
if [ -n "${FROM_URL}" ]; then
|
||||
FROM_SOURCE="${FROM_URL}"
|
||||
elif [ -n "${FROM_VERSION}" ]; then
|
||||
FROM_SOURCE="version ${FROM_VERSION}"
|
||||
else
|
||||
FROM_SOURCE="release (default)"
|
||||
fi
|
||||
if [ -n "${TO_URL}" ]; then
|
||||
TO_SOURCE="${TO_URL}"
|
||||
elif [ -n "${TO_VERSION}" ]; then
|
||||
TO_SOURCE="version ${TO_VERSION}"
|
||||
else
|
||||
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-upgrade-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-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 upgrade compatibility report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- From: ${FROM_SOURCE}"
|
||||
echo "- To: ${TO_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
||||
SUITE: upgrade
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'upgrade'
|
||||
SUITE_LABEL: 'Upgrade compatibility'
|
||||
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: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-upgrade-test-${{ github.run_id }}
|
||||
path: |
|
||||
/tmp/rustfs-upgrade-report.md
|
||||
/tmp/rustfs-upgrade.*/*
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: ${{ always() && (inputs.cleanup_after != 'false' || github.event_name != 'workflow_dispatch') }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: S3 compatibility)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-s3' \
|
||||
-F 'client_payload[from_suite]=upgrade'; then
|
||||
echo "dispatched next suite S3 compatibility (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch S3 compatibility after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after upgrade (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **upgrade** to **S3 compatibility** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-s3'"
|
||||
echo "- 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-s3'"
|
||||
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
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS upgrade compatibility test failed"
|
||||
echo "From: ${{ inputs.from_url || inputs.from_version || 'release (default)' }}"
|
||||
echo "To: ${{ inputs.to_url || inputs.to_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded report and logs for details."
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
|
||||
- uses: overtrue/repo-visuals-action@fd79cba437ecfac933d00a69add17eb95d3939c3 # v1.3.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
output-branch: star-history
|
||||
|
||||
+4
-3
@@ -55,11 +55,10 @@ docs/*
|
||||
!docs/architecture/**
|
||||
!docs/operations/
|
||||
!docs/operations/**
|
||||
!docs/postmortems/
|
||||
!docs/postmortems/**
|
||||
!docs/testing/
|
||||
!docs/testing/**
|
||||
docs/heal-scanner-logging-governance.md
|
||||
docs/benchmark/rustfs-target-bench/
|
||||
docs/benchmark/*.md
|
||||
.codegraph/*
|
||||
.docker/test/compat/data/*
|
||||
.docker/test/compat/kms/*
|
||||
@@ -83,6 +82,8 @@ worktrees/*
|
||||
|
||||
# Local AI-agent review artifacts (omo evidence dumps)
|
||||
.omo/
|
||||
# Legacy per-tool skill dir; skills live in .agents/skills (shared by all agents)
|
||||
.mimocode/
|
||||
|
||||
# insta scratch files; the accepted .snap files ARE the assertions and are committed
|
||||
*.snap.new
|
||||
|
||||
@@ -86,6 +86,7 @@ This file contains repository-wide rules. Use the nearest subdirectory
|
||||
- CI gates: `.github/workflows/ci.yml`.
|
||||
- PR format: `.github/pull_request_template.md`.
|
||||
- Architecture routing: `ARCHITECTURE.md` and `docs/architecture/README.md`.
|
||||
- Knowledge-base index and documentation rules: `docs/architecture/README.md`.
|
||||
- Agent skills: `.agents/skills/*/SKILL.md`.
|
||||
|
||||
Do not commit one-shot plans, trackers, migration ledgers, benchmark snapshots,
|
||||
@@ -123,12 +124,13 @@ runtime/build output:
|
||||
- Use `make pre-commit` only when its repository-wide fast checks add confidence
|
||||
beyond the focused checks.
|
||||
|
||||
### Broad or High-Risk Changes
|
||||
### Broad Cross-Module Changes
|
||||
|
||||
After the required adversarial review, run `make pre-pr` when targeted coverage
|
||||
cannot bound the impact, including dependency/toolchain/build-matrix changes,
|
||||
unbounded cross-crate APIs, or locking, durability, erasure coding, replication,
|
||||
RPC, IAM/KMS/auth, cryptography, on-disk/on-wire, and S3-visible behavior.
|
||||
Do not run `make pre-pr` by default before opening a PR. Consider it only when
|
||||
the final diff is broad, spans multiple modules, and targeted checks cannot
|
||||
bound the impact. Decide dynamically from the affected boundaries and risks;
|
||||
otherwise use the scoped formatting, linting, compilation, and test checks
|
||||
above.
|
||||
|
||||
`make pre-pr` includes `make pre-commit`; never run both for the same unchanged
|
||||
diff. Do not repeat a check already covered by a successful umbrella gate.
|
||||
@@ -160,6 +162,12 @@ Risk and review shape:
|
||||
S3-visible semantics. Cover all applicable lenses using exactly two
|
||||
independent reviewers when delegation is explicitly authorized. Split the
|
||||
lenses between them. Otherwise perform two fresh sequential passes.
|
||||
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
|
||||
the remote SDK configuration sends to every replication or migration target
|
||||
is high risk for every target class even when the change fixes one. Follow
|
||||
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
|
||||
run the outbound target matrix, document each new env knob in the same PR,
|
||||
and list verified and unverified target classes in the PR Impact section.
|
||||
|
||||
Available domain lenses are security, concurrency/durability, compatibility,
|
||||
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
||||
|
||||
+3
-3
@@ -62,7 +62,7 @@ rustfs/ # Workspace root (virtual manifest)
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ ├── ... # (see "Crate Reference" below)
|
||||
│ └── e2e_test/ # End-to-end integration tests
|
||||
└── docs/ # Design documents and analysis
|
||||
└── docs/ # Agent knowledge base: contracts, runbooks, testing rules (index: docs/architecture/README.md)
|
||||
```
|
||||
|
||||
### Main Crate Layers (`rustfs/src/`)
|
||||
@@ -92,7 +92,7 @@ refactors.
|
||||
|
||||
| Domain | Current workspace crates | Responsibility |
|
||||
|--------|--------------------------|----------------|
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. |
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-metrics`, `utils` | Shared configuration, data-usage models, heal domain contracts, scanner telemetry types, utilities, and checksums. |
|
||||
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
|
||||
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
|
||||
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
|
||||
@@ -135,7 +135,7 @@ default build (lifecycle:
|
||||
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
|
||||
collision, not copies; renaming is tracked in rustfs/backlog#1847.
|
||||
- `LastMinuteLatency` has two deliberately different implementations: the
|
||||
per-second bucketed accumulator in `crates/scanner-contracts/src/last_minute.rs` and
|
||||
per-second bucketed accumulator in `crates/scanner-metrics/src/last_minute.rs` and
|
||||
the in-memory endpoint-health sample tracker in
|
||||
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
|
||||
why it stays local).
|
||||
|
||||
@@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||
|
||||
### Added
|
||||
- **On-Demand Migration**: Lazy, pull-style migration of an existing S3-compatible bucket into RustFS. A local bucket is attached to an external source bucket; a GET for a key that does not exist locally fetches it from the source, streams it to the client, and stores it locally in the same pass, so every later read is served locally. The module is on by default; set `RUSTFS_ON_DEMAND_MIGRATION_ENABLED=false` on every node to turn it off. A bucket with no source configured behaves exactly as before — the runtime never intervenes on its reads and makes no outbound call. Operator guide at `docs/operations/on-demand-migration.md`.
|
||||
- Per-bucket configuration persisted as `on-demand-migration.json` in the bucket metadata: source provider (`s3`, `aws`, `minio`, `rustfs`, `r2`, `gcs`), endpoint, region, addressing style, credentials and TLS material, an optional key-prefix filter and source-prefix rewrite, and a policy block covering the inline size threshold, multipart part size, concurrency, queue capacity, timeouts, bandwidth limit and negative-cache TTL
|
||||
- Admin routes under `/rustfs/admin/v3/on-demand-migration/{bucket}`: `PUT` (with `?dry-run=true` to validate and probe the source without saving), `GET`, `DELETE`, `GET .../status`, plus `POST .../backfill?op=start|cancel` and `GET .../backfill` for the background full-backfill job with its resumable checkpoint. Authorized by the new `admin:GetBucketOnDemandMigration` and `admin:SetBucketOnDemandMigration` actions; every response redacts `secret_key` and `session_token`
|
||||
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
|
||||
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
|
||||
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
|
||||
- Limitations: listings show only local objects (the source is not merged into `ListObjectsV2`); PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
|
||||
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
|
||||
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
|
||||
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
|
||||
|
||||
@@ -15,7 +15,7 @@ cargo check -p <crate> # fast type-check one crate
|
||||
cargo test -p <crate> # test one crate
|
||||
cargo fmt --all # format (required before PR)
|
||||
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
|
||||
make pre-pr # full pre-PR gate: fmt + arch checks + clippy + tests
|
||||
make pre-pr # optional full gate for broad cross-module changes
|
||||
make build-docker BUILD_OS=ubuntu22.04
|
||||
```
|
||||
|
||||
@@ -27,6 +27,7 @@ make build-docker BUILD_OS=ubuntu22.04
|
||||
|
||||
## Where to look (do not duplicate here)
|
||||
|
||||
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
|
||||
- Crate membership: `Cargo.toml` `[workspace].members`
|
||||
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
|
||||
|
||||
+20
-7
@@ -62,12 +62,20 @@ make test
|
||||
# Fast pre-commit gate — see below for exactly what it runs
|
||||
make pre-commit
|
||||
|
||||
# Full pre-PR gate (pre-commit gates + clippy + tests)
|
||||
# Optional full gate for broad cross-module changes (pre-commit + clippy + tests)
|
||||
make pre-pr
|
||||
```
|
||||
|
||||
> `make test` requires [cargo-nextest](https://nexte.st) (CI runs it and only nextest honours `.config/nextest.toml` test-groups). Install it with `cargo install cargo-nextest --locked` or a prebuilt binary (see https://nexte.st/docs/installation/). To run the plain `cargo test` fallback anyway (results not authoritative — serialization semantics differ from CI), set `RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1`.
|
||||
|
||||
> Some guard checks are Python (`test-wiring-check` in `make pre-commit`, plus the
|
||||
> security-coverage and scheduled-validation self-tests in `make test`) and import
|
||||
> `tomllib`, so they need **Python 3.11+**. Make resolves the interpreter through
|
||||
> `scripts/python_bin.sh`, which prefers a `python3.11`+ on `PATH` and otherwise falls
|
||||
> back to `uv run --python 3.12`. macOS ships `/usr/bin/python3` at 3.9, so install a
|
||||
> newer one (`brew install python@3.12`) or [uv](https://docs.astral.sh/uv/); pin a
|
||||
> specific interpreter with `RUSTFS_PYTHON=/path/to/python3.12`.
|
||||
|
||||
> For the full test-layer taxonomy (unit / ecstore black-box / e2e / s3s-e2e / S3 compatibility / chaos / fuzz / bench), each layer's entry command, the naming conventions the migration gate depends on, and the serial/nextest rules, see [docs/testing/README.md](docs/testing/README.md).
|
||||
|
||||
> For the event, timeout, required-status, and local reproduction matrix, see [docs/testing/ci-gates.md](docs/testing/ci-gates.md).
|
||||
@@ -88,14 +96,16 @@ make pre-pr
|
||||
8. `quick-check` — `cargo check --workspace --exclude e2e_test`
|
||||
|
||||
**`make pre-commit` does NOT run clippy and does NOT run any tests.**
|
||||
A green `make pre-commit` is not enough to open a pull request.
|
||||
It does not replace the scoped Clippy and test checks applicable to a change.
|
||||
|
||||
`make pre-pr` is the **full** gate: it runs all of the guard checks above,
|
||||
then `clippy-check` (`cargo clippy --all-targets --all-features -- -D warnings`)
|
||||
and `test` (shell script tests, workspace tests excluding `e2e_test`, and doc
|
||||
tests). Complete the applicable multi-role adversarial review described in
|
||||
`AGENTS.md` before running `make pre-pr`; then run the gate before opening or
|
||||
updating a pull request. This is what CI enforces.
|
||||
`AGENTS.md` first. Do not run `make pre-pr` locally by default before opening or
|
||||
updating a pull request. Consider it only for a broad change that spans multiple
|
||||
modules and whose impact cannot be bounded by targeted checks; decide from the
|
||||
affected boundaries and risks. CI still runs its configured repository gates.
|
||||
|
||||
### 🔒 Git Pre-commit Hooks (optional)
|
||||
|
||||
@@ -114,8 +124,9 @@ Or manually:
|
||||
chmod +x .git/hooks/pre-commit
|
||||
```
|
||||
|
||||
With or without a hook, the expectation is the same: run `make pre-commit`
|
||||
before committing and `make pre-pr` before opening a pull request.
|
||||
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
|
||||
|
||||
@@ -154,7 +165,9 @@ Example output when formatting fails:
|
||||
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
|
||||
4. **Commit your changes**: `git commit -m "your message"`
|
||||
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
|
||||
6. **Run the full gate before opening/updating a PR**: `make pre-pr` (clippy + tests)
|
||||
6. **Run applicable scoped checks before opening/updating a PR**; consider
|
||||
`make pre-pr` only for broad cross-module changes whose impact cannot be
|
||||
bounded by targeted checks
|
||||
7. **Push to your branch**: `git push`
|
||||
|
||||
### 🛠️ IDE Integration
|
||||
|
||||
Generated
+265
-217
File diff suppressed because it is too large
Load Diff
+71
-68
@@ -29,6 +29,7 @@ members = [
|
||||
"crates/heal-contracts", # Heal request/response channel contracts
|
||||
"crates/iam", # Identity and Access Management
|
||||
"crates/keystone", # OpenStack Keystone integration
|
||||
"crates/license", # License and entitlement provider contracts
|
||||
"crates/lifecycle", # Lifecycle rule evaluation contracts
|
||||
"crates/kms", # Key Management Service
|
||||
"crates/lock", # Distributed locking implementation
|
||||
@@ -51,7 +52,7 @@ members = [
|
||||
"crates/s3select-api", # S3 Select API interface
|
||||
"crates/s3select-query", # S3 Select query engine
|
||||
"crates/scanner", # Scanner for data integrity checks and health monitoring
|
||||
"crates/scanner-contracts", # Scanner metrics and cycle contracts
|
||||
"crates/scanner-metrics", # Scanner metrics and cycle telemetry
|
||||
"crates/security-governance", # Security governance contracts
|
||||
"crates/extension-schema", # Extension schema contracts
|
||||
"crates/signer", # client signer
|
||||
@@ -71,8 +72,8 @@ resolver = "3"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.97.1"
|
||||
version = "1.0.0-rc.4"
|
||||
rust-version = "1.98.0"
|
||||
version = "1.0.0-rc.5"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -89,61 +90,62 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.4" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.4" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.4" }
|
||||
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.4" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.4" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.4" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.4" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.4" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.4" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.4" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.4" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.4" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.4" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.4" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.4" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.4" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.4" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.4" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.4" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.4" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.4" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.4" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.4" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.4" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.4", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.4" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.4" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.4" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.4" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.4" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.4" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.4" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.4" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.4" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.4" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.4" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.4" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.4" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.4" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.4" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.4" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.4" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.4" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.4" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.4" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.4" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.4" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.4" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.4" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.5" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.5" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.5" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.0-rc.5" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.5" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.5" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.5" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.5" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.5" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.5" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.5" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.5" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.5" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.5" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.5" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.5" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.0-rc.5" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.5" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.5" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.5" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.5" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.5" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.5" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.5" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.5" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.5", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.5" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.5" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.5" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.5" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.5" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.5" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.5" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.5" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.5" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.5" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.5" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.5" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.5" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.5" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.5" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.5" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.5" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.5" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.5" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.5" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.5" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.5" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.5" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
async_zip = { default-features = false, version = "0.0.19" }
|
||||
mysql_async = { default-features = false, version = "0.37" }
|
||||
async-compression = { version = "0.4.43" }
|
||||
mysql_async = { default-features = false, version = "0.37.1" }
|
||||
async-compression = { version = "0.4.44" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.92"
|
||||
async-nats = { version = "0.50.0", default-features = false }
|
||||
@@ -163,7 +165,7 @@ http-body = "1.1.0"
|
||||
http-body-util = "0.1.5"
|
||||
minlz = "1.2.3"
|
||||
reqwest = "0.13.4"
|
||||
rustfs-kafka-async = { version = "1.2.0" }
|
||||
rustfs-kafka-async = { version = "1.3.1" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.53.1" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.4" }
|
||||
@@ -174,7 +176,7 @@ tonic = { version = "0.14.6" }
|
||||
tonic-prost = { version = "0.14.6" }
|
||||
tonic-prost-build = { version = "0.14.6" }
|
||||
tower = { version = "0.5.3" }
|
||||
tower-http = { version = "0.7.0" }
|
||||
tower-http = { version = "0.7.1" }
|
||||
|
||||
# Serialization and Data Formats
|
||||
apache-avro = { version = "0.22.0", features = ["snappy", "zstandard"] }
|
||||
@@ -232,7 +234,8 @@ tokio-postgres-rustls = "0.14.0"
|
||||
# Utilities and Tools
|
||||
anyhow = "1.0.104"
|
||||
arc-swap = "1.9.2"
|
||||
astral-tokio-tar = "0.7.0"
|
||||
# RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin until every parser hardening used by Snowball is released upstream. Remove after astral-sh/tokio-tar#118 is merged and a published release includes extension, physical-entry, and sparse limits, cancellation-safe sparse parsing, and error-fused entry streams.
|
||||
astral-tokio-tar = { git = "https://github.com/cxymds/tokio-tar.git", rev = "603756478b7668436e464519c77ccac22a99ba96" }
|
||||
atoi = "3.1.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.11.0" }
|
||||
@@ -241,10 +244,10 @@ aws-sdk-kms = { default-features = false, version = "1.117.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.113.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
||||
aws-smithy-runtime-api = { version = "1.15.0" }
|
||||
aws-smithy-types = { version = "1.6.2" }
|
||||
aws-smithy-runtime-api = { version = "1.16.0" }
|
||||
aws-smithy-types = { version = "1.6.3" }
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
brotli = "9.0.0"
|
||||
clap = { version = "4.6.6" }
|
||||
const-str = { version = "1.1.0" }
|
||||
convert_case = "0.12.0"
|
||||
@@ -282,7 +285,7 @@ mime_guess = "2.0.5"
|
||||
moka = { version = "0.12.16" }
|
||||
netif = "0.1.6"
|
||||
num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.12.1"
|
||||
nvml-wrapper = "0.13.0"
|
||||
parking_lot = "0.12.5"
|
||||
path-absolutize = "4.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
@@ -295,7 +298,7 @@ pretty_assertions = "1.4.1"
|
||||
rand = { version = "0.10.2" }
|
||||
ratelimit = "2.0.0"
|
||||
rayon = "1.12.0"
|
||||
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
|
||||
rustfs-erasure-codec = { version = "8.0.2" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||
@@ -304,11 +307,11 @@ rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.4" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "9c4690d8e73fc8d184031a19b2c4539ebc77d180", version = "0.15.0", features = ["minio"] }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "bdcb6259339c41369f9f1c60e3a42b5ab8da607b", version = "0.15.0", features = ["minio"] }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.3"
|
||||
smallvec = { version = "1.15.2" }
|
||||
smallvec = { version = "1.16.0" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.3.0" }
|
||||
@@ -354,17 +357,17 @@ pyroscope = { version = "2.1.1" }
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.0" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "10.0.2" }
|
||||
suppaftp = { version = "11.0.0" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.63.1" }
|
||||
russh = { version = "0.63.2" }
|
||||
russh-sftp = "2.4.0"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.5.1" }
|
||||
hotpath = { version = "0.24.0", default-features = false }
|
||||
rustfs-mimalloc = { version = "0.5.3" }
|
||||
hotpath = { version = "0.25.0", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ SHELL := $(shell which bash)
|
||||
.SHELLFLAGS = -eu -o pipefail -c
|
||||
|
||||
DOCKER_CLI ?= docker
|
||||
# Python interpreter for the repository's helper scripts. They import tomllib
|
||||
# (Python 3.11+), while macOS still ships /usr/bin/python3 at 3.9, so calls go
|
||||
# through a resolver that picks a new-enough interpreter (or falls back to uv).
|
||||
# Override with RUSTFS_PYTHON=/path/to/python3.12, or replace the resolver via
|
||||
# RUSTFS_PYTHON_BIN=<command>.
|
||||
RUSTFS_PYTHON_BIN ?= ./scripts/python_bin.sh
|
||||
IMAGE_NAME ?= rustfs:v1.0.0
|
||||
CONTAINER_NAME ?= rustfs-dev
|
||||
# Docker build configurations
|
||||
|
||||
@@ -48,16 +48,33 @@ Unlike other storage systems, RustFS is released under the permissible Apache 2.
|
||||
- **Open Source**: Licensed under Apache 2.0, encouraging unrestricted community contributions and commercial usage.
|
||||
- **User-Friendly**: Designed with simplicity in mind for easy deployment and management.
|
||||
|
||||
| Feature | Status | Feature | Status |
|
||||
| :---------------------- | :----------- | :----------------------- | :--------------- |
|
||||
| **S3 Core Features** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
||||
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
||||
| **Versioning** | ✅ Available | **Bucket Replication** | ✅ Available |
|
||||
| **Logging** | ✅ Available | **Lifecycle Management** | 🚧 Under Testing |
|
||||
| **Event Notifications** | ✅ Available | **Distributed Mode** | 🚧 Under Testing |
|
||||
| **K8s Helm Charts** | ✅ Available | **RustFS KMS** | 🚧 Under Testing |
|
||||
| **Keystone Auth** | ✅ Available | **Multi-Tenancy** | ✅ Available |
|
||||
| **Swift API** | ✅ Available | **Swift Metadata Ops** | 🚧 Partial |
|
||||
Status legend: ✅ Available — shipped and covered by CI gates; 🧪 Preview — shipped behind an opt-in flag or with a bounded compatibility claim.
|
||||
|
||||
| Feature | Status | Feature | Status |
|
||||
| :------------------------------- | :----------- | :--------------------------------- | :----------- |
|
||||
| **S3 Core Features** | ✅ Available | **Distributed Mode** | ✅ Available |
|
||||
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
||||
| **Versioning** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
||||
| **Object Lock (WORM)** | ✅ Available | **Healing & Scanner** | ✅ Available |
|
||||
| **Server-Side Encryption** | ✅ Available | **Pool Expansion / Decommission** | ✅ Available |
|
||||
| **RustFS KMS** | ✅ Available | **Bucket Replication** | ✅ Available |
|
||||
| **Lifecycle Management (ILM)** | ✅ Available | **Site Replication** | ✅ Available |
|
||||
| **ILM Tiering (Remote S3)** | ✅ Available | **Bucket Quota** | ✅ Available |
|
||||
| **S3 Select** | ✅ Available | **Event Notifications** | ✅ Available |
|
||||
| **S3 Tables (Iceberg REST)** | 🧪 Preview | **Audit Logging** | ✅ Available |
|
||||
| **IAM / Policies** | ✅ Available | **Logging & Observability** | ✅ Available |
|
||||
| **OIDC / SSO** | ✅ Available | **Web Console** | ✅ Available |
|
||||
| **Keystone Auth** | ✅ Available | **K8s Helm Charts** | ✅ Available |
|
||||
| **Swift API** | ✅ Available | **FTPS / WebDAV** | ✅ Available |
|
||||
| **Multi-Tenancy** | ✅ Available | **SFTP** | ✅ Available |
|
||||
| **MinIO On-Disk Compatibility** | 🧪 Preview | | |
|
||||
|
||||
Notes:
|
||||
|
||||
- **RustFS KMS**: Vault (KV2 / Transit) and AWS KMS backends are supported for production. The `Local` and `Static` backends are for development and testing only. See [KMS backend security properties](docs/operations/kms-backend-security.md).
|
||||
- **Swift API / SFTP**: opt-in cargo features (`--features swift`, `--features sftp`, or `full`). FTPS and WebDAV are enabled in the default build.
|
||||
- **S3 Tables**: ships as an Iceberg REST Catalog with automated PyIceberg and DuckDB coverage; other engines and vendor profiles carry bounded claims listed in the [S3 Tables support matrix](docs/architecture/s3-tables-support-matrix.md).
|
||||
- **MinIO On-Disk Compatibility**: gated behind the `rio-v2` feature and not part of the default build. Objects MinIO encrypted are not readable by RustFS. See [MinIO file-format interoperability](docs/architecture/minio-file-format-compat.md).
|
||||
|
||||
## RustFS vs MinIO Performance
|
||||
|
||||
@@ -115,7 +132,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.5
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.5
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
|
||||
@@ -59,20 +59,20 @@ pub const ENV_CAPACITY_MAX_TIMEOUT: &str = "RUSTFS_CAPACITY_MAX_TIMEOUT";
|
||||
// ============================================================================
|
||||
|
||||
/// Scheduled update interval in seconds
|
||||
/// Default: 120 seconds (2 minutes)
|
||||
pub const DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS: u64 = 120;
|
||||
/// Default: 600 seconds (10 minutes)
|
||||
pub const DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS: u64 = 600;
|
||||
|
||||
/// Write trigger delay in seconds
|
||||
/// Default: 5 seconds
|
||||
pub const DEFAULT_WRITE_TRIGGER_DELAY_SECS: u64 = 5;
|
||||
/// Default: 30 seconds
|
||||
pub const DEFAULT_WRITE_TRIGGER_DELAY_SECS: u64 = 30;
|
||||
|
||||
/// Write frequency threshold (writes per minute)
|
||||
/// Default: 5 writes/minute
|
||||
pub const DEFAULT_WRITE_FREQUENCY_THRESHOLD: usize = 5;
|
||||
/// Default: 20 writes/minute
|
||||
pub const DEFAULT_WRITE_FREQUENCY_THRESHOLD: usize = 20;
|
||||
|
||||
/// Fast update threshold in seconds
|
||||
/// Default: 30 seconds
|
||||
pub const DEFAULT_FAST_UPDATE_THRESHOLD_SECS: u64 = 30;
|
||||
/// Default: 120 seconds
|
||||
pub const DEFAULT_FAST_UPDATE_THRESHOLD_SECS: u64 = 120;
|
||||
|
||||
/// Maximum files threshold for sampling
|
||||
/// Default: 200,000 files
|
||||
@@ -129,4 +129,16 @@ mod tests {
|
||||
assert_eq!(ENV_CAPACITY_MIN_TIMEOUT, "RUSTFS_CAPACITY_MIN_TIMEOUT");
|
||||
assert_eq!(ENV_CAPACITY_MAX_TIMEOUT, "RUSTFS_CAPACITY_MAX_TIMEOUT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capacity_default_values() {
|
||||
assert_eq!(DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS, 600);
|
||||
assert_eq!(DEFAULT_WRITE_TRIGGER_DELAY_SECS, 30);
|
||||
assert_eq!(DEFAULT_WRITE_FREQUENCY_THRESHOLD, 20);
|
||||
assert_eq!(DEFAULT_FAST_UPDATE_THRESHOLD_SECS, 120);
|
||||
assert_eq!(DEFAULT_MAX_FILES_THRESHOLD, 200_000);
|
||||
assert_eq!(DEFAULT_STAT_TIMEOUT_SECS, 3);
|
||||
assert_eq!(DEFAULT_SAMPLE_RATE, 200);
|
||||
assert_eq!(DEFAULT_CAPACITY_METRICS_INTERVAL_SECS, 600);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,27 @@ pub const DEFAULT_POOL_META_V3_FLEET_CONFIRMED: bool = false;
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V3_WRITE);
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V3_FLEET_CONFIRMED);
|
||||
|
||||
/// Maximum unpacked size accepted for one Snowball archive member.
|
||||
///
|
||||
/// The value is expressed in bytes. Invalid values use the default, while
|
||||
/// valid values are clamped to [`MAX_SNOWBALL_ENTRY_BYTES`].
|
||||
pub const ENV_SNOWBALL_MAX_ENTRY_BYTES: &str = "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES";
|
||||
pub const DEFAULT_SNOWBALL_MAX_ENTRY_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
pub const MAX_SNOWBALL_ENTRY_BYTES: u64 = 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
|
||||
|
||||
/// Maximum cumulative unpacked object bytes accepted from one Snowball
|
||||
/// archive request.
|
||||
///
|
||||
/// This does not include tar headers or bounded PAX metadata. The value is
|
||||
/// expressed in bytes and is clamped to
|
||||
/// [`MAX_SNOWBALL_UNPACKED_BYTES`].
|
||||
pub const ENV_SNOWBALL_MAX_UNPACKED_BYTES: &str = "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES";
|
||||
pub const DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES: u64 = 10 * 1024 * 1024 * 1024;
|
||||
pub const MAX_SNOWBALL_UNPACKED_BYTES: u64 = 10 * 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
|
||||
|
||||
const _: () = assert!(DEFAULT_SNOWBALL_MAX_ENTRY_BYTES <= MAX_SNOWBALL_ENTRY_BYTES);
|
||||
const _: () = assert!(DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES <= MAX_SNOWBALL_UNPACKED_BYTES);
|
||||
|
||||
// =============================================================================
|
||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||
// =============================================================================
|
||||
@@ -820,4 +841,10 @@ mod remote_version_state_tests {
|
||||
assert_eq!(super::ENV_POOL_META_V3_WRITE, "RUSTFS_POOL_META_V3_WRITE");
|
||||
assert_eq!(super::ENV_POOL_META_V3_FLEET_CONFIRMED, "RUSTFS_POOL_META_V3_FLEET_CONFIRMED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowball_limit_environment_names_are_stable() {
|
||||
assert_eq!(super::ENV_SNOWBALL_MAX_ENTRY_BYTES, "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES");
|
||||
assert_eq!(super::ENV_SNOWBALL_MAX_UNPACKED_BYTES, "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,11 +198,11 @@ pub const ENV_SCANNER_IDLE_MODE: &str = "RUSTFS_SCANNER_IDLE_MODE";
|
||||
/// Environment variable that controls scanner cache save timeout in seconds.
|
||||
/// The scanner enforces a minimum value of `1`.
|
||||
/// - Unit: seconds (u64).
|
||||
/// - Example: `export RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS=30`
|
||||
/// - Example: `export RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS=14`
|
||||
pub const ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS: &str = "RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS";
|
||||
|
||||
/// Default scanner cache save timeout in seconds.
|
||||
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 30;
|
||||
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 14;
|
||||
|
||||
/// Environment variable that caps concurrent scanner set tasks.
|
||||
/// A value of `0` keeps the existing topology-based concurrency.
|
||||
|
||||
@@ -100,7 +100,8 @@ aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a",
|
||||
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||
aws-config = { workspace = true }
|
||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
||||
async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
|
||||
aws-smithy-types.workspace = true
|
||||
async-compression = { workspace = true, features = ["tokio", "bzip2", "lz4", "xz"] }
|
||||
async-trait = { workspace = true }
|
||||
flate2.workspace = true
|
||||
http.workspace = true
|
||||
@@ -114,6 +115,7 @@ rustfs-signer.workspace = true
|
||||
# server's implementation: a shared helper could agree with a bug on both sides.
|
||||
data-encoding = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
minlz.workspace = true
|
||||
sha1 = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -25,7 +25,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
||||
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
|
||||
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
|
||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||
|
||||
@@ -169,7 +169,7 @@ the same profile for membership and execution with one nightly worker.
|
||||
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
|
||||
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
|
||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
||||
| Direct upgrade 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) |
|
||||
| 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) |
|
||||
@@ -233,8 +233,8 @@ spawn error. Install the pinned CI version before running their profiles.
|
||||
[`src/policy/README.md`](src/policy/README.md),
|
||||
[`src/protocols/README.md`](src/protocols/README.md),
|
||||
[`src/reliant/README.md`](src/reliant/README.md)
|
||||
- Authoritative per-module counts:
|
||||
[`docs/testing/e2e-suite-inventory.md`](../../docs/testing/e2e-suite-inventory.md)
|
||||
- Per-module counts: `cargo nextest list -p e2e_test --profile <profile>`
|
||||
(one-liner in [`docs/testing/README.md`](../../docs/testing/README.md))
|
||||
- Test pyramid & flake policy: [`docs/testing/README.md`](../../docs/testing/README.md)
|
||||
|
||||
## CI smoke subset (`--profile e2e-smoke`)
|
||||
@@ -271,12 +271,12 @@ Note on `#[serial]`: nextest runs each test in its own process, so
|
||||
parallel-safe by construction (random port + isolated temp dir), which the
|
||||
current subset is.
|
||||
|
||||
### Authoritative test inventory
|
||||
### Test inventory
|
||||
|
||||
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
|
||||
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
|
||||
moving e2e tests so acceptance numbers in the test-strategy issues
|
||||
(backlog#1147–#1155) stay auditable. When a profile membership change is
|
||||
Per-module counts are not committed; list them with
|
||||
`cargo nextest list -p e2e_test --profile <profile>` (the result is
|
||||
platform-dependent because some modules are linux-only; the `jq` one-liner is
|
||||
in `docs/testing/README.md`). When a profile membership change is
|
||||
intentional, review its JSON listing before updating the matching
|
||||
`.config/e2e-*-selection.txt` test-ID digest. Update only the platform that
|
||||
produced the listing:
|
||||
|
||||
@@ -22,7 +22,10 @@ mod tests {
|
||||
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
||||
use aws_sdk_s3::types::{
|
||||
ChecksumAlgorithm, ChecksumMode, ChecksumType as SdkChecksumType, CompletedMultipartUpload, CompletedPart,
|
||||
ServerSideEncryption,
|
||||
};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||
@@ -260,6 +263,117 @@ mod tests {
|
||||
info!("PASSED: HeadObject returns stored SHA256 digest");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_head_object_returns_sse_s3_checksum() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_SSE_S3_MASTER_KEY", "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="),
|
||||
("RUSTFS_CONSOLE_ENABLE", "false"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-sse-s3-checksum-head";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
let put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("encrypted.txt")
|
||||
.body(ByteStream::from_static(b"encrypted checksum"))
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 PutObject with CRC32 failed");
|
||||
let expected = put.checksum_crc32().expect("PutObject must return CRC32");
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("encrypted.txt")
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 HeadObject failed");
|
||||
|
||||
assert_eq!(head.checksum_crc32(), Some(expected));
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("encrypted-copy.txt")
|
||||
.copy_source(format!("{bucket}/encrypted.txt"))
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 CopyObject failed");
|
||||
let copy_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("encrypted-copy.txt")
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 copied HeadObject failed");
|
||||
|
||||
assert_eq!(copy_head.checksum_crc32(), Some(expected));
|
||||
|
||||
let multipart_key = "encrypted-multipart.txt";
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(multipart_key)
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 CreateMultipartUpload with CRC32 failed");
|
||||
let upload_id = create.upload_id().expect("CreateMultipartUpload must return an upload ID");
|
||||
let part = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from_static(b"encrypted multipart checksum"))
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 UploadPart with CRC32 failed");
|
||||
let completed_part = CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(part.e_tag().expect("UploadPart must return an ETag"))
|
||||
.checksum_crc32(part.checksum_crc32().expect("UploadPart must return CRC32"))
|
||||
.build();
|
||||
let complete = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 CompleteMultipartUpload with CRC32 failed");
|
||||
let expected_multipart = complete.checksum_crc32().expect("CompleteMultipartUpload must return CRC32");
|
||||
let multipart_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(multipart_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 multipart HeadObject failed");
|
||||
|
||||
assert_eq!(multipart_head.checksum_crc32(), Some(expected_multipart));
|
||||
}
|
||||
|
||||
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
|
||||
/// Uses part size >= 5MB (server minimum) for two parts.
|
||||
#[tokio::test]
|
||||
@@ -485,6 +599,222 @@ mod tests {
|
||||
Some(full_checksum.as_str()),
|
||||
"Multipart object should report the same full-object CRC64NVME as direct upload"
|
||||
);
|
||||
assert_eq!(
|
||||
multipart_head.checksum_type(),
|
||||
Some(&SdkChecksumType::FullObject),
|
||||
"Multipart object with a full-object checksum must report FULL_OBJECT"
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a CRC32 FULL_OBJECT multipart upload and upload every part, returning
|
||||
/// the upload id and the `CompletedPart` list ready for CompleteMultipartUpload.
|
||||
async fn start_full_object_crc32_upload(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
parts: &[&Vec<u8>],
|
||||
) -> (String, Vec<CompletedPart>) {
|
||||
let create_result = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.checksum_type(SdkChecksumType::FullObject)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
|
||||
|
||||
let mut completed_parts = Vec::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let part_number = index as i32 + 1;
|
||||
let uploaded = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from((*part).clone()))
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Failed to upload part {part_number}: {e:?}"));
|
||||
completed_parts.push(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.e_tag(uploaded.e_tag().expect("No etag for part"))
|
||||
.checksum_crc32(uploaded.checksum_crc32().expect("No CRC32 for part"))
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
(upload_id, completed_parts)
|
||||
}
|
||||
|
||||
/// A multipart upload completed with a **full-object** checksum must report
|
||||
/// `x-amz-checksum-type: FULL_OBJECT` on both GET and HEAD, the way AWS does.
|
||||
///
|
||||
/// `complete_multipart_upload` used to persist the object-level checksum
|
||||
/// record with the pre-merge checksum type, so the MULTIPART /
|
||||
/// INCLUDES_MULTIPART flags never reached disk. `rustfs_rio::read_checksums`
|
||||
/// only emits the FULL_OBJECT entry inside its MULTIPART branch, so these
|
||||
/// objects came back from GET and HEAD with no checksum-type header at all.
|
||||
/// Found while root-causing rustfs#6825.
|
||||
#[tokio::test]
|
||||
async fn test_full_object_multipart_reports_full_object_checksum_type() {
|
||||
init_logging();
|
||||
info!("TEST: full-object multipart upload round-trips x-amz-checksum-type: FULL_OBJECT");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-full-object-checksum-type";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 241) as u8).collect();
|
||||
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 29) % 241) as u8).collect();
|
||||
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
||||
|
||||
// CRC32 with an explicit FULL_OBJECT type: the object checksum is the
|
||||
// CRC32 of the whole object, not the composite hash of the part digests.
|
||||
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
|
||||
.expect("crc32 checksum")
|
||||
.encoded;
|
||||
|
||||
let key = "full-object-multipart.bin";
|
||||
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
// Restate the full-object intent and value on CompleteMultipartUpload,
|
||||
// exactly as an AWS SDK client does: `x-amz-checksum-type: FULL_OBJECT`
|
||||
// plus `x-amz-checksum-crc32`, with no `x-amz-checksum-algorithm`
|
||||
// header (CompleteMultipartUpload has no such member).
|
||||
.checksum_type(SdkChecksumType::FullObject)
|
||||
.checksum_crc32(full_object_crc32.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to complete multipart upload");
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to head object");
|
||||
assert_eq!(
|
||||
head.checksum_type(),
|
||||
Some(&SdkChecksumType::FullObject),
|
||||
"HeadObject must report x-amz-checksum-type: FULL_OBJECT"
|
||||
);
|
||||
assert_eq!(
|
||||
head.checksum_crc32(),
|
||||
Some(full_object_crc32.as_str()),
|
||||
"HeadObject must report the full-object CRC32, with no -<parts> suffix"
|
||||
);
|
||||
|
||||
let get = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to get object");
|
||||
assert_eq!(
|
||||
get.checksum_type(),
|
||||
Some(&SdkChecksumType::FullObject),
|
||||
"GetObject must report x-amz-checksum-type: FULL_OBJECT"
|
||||
);
|
||||
assert_eq!(
|
||||
get.checksum_crc32(),
|
||||
Some(full_object_crc32.as_str()),
|
||||
"GetObject must report the full-object CRC32, with no -<parts> suffix"
|
||||
);
|
||||
|
||||
let body = get.body.collect().await.expect("Failed to read body").into_bytes();
|
||||
assert_eq!(body.as_ref(), content.as_slice(), "GetObject body must match the uploaded content");
|
||||
|
||||
info!("PASSED: full-object multipart reports FULL_OBJECT on GET and HEAD");
|
||||
}
|
||||
|
||||
/// Declaring a checksum type on CompleteMultipartUpload that contradicts the
|
||||
/// one recorded at CreateMultipartUpload must be rejected, and rejected as a
|
||||
/// client error (4xx), not a server error.
|
||||
#[tokio::test]
|
||||
async fn test_complete_multipart_rejects_contradicting_checksum_type() {
|
||||
init_logging();
|
||||
info!("TEST: CompleteMultipartUpload rejects a checksum type that contradicts the upload");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-checksum-type-mismatch";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 239) as u8).collect();
|
||||
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 31) % 239) as u8).collect();
|
||||
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
||||
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
|
||||
.expect("crc32 checksum")
|
||||
.encoded;
|
||||
|
||||
let key = "checksum-type-mismatch.bin";
|
||||
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
|
||||
|
||||
let err = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
// The upload was created as FULL_OBJECT; claiming COMPOSITE here
|
||||
// contradicts it.
|
||||
.checksum_type(SdkChecksumType::Composite)
|
||||
.checksum_crc32(full_object_crc32.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect_err("COMPOSITE on a FULL_OBJECT upload must be rejected");
|
||||
|
||||
let service_err = err.into_service_error();
|
||||
let code = service_err.meta().code().unwrap_or("<no code>").to_string();
|
||||
let message = service_err.meta().message().unwrap_or_default().to_string();
|
||||
|
||||
// Before the fix the storage layer refused the combination with a generic
|
||||
// error and the caller got `500 InternalError` -- "please try again" for a
|
||||
// request that can only ever fail.
|
||||
assert_eq!(
|
||||
code, "InvalidRequest",
|
||||
"a contradicting checksum type is a client error, got {code}: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("FULL_OBJECT") && message.contains("COMPOSITE"),
|
||||
"the message must name the recorded and requested types, got {message}"
|
||||
);
|
||||
|
||||
// The upload is untouched by the rejected completion, so a well-formed
|
||||
// retry on the same upload id still succeeds.
|
||||
let listed = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("upload must survive the rejected completion");
|
||||
assert_eq!(listed.parts().len(), 2, "both parts must still be listed after the rejection");
|
||||
|
||||
info!("PASSED: contradicting checksum type rejected as InvalidRequest");
|
||||
}
|
||||
|
||||
/// Integration test for the AWS 2026-04 additional checksum algorithms
|
||||
|
||||
@@ -27,8 +27,10 @@
|
||||
//! Readiness is established by the harness's `start()` handshake (TCP reachability
|
||||
//! plus an S3 `ListBuckets` poll) — there are no fixed sleeps.
|
||||
//!
|
||||
//! Out of scope for this block (tracked separately): network fault injection
|
||||
//! (toxiproxy / socket proxy) and 5GiB large-object budgets.
|
||||
//! The volume-proxy smoke below also proves that the socket-level fault proxy
|
||||
//! can be installed before startup without changing the client-facing node URL.
|
||||
//! A full lock-plane partition matrix and 5GiB large-object budget remain
|
||||
//! tracked separately.
|
||||
|
||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
||||
|
||||
@@ -76,6 +78,28 @@ async fn cluster_multidrive_single_pool_smoke() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 4 nodes x 4 drives, single pool: exercise the maximum local erasure layout
|
||||
/// supported by the cluster harness. This remains in the nightly lane because
|
||||
/// it starts four real server processes and sixteen data directories.
|
||||
#[tokio::test]
|
||||
async fn cluster_four_node_four_drive_single_pool_smoke() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 4)).await?;
|
||||
|
||||
let volumes = cluster.rustfs_volumes_arg();
|
||||
assert_eq!(volumes.split(' ').count(), 16, "expected 16 explicit endpoints, got: {volumes}");
|
||||
assert!(!volumes.contains('{'), "single-pool layout must not use ellipses: {volumes}");
|
||||
assert!(cluster.nodes.iter().all(|node| node.data_dirs.len() == 4));
|
||||
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(BUCKET).await?;
|
||||
|
||||
let payload = vec![0x3Cu8; 1024 * 1024];
|
||||
put_get_roundtrip(&cluster, "multidrive-4/object", &payload).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
|
||||
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
|
||||
#[tokio::test]
|
||||
@@ -103,3 +127,27 @@ async fn cluster_two_pool_smoke() -> TestResult {
|
||||
put_get_roundtrip(&cluster, "twopool/object", &payload).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A real cluster smoke for the volume FaultProxy wiring. The proxy target is
|
||||
/// not listening yet when it is created; cluster startup must still converge
|
||||
/// once the target node starts, and peer disk/RPC traffic must traverse it.
|
||||
#[tokio::test]
|
||||
async fn cluster_volume_fault_proxy_pass_smoke() -> TestResult {
|
||||
crate::common::init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2)).await?;
|
||||
let proxy = cluster.start_volume_proxy_for_node(0).await?;
|
||||
let proxied = proxy.local_addr().to_string();
|
||||
assert!(cluster.rustfs_volumes_arg().contains(&proxied));
|
||||
|
||||
let result: TestResult = async {
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(BUCKET).await?;
|
||||
let payload = vec![0x6Du8; 256 * 1024];
|
||||
put_get_roundtrip(&cluster, "volume-proxy/object", &payload).await
|
||||
}
|
||||
.await;
|
||||
|
||||
proxy.shutdown().await;
|
||||
result
|
||||
}
|
||||
|
||||
+155
-35
@@ -34,6 +34,7 @@ use serde_json;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs as stdfs;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Once;
|
||||
@@ -1214,6 +1215,9 @@ pub struct RustFSTestClusterEnvironment {
|
||||
pub node_extra_env: Vec<Vec<(String, String)>>,
|
||||
pub node_capture_log_paths: Vec<Option<String>>,
|
||||
pub topology: ClusterTopology,
|
||||
/// Optional socket proxies used for the corresponding node's volume
|
||||
/// endpoints. Proxies must be installed before [`Self::start`].
|
||||
volume_proxy_addresses: Vec<Option<SocketAddr>>,
|
||||
}
|
||||
|
||||
impl RustFSTestClusterEnvironment {
|
||||
@@ -1305,6 +1309,7 @@ impl RustFSTestClusterEnvironment {
|
||||
extra_env.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
||||
}
|
||||
|
||||
let node_count = topology.node_count;
|
||||
Ok(Self {
|
||||
nodes,
|
||||
temp_dir,
|
||||
@@ -1314,6 +1319,7 @@ impl RustFSTestClusterEnvironment {
|
||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||
node_capture_log_paths: vec![None; topology.node_count],
|
||||
topology,
|
||||
volume_proxy_addresses: vec![None; node_count],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1381,6 +1387,34 @@ impl RustFSTestClusterEnvironment {
|
||||
self.build_volumes_arg()
|
||||
}
|
||||
|
||||
/// Start a socket proxy for one node's volume endpoints and route all
|
||||
/// subsequent `RUSTFS_VOLUMES` references for that node through it.
|
||||
///
|
||||
/// Call this before [`Self::start`], then use the returned proxy's
|
||||
/// [`crate::fault_proxy::FaultProxy::set_mode`] to inject latency,
|
||||
/// blackhole, or one-way partition faults. The node's own listen address
|
||||
/// remains direct, so S3 clients can still reach it while peer disk/RPC
|
||||
/// traffic is steered through the proxy.
|
||||
pub async fn start_volume_proxy_for_node(
|
||||
&mut self,
|
||||
node_idx: usize,
|
||||
) -> Result<crate::fault_proxy::FaultProxy, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.ensure_node_index(node_idx)?;
|
||||
if self.volume_proxy_addresses[node_idx].is_some() {
|
||||
return Err(format!("a volume proxy is already configured for node {node_idx}").into());
|
||||
}
|
||||
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
|
||||
let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
|
||||
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
|
||||
Ok(proxy)
|
||||
}
|
||||
|
||||
fn volume_address(&self, node_idx: usize) -> String {
|
||||
self.volume_proxy_addresses[node_idx]
|
||||
.map(|address| address.to_string())
|
||||
.unwrap_or_else(|| self.nodes[node_idx].address.clone())
|
||||
}
|
||||
|
||||
fn build_volumes_arg(&self) -> String {
|
||||
let pools = self.topology.normalized_pools();
|
||||
|
||||
@@ -1389,7 +1423,11 @@ impl RustFSTestClusterEnvironment {
|
||||
return self
|
||||
.nodes
|
||||
.iter()
|
||||
.flat_map(|n| n.data_dirs.iter().map(move |dir| format!("http://{}{}", n.address, dir)))
|
||||
.enumerate()
|
||||
.flat_map(|(node_idx, n)| {
|
||||
let address = self.volume_address(node_idx);
|
||||
n.data_dirs.iter().map(move |dir| format!("http://{}{}", address, dir))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
@@ -1400,13 +1438,19 @@ impl RustFSTestClusterEnvironment {
|
||||
pools
|
||||
.iter()
|
||||
.map(|nodes| {
|
||||
let node = &self.nodes[nodes[0]];
|
||||
let node_idx = nodes[0];
|
||||
let node = &self.nodes[node_idx];
|
||||
let base = node
|
||||
.data_dirs
|
||||
.first()
|
||||
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
|
||||
.unwrap_or(&node.data_dir);
|
||||
format!("http://{}{}/drive{{0...{}}}", node.address, base, self.topology.drives_per_node - 1)
|
||||
format!(
|
||||
"http://{}{}/drive{{0...{}}}",
|
||||
self.volume_address(node_idx),
|
||||
base,
|
||||
self.topology.drives_per_node - 1
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
@@ -1425,31 +1469,18 @@ impl RustFSTestClusterEnvironment {
|
||||
/// times out, or cluster service readiness times out.
|
||||
pub async fn start(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = rustfs_binary_path();
|
||||
self.start_with_binary(&binary_path).await
|
||||
}
|
||||
|
||||
/// Start every cluster node with a specific RustFS binary.
|
||||
///
|
||||
/// Upgrade compatibility tests use this to initialize a cluster with a
|
||||
/// pinned previous release before replacing nodes with the workspace build.
|
||||
pub async fn start_with_binary(&mut self, binary_path: &Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let volumes_arg = self.build_volumes_arg();
|
||||
|
||||
for (i, node) in self.nodes.iter_mut().enumerate() {
|
||||
info!("Starting cluster node {} on {}", i, node.address);
|
||||
|
||||
let mut command = Command::new(&binary_path);
|
||||
command
|
||||
.env("RUSTFS_VOLUMES", &volumes_arg)
|
||||
.env("RUSTFS_ADDRESS", &node.address)
|
||||
.env("RUSTFS_ACCESS_KEY", &self.access_key)
|
||||
.env("RUSTFS_SECRET_KEY", &self.secret_key)
|
||||
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
||||
.env("RUST_LOG", "rustfs=info,rustfs_notify=debug");
|
||||
|
||||
for (key, value) in &self.extra_env {
|
||||
command.env(key, value);
|
||||
}
|
||||
for (key, value) in &self.node_extra_env[i] {
|
||||
command.env(key, value);
|
||||
}
|
||||
capture_command_logs(&mut command, self.node_capture_log_paths[i].as_deref())?;
|
||||
|
||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||
|
||||
node.process = Some(process);
|
||||
for node_idx in 0..self.nodes.len() {
|
||||
self.spawn_node(node_idx, binary_path, &volumes_arg)?;
|
||||
}
|
||||
|
||||
for (i, node) in self.nodes.iter().enumerate() {
|
||||
@@ -1465,20 +1496,46 @@ impl RustFSTestClusterEnvironment {
|
||||
|
||||
/// Start one node process using the cluster's existing volume layout.
|
||||
pub async fn start_node(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = rustfs_binary_path();
|
||||
self.start_node_from_binary(node_idx, &binary_path).await
|
||||
}
|
||||
|
||||
/// Start one stopped cluster node with a specific RustFS binary while
|
||||
/// preserving the cluster's volume layout and that node's data directory.
|
||||
pub async fn start_node_from_binary(
|
||||
&mut self,
|
||||
node_idx: usize,
|
||||
binary_path: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let volumes_arg = self.build_volumes_arg();
|
||||
self.spawn_node(node_idx, binary_path, &volumes_arg)?;
|
||||
|
||||
self.wait_for_node_ready(&self.nodes[node_idx].address, node_idx).await?;
|
||||
self.wait_for_node_service_ready(node_idx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_node(
|
||||
&mut self,
|
||||
node_idx: usize,
|
||||
binary_path: &Path,
|
||||
volumes_arg: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.ensure_node_index(node_idx)?;
|
||||
if self.nodes[node_idx].process.is_some() {
|
||||
return Err(format!("cluster node {node_idx} is already running").into());
|
||||
}
|
||||
if !binary_path.is_file() {
|
||||
return Err(format!("RustFS binary does not exist: {}", binary_path.display()).into());
|
||||
}
|
||||
|
||||
let binary_path = rustfs_binary_path();
|
||||
let volumes_arg = self.build_volumes_arg();
|
||||
let log_path = self.node_capture_log_paths[node_idx].clone();
|
||||
let node = &mut self.nodes[node_idx];
|
||||
info!("Starting cluster node {} on {}", node_idx, node.address);
|
||||
info!("Starting cluster node {} on {} with {}", node_idx, node.address, binary_path.display());
|
||||
|
||||
let mut command = Command::new(&binary_path);
|
||||
let mut command = Command::new(binary_path);
|
||||
command
|
||||
.env("RUSTFS_VOLUMES", &volumes_arg)
|
||||
.env("RUSTFS_VOLUMES", volumes_arg)
|
||||
.env("RUSTFS_ADDRESS", &node.address)
|
||||
.env("RUSTFS_ACCESS_KEY", &self.access_key)
|
||||
.env("RUSTFS_SECRET_KEY", &self.secret_key)
|
||||
@@ -1495,9 +1552,6 @@ impl RustFSTestClusterEnvironment {
|
||||
|
||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||
node.process = Some(process);
|
||||
|
||||
self.wait_for_node_ready(&self.nodes[node_idx].address, node_idx).await?;
|
||||
self.wait_for_node_service_ready(node_idx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1645,6 +1699,51 @@ impl RustFSTestClusterEnvironment {
|
||||
process.wait()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully stop one cluster node and wait for its process to exit.
|
||||
///
|
||||
/// This is intentionally separate from [`Self::stop_node`]: the latter is
|
||||
/// a hard kill used by crash-recovery tests, while this path lets RustFS
|
||||
/// complete its normal shutdown hooks before a test restarts the node.
|
||||
pub async fn stop_node_gracefully(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.ensure_node_index(node_idx)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let Some(process) = self.nodes[node_idx].process.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let pid = process.id().to_string();
|
||||
let signal_status = Command::new("kill").args(["-TERM", &pid]).status()?;
|
||||
if !signal_status.success() {
|
||||
return Err(format!("failed to send SIGTERM to cluster node {node_idx} (pid {pid})").into());
|
||||
}
|
||||
|
||||
let mut process = self.nodes[node_idx]
|
||||
.process
|
||||
.take()
|
||||
.ok_or_else(|| format!("cluster node {node_idx} process disappeared while stopping"))?;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(45);
|
||||
loop {
|
||||
if let Some(status) = process.try_wait()? {
|
||||
info!("Cluster node {} stopped gracefully with {}", node_idx, status);
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
return Err(format!("cluster node {node_idx} did not stop gracefully within 45 seconds").into());
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = node_idx;
|
||||
Err("graceful cluster-node stop is only supported on Unix E2E hosts".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RustFSTestClusterEnvironment {
|
||||
@@ -2000,7 +2099,7 @@ mod tests {
|
||||
}
|
||||
let multidrive = topology.drives_per_node > 1;
|
||||
|
||||
let nodes = (0..topology.node_count)
|
||||
let nodes: Vec<ClusterNode> = (0..topology.node_count)
|
||||
.map(|i| {
|
||||
let address = format!("127.0.0.1:{}", 9000 + i);
|
||||
let data_dirs: Vec<String> = if multidrive {
|
||||
@@ -2021,6 +2120,7 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let node_count = nodes.len();
|
||||
RustFSTestClusterEnvironment {
|
||||
nodes,
|
||||
temp_dir,
|
||||
@@ -2030,6 +2130,7 @@ mod tests {
|
||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||
node_capture_log_paths: vec![None; topology.node_count],
|
||||
topology,
|
||||
volume_proxy_addresses: vec![None; node_count],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2114,6 +2215,25 @@ mod tests {
|
||||
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn volume_proxy_rewrites_cluster_volume_endpoint() {
|
||||
let mut env = RustFSTestClusterEnvironment::new(1)
|
||||
.await
|
||||
.expect("cluster environment should allocate a node");
|
||||
let direct = env.nodes[0].address.clone();
|
||||
let proxy = env
|
||||
.start_volume_proxy_for_node(0)
|
||||
.await
|
||||
.expect("volume proxy should bind before the target server starts");
|
||||
let proxied = proxy.local_addr().to_string();
|
||||
let volumes = env.rustfs_volumes_arg();
|
||||
|
||||
assert!(volumes.contains(&proxied), "volumes must use the proxy address: {volumes}");
|
||||
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_node_env_supports_per_node_overrides() {
|
||||
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Regression: an object legally committed at degraded write quorum must stay
|
||||
//! listable while a *different* drive is offline.
|
||||
//!
|
||||
//! On a 4-drive EC 2+2 set, a PUT made while one drive is down persists
|
||||
//! `xl.meta` on 3 of 4 drives (write quorum). If a different drive later goes
|
||||
//! offline before heal converges, a strict latest-listing quorum of 3 can only
|
||||
//! ever observe 2 copies, so ListObjectsV2 silently dropped the object even
|
||||
//! though GetObject (read quorum 2) still succeeded. Exposed by the flaky
|
||||
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853); the
|
||||
//! product fix relaxes the listing's required object quorum by the number of
|
||||
//! set drives the listing could not consult (see
|
||||
//! `latest_listing_required_object_quorum` in
|
||||
//! `crates/ecstore/src/store/list_objects.rs`).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestClusterEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use bytes::Bytes;
|
||||
use std::collections::HashSet;
|
||||
use std::error::Error;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
const BUCKET: &str = "degraded-listing-availability";
|
||||
const OBJECT_COUNT: usize = 8;
|
||||
/// Well under the observed heal-convergence gap (~50s in the CI incident),
|
||||
/// so a listing that only completes after heal restores the missing copy
|
||||
/// still fails this deadline on a regressed build.
|
||||
const LISTING_DEADLINE: Duration = Duration::from_secs(25);
|
||||
const GET_RETRY_DEADLINE: Duration = Duration::from_secs(15);
|
||||
const PUT_RETRY_DEADLINE: Duration = Duration::from_secs(15);
|
||||
|
||||
fn object_key(idx: usize) -> String {
|
||||
format!("degraded-object-{idx:02}")
|
||||
}
|
||||
|
||||
async fn list_all_keys(client: &Client) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
|
||||
let mut keys = HashSet::new();
|
||||
let mut continuation_token: Option<String> = None;
|
||||
loop {
|
||||
let response = client
|
||||
.list_objects_v2()
|
||||
.bucket(BUCKET)
|
||||
.set_continuation_token(continuation_token.clone())
|
||||
.send()
|
||||
.await?;
|
||||
keys.extend(
|
||||
response
|
||||
.contents()
|
||||
.iter()
|
||||
.filter_map(|object| object.key().map(str::to_owned)),
|
||||
);
|
||||
match response.next_continuation_token() {
|
||||
Some(token) => continuation_token = Some(token.to_owned()),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// 4-node single-drive cluster (EC 2+2, write quorum 3):
|
||||
/// 1. Stop node 1 and PUT objects — each commits on nodes {0, 2, 3} only.
|
||||
/// 2. Stop node 3 (a holder drive), then bring node 1 back before heal can
|
||||
/// recreate the missing copies there.
|
||||
/// 3. Every object still satisfies read quorum (nodes 0 and 2), so GET
|
||||
/// must succeed AND ListObjectsV2 must report every key well before
|
||||
/// heal converges.
|
||||
#[tokio::test]
|
||||
async fn degraded_write_remains_listable_while_a_different_drive_is_offline() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||
// Listing availability must not depend on heal convergence: disable
|
||||
// the background healers so the degraded objects keep their metadata
|
||||
// on exactly 3 of 4 drives for the whole test.
|
||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "false");
|
||||
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(BUCKET).await?;
|
||||
let client = cluster.create_s3_client(0)?;
|
||||
|
||||
info!("stopping node 1 so the uploads commit at degraded write quorum (3 of 4)");
|
||||
cluster.stop_node(1)?;
|
||||
// The first writes after a node drops can see transient 503s while the
|
||||
// survivors notice the dead peer; retry briefly (overwrites of the same
|
||||
// unversioned key are idempotent).
|
||||
for idx in 0..OBJECT_COUNT {
|
||||
let key = object_key(idx);
|
||||
let body = format!("degraded listing payload {idx}");
|
||||
let deadline = Instant::now() + PUT_RETRY_DEADLINE;
|
||||
loop {
|
||||
let request = client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(&key)
|
||||
.body(Bytes::from(body.clone()).into());
|
||||
match request.send().await {
|
||||
Ok(_) => break,
|
||||
Err(error) if Instant::now() < deadline => {
|
||||
info!("retrying degraded PUT for {key}: {error}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(error) => return Err(format!("degraded PUT for {key} failed: {error}").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("stopping node 3 (holds a copy) and restoring node 1 (holds none)");
|
||||
cluster.stop_node(3)?;
|
||||
cluster.start_node(1).await?;
|
||||
|
||||
// The first requests after a node drops can see transient 503s while
|
||||
// the survivors notice the dead peer; retry briefly before asserting.
|
||||
for idx in 0..OBJECT_COUNT {
|
||||
let key = object_key(idx);
|
||||
let deadline = Instant::now() + GET_RETRY_DEADLINE;
|
||||
let body = loop {
|
||||
match client.get_object().bucket(BUCKET).key(&key).send().await {
|
||||
Ok(response) => break response.body.collect().await?.into_bytes(),
|
||||
Err(error) if Instant::now() < deadline => {
|
||||
info!("retrying degraded GET for {key}: {error}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(error) => return Err(format!("degraded object {key} failed read quorum GET: {error}").into()),
|
||||
}
|
||||
};
|
||||
assert!(!body.is_empty(), "degraded object {key} should read back at read quorum");
|
||||
}
|
||||
|
||||
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
|
||||
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||
let listed = loop {
|
||||
let listed = match list_all_keys(&client).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) if Instant::now() < deadline => {
|
||||
info!("retrying degraded listing: {error}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if expected.is_subset(&listed) {
|
||||
break listed;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"objects readable at read quorum stayed missing from ListObjectsV2 for {LISTING_DEADLINE:?}: \
|
||||
missing={:?} listed={listed:?}",
|
||||
expected.difference(&listed).collect::<Vec<_>>(),
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
};
|
||||
info!(listed = listed.len(), "degraded objects are listable while node 3 is offline");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
# Programmable fake S3 target
|
||||
|
||||
This module is the shared failure-injection boundary for replication end-to-end tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
|
||||
This module is the shared failure-injection boundary for replication end-to-end tests and the programmable external source for on-demand-migration (ODM) tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
|
||||
|
||||
`FakeS3Target::start()` creates the listener. Add target buckets with `create_bucket`, point a RustFS remote target at `address()`, use `FAKE_ACCESS_KEY` / `FAKE_SECRET_KEY`, then enqueue per-operation faults with `inject`. Faults for one operation are consumed in FIFO order and do not consume faults queued for another operation. A fault is consumed only after `s3s` verifies the full request signature, so anonymous, other-access-key, and bad-signature traffic cannot disturb a script.
|
||||
|
||||
Supported data operations are HeadBucket, GetBucketVersioning, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
|
||||
Supported data operations are HeadBucket, GetBucketVersioning, ListObjectsV2, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets created with `create_bucket` are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
|
||||
|
||||
Fault actions cover HTTP 401/403/503 responses, pre-dispatch delay, connection abort when a logical request-body threshold is reached, streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions. Each record also journals a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
|
||||
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Three switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
|
||||
|
||||
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type at 1 KiB. A PUT or uploaded part is capped at 64 MiB; a completed multipart object and all stored object/part data are capped at 128 MiB. Body drain, body-permit waits, delay, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
|
||||
`create_bucket_with_mode(name, BucketMode::Unversioned)` models a plain migration source: PUT overwrites in place, DELETE removes the key without a delete marker, GetBucketVersioning reports no status, and no `x-amz-version-id` is returned by PUT, GET, HEAD, tagging, or multipart completion. The only `versionId` such a bucket accepts is `null`; any other value is rejected with `InvalidArgument`. The mode is fixed at creation.
|
||||
|
||||
ListObjectsV2 lists current versions only (a key whose newest version is a delete marker is hidden) in byte order and supports `prefix`, `delimiter`, `max-keys` (clamped to 1000), `start-after`, and `continuation-token`; common prefixes count toward `max-keys`, `IsTruncated` / `NextContinuationToken` / `KeyCount` follow S3, and continuation tokens are opaque. `encoding-type` and `fetch-owner` are accepted but ignored, and ListObjects (v1) is not implemented. GET and HEAD honor `Range` in the `bytes=first-last`, `bytes=first-`, and `bytes=-suffix` forms with a 206 status, exact `Content-Range`, and `Accept-Ranges: bytes`; unsatisfiable ranges answer 416 `InvalidRange` with `Content-Range: bytes */<length>`. PUT and CreateMultipartUpload accept `Content-Type`, `Content-Encoding`, `Content-Disposition`, `Content-Language`, `Cache-Control`, `Expires`, and `x-amz-meta-*` (names stored lowercased), and HEAD/GET replay them verbatim together with `Last-Modified` and the ETag (hex MD5 for single PUTs, `<md5-of-part-md5s>-<parts>` for multipart objects). `put_seed_object` stores an object directly, bypassing the wire, the fault script, and the journal, so a source can be seeded without polluting the assertions a scenario later makes.
|
||||
|
||||
Fault actions cover HTTP 401/403/503 responses (`Status`), any 4xx/5xx status paired with the matching S3 error code (`ResponseStatus`), pre-dispatch delay, holding a fully computed successful response before its first byte (`Stall`), connection abort when a logical request-body threshold is reached, GetObject bodies cut off after N bytes while `Content-Length` announces the full size (`TruncateBodyAt`), GetObject bodies delivered in fixed slices with a pause between them (`SlowSendBody`, a mid-body stall rather than a first-byte one), streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions and `count_requests(operation, key)` counts entries for one exact key. Each record journals the `Range` and `User-Agent` request headers, the ListObjectsV2 `prefix` and `continuation-token` query values, a `TransportSnapshot` — whether the body was announced as `aws-chunked`, the verbatim `Content-MD5`, the sorted `x-amz-checksum-*` / `x-amz-sdk-checksum-algorithm` header names, and whether any `x-amz-object-lock-*` header was present — and a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
|
||||
|
||||
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type and each standard object header at 1 KiB. By default a PUT or uploaded part is capped at 64 MiB and a completed multipart object and all stored object/part data are capped at 128 MiB; `FakeS3Target::start_with_options(FakeS3TargetOptions { max_object_bytes })` raises the object cap up to 256 MiB, and the total budget then becomes twice the object cap (never below 128 MiB). Body drain, body-permit waits, delay, stall, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -560,18 +560,12 @@ async fn test_multipart_encryption_type(
|
||||
.set_parts(Some(completed_parts))
|
||||
.build();
|
||||
|
||||
let mut complete_request = s3_client
|
||||
let complete_request = s3_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(object_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(completed_multipart_upload);
|
||||
if matches!(encryption_type, EncryptionType::SSEC) {
|
||||
complete_request = complete_request
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(sse_c_key.as_ref().unwrap())
|
||||
.sse_customer_key_md5(sse_c_md5.as_ref().unwrap());
|
||||
}
|
||||
let _complete_output = complete_request.send().await?;
|
||||
|
||||
// Download and verify
|
||||
|
||||
@@ -23,10 +23,17 @@ pub mod common;
|
||||
#[cfg(test)]
|
||||
pub mod chaos;
|
||||
|
||||
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8).
|
||||
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
|
||||
// and on-demand-migration source scenarios (backlog#2151).
|
||||
#[cfg(test)]
|
||||
pub mod fake_s3_target;
|
||||
|
||||
// On-demand migration (backlog#2147): shared two-server environment, admin
|
||||
// wrappers, and the harness self-test (backlog#2151). Behavior scenarios are
|
||||
// added by later ODM tasks.
|
||||
#[cfg(test)]
|
||||
pub mod on_demand_migration;
|
||||
|
||||
// Socket-level network fault-injection proxy for black-box cluster tests
|
||||
// (backlog#1325 network fault-injection block): latency / blackhole / one-way
|
||||
// partition on the wire between nodes. Serves #1312/#1319 (lock-plane one-way
|
||||
@@ -73,6 +80,12 @@ mod upgrade_compatibility_test;
|
||||
#[cfg(test)]
|
||||
mod replication_lww_receiver_test;
|
||||
|
||||
// Outbound target matrix: every object shape against every remote-target
|
||||
// failure mode the fake target models (SOP:
|
||||
// docs/postmortems/2026-09-03-replication-checksum-default-regression.md).
|
||||
#[cfg(test)]
|
||||
mod replication_target_matrix_test;
|
||||
|
||||
// Data usage regression tests
|
||||
#[cfg(test)]
|
||||
mod data_usage_test;
|
||||
@@ -205,6 +218,10 @@ mod cluster_multidrive_pool_test;
|
||||
#[cfg(test)]
|
||||
mod inline_fast_path_cluster_test;
|
||||
|
||||
// backlog#2207: two-node gate for the cluster-authoritative tier stats contract.
|
||||
#[cfg(test)]
|
||||
mod tier_stats_cluster_test;
|
||||
|
||||
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
|
||||
#[cfg(test)]
|
||||
mod checksum_upload_test;
|
||||
@@ -348,6 +365,11 @@ mod delete_regression_test;
|
||||
#[cfg(test)]
|
||||
mod listing_regression_test;
|
||||
|
||||
// Cluster regression: objects committed at degraded write quorum must stay
|
||||
// listable while a different drive is offline (CI run 33478999853).
|
||||
#[cfg(test)]
|
||||
mod degraded_listing_availability_test;
|
||||
|
||||
// P1 regression: bucket statistics accuracy (rustfs#5615, #5008, #5116, #5055, #3898, #1012)
|
||||
#[cfg(test)]
|
||||
mod bucket_stats_regression_test;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! Regression coverage for anonymous access on multipart control APIs.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use async_compression::tokio::write::{BzEncoder, XzEncoder};
|
||||
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
|
||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
@@ -23,7 +23,10 @@ use aws_sdk_s3::types::{
|
||||
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
|
||||
};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use flate2::{Compression, write::GzEncoder};
|
||||
use flate2::{
|
||||
Compression,
|
||||
write::{GzEncoder, ZlibEncoder},
|
||||
};
|
||||
use http::HeaderValue;
|
||||
use http::header::{CONTENT_TYPE, HOST};
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
@@ -187,6 +190,12 @@ fn gzip_bytes(data: &[u8]) -> Vec<u8> {
|
||||
encoder.finish().expect("gzip encoder should finish")
|
||||
}
|
||||
|
||||
fn zlib_bytes(data: &[u8]) -> Vec<u8> {
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(data).expect("zlib encoder should accept input");
|
||||
encoder.finish().expect("zlib encoder should finish")
|
||||
}
|
||||
|
||||
fn zstd_bytes(data: &[u8]) -> Vec<u8> {
|
||||
let mut encoder = zstd::Encoder::new(Vec::new(), 0).expect("zstd encoder should initialize");
|
||||
encoder.write_all(data).expect("zstd encoder should accept input");
|
||||
@@ -209,6 +218,45 @@ async fn xz_bytes(data: &[u8]) -> Vec<u8> {
|
||||
encoder.into_inner().into_inner()
|
||||
}
|
||||
|
||||
async fn lz4_bytes(data: &[u8]) -> Vec<u8> {
|
||||
let cursor = Cursor::new(Vec::new());
|
||||
let mut encoder = Lz4Encoder::new(cursor);
|
||||
encoder.write_all(data).await.expect("LZ4 encoder should accept input");
|
||||
encoder.shutdown().await.expect("LZ4 encoder should finish");
|
||||
encoder.into_inner().into_inner()
|
||||
}
|
||||
|
||||
/// Encode the S2 framed stream shape emitted by minio-go PutObjectsSnowball
|
||||
/// with `Compress: true`: 1 MiB independent blocks, better compression,
|
||||
/// masked CRC-32C, and the `S2sTwO` stream identifier.
|
||||
fn minio_go_snowball_s2_bytes(data: &[u8]) -> Vec<u8> {
|
||||
const BLOCK_SIZE: usize = 1 << 20;
|
||||
const CHECKSUM_SIZE: usize = 4;
|
||||
|
||||
let mut output = b"\xff\x06\x00\x00S2sTwO".to_vec();
|
||||
let mut encoder = minlz::Encoder::new();
|
||||
for block in data.chunks(BLOCK_SIZE) {
|
||||
let compressed = encoder.encode_better(block);
|
||||
let compressed_limit = block.len().saturating_sub(block.len() / 32).saturating_sub(5);
|
||||
let (chunk_type, payload) = if compressed.len() <= compressed_limit {
|
||||
(0x00, compressed.as_slice())
|
||||
} else {
|
||||
(0x01, block)
|
||||
};
|
||||
let chunk_len = payload.len() + CHECKSUM_SIZE;
|
||||
assert!(chunk_len < 1 << 24, "S2 fixture chunk must fit the 24-bit frame length");
|
||||
output.extend_from_slice(&[
|
||||
chunk_type,
|
||||
(chunk_len & 0xff) as u8,
|
||||
((chunk_len >> 8) & 0xff) as u8,
|
||||
((chunk_len >> 16) & 0xff) as u8,
|
||||
]);
|
||||
output.extend_from_slice(&minlz::crc::crc(block).to_le_bytes());
|
||||
output.extend_from_slice(payload);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn assert_s3_error_code<T, E>(result: Result<T, SdkError<E>>, code: &str)
|
||||
where
|
||||
T: std::fmt::Debug,
|
||||
@@ -3456,6 +3504,62 @@ async fn test_signed_put_object_extract_expands_tar_entries_with_prefix_headers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_signed_put_object_extract_ignore_dirs_skips_unauthorized_directory()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let bucket = "signed-extract-ignore-dirs-auth";
|
||||
let archive_key = "bundle.tar";
|
||||
let allowed_member = "allowed/member.txt";
|
||||
let denied_directory = "denied/";
|
||||
let username = "snowball-ignore-dirs";
|
||||
let secret_key = "snowball-ignore-dirs-secret";
|
||||
let expected_body = b"allowed-body";
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
create_restricted_user(&env, username, secret_key).await?;
|
||||
|
||||
let policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [username] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [
|
||||
format!("arn:aws:s3:::{bucket}/{archive_key}"),
|
||||
format!("arn:aws:s3:::{bucket}/{allowed_member}")
|
||||
]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
admin_client.put_bucket_policy().bucket(bucket).policy(policy).send().await?;
|
||||
|
||||
let restricted_client = restricted_user_client(&env, username, secret_key);
|
||||
let tar_bytes = make_tar(&[(allowed_member, expected_body)], &[denied_directory]).await;
|
||||
restricted_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
.body(ByteStream::from(tar_bytes))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
req.headers_mut().insert("x-amz-meta-snowball-ignore-dirs", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let stored = admin_client.get_object().bucket(bucket).key(allowed_member).send().await?;
|
||||
assert_eq!(stored.body.collect().await?.into_bytes().as_ref(), expected_body);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_signed_put_object_extract_preserves_request_metadata_on_extracted_objects()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -4185,6 +4289,60 @@ async fn test_signed_put_object_extract_returns_archive_etag() -> Result<(), Box
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_signed_put_object_extract_expands_s2_and_lz4_by_magic_with_raw_etags()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let bucket = "signed-extract-magic-codecs";
|
||||
let client = env.create_s3_client();
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
let s2_tar = make_tar(&[("s2/object.txt", b"s2-body")], &[]).await;
|
||||
let s2_archive = minio_go_snowball_s2_bytes(&s2_tar);
|
||||
let expected_s2_etag = format!("\"{}\"", md5_hex(&s2_archive));
|
||||
let s2_response = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
// minio-go intentionally uploads a compressed S2 stream with a .tar key.
|
||||
.key("snowball-upload-0123456789abcdef.tar")
|
||||
.body(ByteStream::from(s2_archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(s2_response.e_tag(), Some(expected_s2_etag.as_str()));
|
||||
|
||||
let s2_object = client.get_object().bucket(bucket).key("s2/object.txt").send().await?;
|
||||
assert_eq!(s2_object.body.collect().await?.into_bytes().as_ref(), b"s2-body");
|
||||
|
||||
let lz4_tar = make_tar(&[("lz4/object.txt", b"lz4-body")], &[]).await;
|
||||
let lz4_archive = lz4_bytes(&lz4_tar).await;
|
||||
let expected_lz4_etag = format!("\"{}\"", md5_hex(&lz4_archive));
|
||||
let lz4_response = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("also-looks-like-a-plain.tar")
|
||||
.body(ByteStream::from(lz4_archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(lz4_response.e_tag(), Some(expected_lz4_etag.as_str()));
|
||||
|
||||
let lz4_object = client.get_object().bucket(bucket).key("lz4/object.txt").send().await?;
|
||||
assert_eq!(lz4_object.body.collect().await?.into_bytes().as_ref(), b"lz4-body");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_signed_put_object_extract_preserves_entry_mtime() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -4309,9 +4467,15 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
|
||||
let context_archive_resources = [
|
||||
format!("arn:aws:s3:::{bucket}/tag-context.tar"),
|
||||
format!("arn:aws:s3:::{bucket}/lock-context.tar"),
|
||||
format!("arn:aws:s3:::{bucket}/legal-hold-context.tar"),
|
||||
format!("arn:aws:s3:::{bucket}/user-agent-bypass.tar"),
|
||||
format!("arn:aws:s3:::{bucket}/sse-bypass.tar"),
|
||||
];
|
||||
let tag_entry_resource = format!("arn:aws:s3:::{bucket}/tag-context-entry.txt");
|
||||
let lock_entry_resource = format!("arn:aws:s3:::{bucket}/lock-context-entry.txt");
|
||||
let legal_hold_entry_resource = format!("arn:aws:s3:::{bucket}/legal-hold-context-entry.txt");
|
||||
let user_agent_entry_resource = format!("arn:aws:s3:::{bucket}/user-agent-bypass-entry.txt");
|
||||
let sse_entry_resource = format!("arn:aws:s3:::{bucket}/sse-bypass-entry.txt");
|
||||
let policy = serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
@@ -4371,7 +4535,7 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
|
||||
"Sid": "PaxContextArchives",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject", "s3:PutObjectRetention", "s3:PutObjectTagging"],
|
||||
"Action": ["s3:PutObject", "s3:PutObjectRetention", "s3:PutObjectLegalHold", "s3:PutObjectTagging"],
|
||||
"Resource": context_archive_resources
|
||||
},
|
||||
{
|
||||
@@ -4411,6 +4575,49 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObjectRetention"],
|
||||
"Resource": [lock_entry_resource]
|
||||
},
|
||||
{
|
||||
"Sid": "PaxLegalHoldContextPut",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [legal_hold_entry_resource.clone()]
|
||||
},
|
||||
{
|
||||
"Sid": "PaxLegalHoldContextAction",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObjectLegalHold"],
|
||||
"Resource": [legal_hold_entry_resource],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:object-lock-legal-hold": "OFF"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "MemberUserAgentCondition",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [user_agent_entry_resource],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"aws:UserAgent": "trusted"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Sid": "MemberSseCondition",
|
||||
"Effect": "Allow",
|
||||
"Principal": { "AWS": [pax_context_user] },
|
||||
"Action": ["s3:PutObject"],
|
||||
"Resource": [sse_entry_resource],
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"s3:x-amz-server-side-encryption": "AES256"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -4423,9 +4630,14 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
|
||||
let cases = [
|
||||
(
|
||||
"legal-hold.tar",
|
||||
put_only_client,
|
||||
put_only_client.clone(),
|
||||
HashMap::from([("minio.metadata.x-amz-object-lock-legal-hold", "ON".to_string())]),
|
||||
),
|
||||
(
|
||||
"tagging.tar",
|
||||
put_only_client,
|
||||
HashMap::from([("minio.metadata.x-amz-tagging", "classification=restricted".to_string())]),
|
||||
),
|
||||
(
|
||||
"retention-condition.tar",
|
||||
conditional_client,
|
||||
@@ -4512,6 +4724,57 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
|
||||
assert_eq!(stored.body.collect().await?.into_bytes().as_ref(), b"condition-body");
|
||||
|
||||
let pax_context_client = restricted_user_client(&env, pax_context_user, pax_context_secret);
|
||||
for (archive_key, entry_key, pax_key, injected_value, outer_user_agent) in [
|
||||
(
|
||||
"user-agent-bypass.tar",
|
||||
"user-agent-bypass-entry.txt",
|
||||
"minio.metadata.user-agent",
|
||||
"trusted",
|
||||
Some("untrusted"),
|
||||
),
|
||||
(
|
||||
"sse-bypass.tar",
|
||||
"sse-bypass-entry.txt",
|
||||
"minio.metadata.x-amz-server-side-encryption",
|
||||
"AES256",
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let pax = HashMap::from([(pax_key, injected_value.to_string())]);
|
||||
let archive = make_tar_with_pax_entry(entry_key, b"must-not-write", None, &pax).await;
|
||||
let err = pax_context_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(move |req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
if let Some(user_agent) = outer_user_agent {
|
||||
req.headers_mut().insert("user-agent", user_agent);
|
||||
}
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect_err("PAX metadata must not satisfy unrelated IAM request conditions");
|
||||
assert_eq!(
|
||||
err.as_service_error().and_then(|error| error.meta().code()),
|
||||
Some("AccessDenied"),
|
||||
"{archive_key}"
|
||||
);
|
||||
let err = admin_client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(entry_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a denied PAX member must not be written");
|
||||
assert!(matches!(
|
||||
err.as_service_error().and_then(|error| error.meta().code()),
|
||||
Some("NoSuchKey" | "NotFound")
|
||||
));
|
||||
}
|
||||
|
||||
let tag_pax = HashMap::from([("minio.metadata.x-amz-tagging", "classification=public".to_string())]);
|
||||
let archive = make_tar_with_pax_entry("tag-context-entry.txt", b"tag-context-body", None, &tag_pax).await;
|
||||
pax_context_client
|
||||
@@ -4575,6 +4838,34 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
|
||||
pax_retain_until
|
||||
);
|
||||
|
||||
let legal_hold_pax = HashMap::from([("minio.metadata.x-amz-object-lock-legal-hold", "ON".to_string())]);
|
||||
let archive = make_tar_with_pax_entry("legal-hold-context-entry.txt", b"must-not-write", None, &legal_hold_pax).await;
|
||||
let err = pax_context_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("legal-hold-context.tar")
|
||||
.object_lock_legal_hold_status(aws_sdk_s3::types::ObjectLockLegalHoldStatus::Off)
|
||||
.body(ByteStream::from(archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect_err("PAX legal hold must replace the outer value in the member IAM condition context");
|
||||
assert_eq!(err.as_service_error().and_then(|error| error.meta().code()), Some("AccessDenied"));
|
||||
let err = admin_client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("legal-hold-context-entry.txt")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a denied PAX legal-hold member must not be written");
|
||||
assert!(matches!(
|
||||
err.as_service_error().and_then(|error| error.meta().code()),
|
||||
Some("NoSuchKey" | "NotFound")
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5050,8 +5341,8 @@ async fn test_signed_put_object_extract_expands_tzst_archive() -> Result<(), Box
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_signed_put_object_extract_rejects_missing_archive_extension() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
async fn test_signed_put_object_extract_uses_magic_without_requiring_or_trusting_extension()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
@@ -5064,8 +5355,7 @@ async fn test_signed_put_object_extract_rejects_missing_archive_extension() -> R
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
let tar_bytes = make_tar(&[("plain.txt", b"plain-body")], &[]).await;
|
||||
|
||||
let result = admin_client
|
||||
admin_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
@@ -5075,15 +5365,80 @@ async fn test_signed_put_object_extract_rejects_missing_archive_extension() -> R
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await;
|
||||
.await?;
|
||||
|
||||
assert_s3_error_code(result, "InvalidArgument");
|
||||
let plain = admin_client.get_object().bucket(bucket).key("plain.txt").send().await?;
|
||||
assert_eq!(plain.body.collect().await?.into_bytes().as_ref(), b"plain-body");
|
||||
|
||||
let raw_with_gzip_suffix = make_tar(&[("raw-with-wrong-suffix.txt", b"raw-body")], &[]).await;
|
||||
admin_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("raw-but-named.tar.gz")
|
||||
.body(ByteStream::from(raw_with_gzip_suffix))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let raw = admin_client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("raw-with-wrong-suffix.txt")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(raw.body.collect().await?.into_bytes().as_ref(), b"raw-body");
|
||||
|
||||
let gzip_with_tar_suffix = gzip_bytes(&make_tar(&[("gzip-with-wrong-suffix.txt", b"gzip-body")], &[]).await);
|
||||
admin_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("gzip-but-named.tar")
|
||||
.body(ByteStream::from(gzip_with_tar_suffix))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let gzip = admin_client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("gzip-with-wrong-suffix.txt")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(gzip.body.collect().await?.into_bytes().as_ref(), b"gzip-body");
|
||||
|
||||
let zlib_archive = zlib_bytes(&make_tar(&[("zlib-extension.txt", b"zlib-body")], &[]).await);
|
||||
admin_client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("bundle.zlib")
|
||||
.body(ByteStream::from(zlib_archive))
|
||||
.customize()
|
||||
.mutate_request(|req| {
|
||||
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let zlib = admin_client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("zlib-extension.txt")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(zlib.body.collect().await?.into_bytes().as_ref(), b"zlib-body");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_signed_put_object_extract_rejects_invalid_tar_gz_payload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
async fn test_signed_put_object_extract_rejects_invalid_archive_payload() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
|
||||
@@ -36,9 +36,10 @@
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||
use rustfs_signer::constants::{UNSIGNED_PAYLOAD, UNSIGNED_PAYLOAD_TRAILER};
|
||||
use rustfs_signer::request_signature_v4::{SIGN_V4_ALGORITHM, get_scope, get_signature, get_signing_key};
|
||||
use std::fmt::Write as _;
|
||||
use std::io::Cursor;
|
||||
use time::macros::format_description;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::info;
|
||||
@@ -98,15 +99,37 @@ impl SigV4 {
|
||||
/// header AND folded into the canonical request — pass the hash of the
|
||||
/// body you *claim* to send, which may differ from what you actually send.
|
||||
fn sign(&self, method: &str, path: &str, canonical_query: &str, content_sha256: &str) -> SignedHeaders {
|
||||
let amz_date = amz_datetime(self.time);
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
self.sign_with_extra_headers(method, path, canonical_query, content_sha256, &[])
|
||||
}
|
||||
|
||||
let canonical_headers = format!(
|
||||
"host:{host}\nx-amz-content-sha256:{sha}\nx-amz-date:{date}\n",
|
||||
host = self.host,
|
||||
sha = content_sha256,
|
||||
date = amz_date,
|
||||
);
|
||||
/// Sign additional request headers while preserving SigV4's lowercase,
|
||||
/// lexicographically sorted canonical-header representation.
|
||||
fn sign_with_extra_headers(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
canonical_query: &str,
|
||||
content_sha256: &str,
|
||||
extra_signed_headers: &[(&str, &str)],
|
||||
) -> SignedHeaders {
|
||||
let amz_date = amz_datetime(self.time);
|
||||
let mut canonical_header_values = vec![
|
||||
("host", self.host.as_str()),
|
||||
("x-amz-content-sha256", content_sha256),
|
||||
("x-amz-date", amz_date.as_str()),
|
||||
];
|
||||
canonical_header_values.extend(extra_signed_headers.iter().copied());
|
||||
canonical_header_values.sort_unstable_by(|left, right| left.0.cmp(right.0));
|
||||
|
||||
let signed_headers = canonical_header_values
|
||||
.iter()
|
||||
.map(|(name, _)| *name)
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
let mut canonical_headers = String::new();
|
||||
for (name, value) in canonical_header_values {
|
||||
let _ = writeln!(canonical_headers, "{name}:{value}");
|
||||
}
|
||||
let canonical_request =
|
||||
format!("{method}\n{path}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{content_sha256}");
|
||||
|
||||
@@ -179,6 +202,34 @@ async fn setup(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn build_single_member_archive(
|
||||
member_key: &str,
|
||||
member_body: &[u8],
|
||||
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
let mut header = tokio_tar::Header::new_gnu();
|
||||
header.set_size(member_body.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, member_key, Cursor::new(member_body)).await?;
|
||||
Ok(builder.into_inner().await?.into_inner())
|
||||
}
|
||||
|
||||
fn sha256_base64(data: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
base64_simd::STANDARD.encode_to_string(Sha256::digest(data))
|
||||
}
|
||||
|
||||
fn encode_unsigned_aws_chunked_with_sha256_trailer(decoded: &[u8]) -> Vec<u8> {
|
||||
let checksum = sha256_base64(decoded);
|
||||
let mut encoded = format!("{:x}\r\n", decoded.len()).into_bytes();
|
||||
encoded.extend_from_slice(decoded);
|
||||
encoded.extend_from_slice(b"\r\n0\r\n");
|
||||
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}\r\n\r\n").as_bytes());
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Positive control: a correctly hand-signed request must succeed. Without
|
||||
/// this, every negative assertion below could pass for the wrong reason (a
|
||||
/// broken signer that never produces a valid signature).
|
||||
@@ -249,6 +300,128 @@ async fn tampered_signature_returns_signature_does_not_match() -> Result<(), Box
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `STREAMING-UNSIGNED-PAYLOAD-TRAILER` disables per-chunk signatures, not the
|
||||
/// seed/header SigV4 signature. A forged request must be rejected before the
|
||||
/// Snowball handler can publish any archive member.
|
||||
#[tokio::test]
|
||||
async fn snowball_streaming_unsigned_trailer_rejects_forged_signature() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
|
||||
let archive_key = "forged-streaming-snowball.tar";
|
||||
let member_key = "must-not-be-published.txt";
|
||||
let archive = build_single_member_archive(member_key, b"forged request payload").await?;
|
||||
let decoded_content_length = archive.len().to_string();
|
||||
let encoded_body = encode_unsigned_aws_chunked_with_sha256_trailer(&archive);
|
||||
let path = format!("/{BUCKET}/{archive_key}");
|
||||
|
||||
let mut signer = SigV4::new(&env);
|
||||
signer.secret_key = "wrong-secret-for-forged-streaming-request".to_string();
|
||||
let extra_signed_headers = [
|
||||
("content-encoding", "aws-chunked"),
|
||||
("x-amz-decoded-content-length", decoded_content_length.as_str()),
|
||||
("x-amz-meta-snowball-auto-extract", "true"),
|
||||
("x-amz-trailer", "x-amz-checksum-sha256"),
|
||||
];
|
||||
let headers = signer.sign_with_extra_headers("PUT", &path, "", UNSIGNED_PAYLOAD_TRAILER, &extra_signed_headers);
|
||||
|
||||
let response = local_http_client()
|
||||
.put(format!("{}{}", env.url, path))
|
||||
.header("authorization", &headers.authorization)
|
||||
.header("content-encoding", "aws-chunked")
|
||||
.header("x-amz-content-sha256", &headers.content_sha256)
|
||||
.header("x-amz-date", &headers.amz_date)
|
||||
.header("x-amz-decoded-content-length", &decoded_content_length)
|
||||
.header("x-amz-meta-snowball-auto-extract", "true")
|
||||
.header("x-amz-trailer", "x-amz-checksum-sha256")
|
||||
.body(encoded_body)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
assert_eq!(status.as_u16(), 403, "forged streaming signature must be 403, body:\n{body}");
|
||||
assert_error_code(&body, "SignatureDoesNotMatch");
|
||||
|
||||
let absent = env
|
||||
.create_s3_client()
|
||||
.get_object()
|
||||
.bucket(BUCKET)
|
||||
.key(member_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a forged streaming request must not publish a Snowball member");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Snowball must consume the complete aws-chunked body before reading the
|
||||
/// trailing checksum exported by s3s into the PutObject response.
|
||||
#[tokio::test]
|
||||
async fn snowball_streaming_unsigned_trailer_returns_sha256_checksum() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
|
||||
let archive_key = "valid-streaming-snowball.tar";
|
||||
let member_key = "streaming-checksum-member.txt";
|
||||
let member_body = b"valid streaming Snowball payload";
|
||||
let archive = build_single_member_archive(member_key, member_body).await?;
|
||||
let expected_checksum = sha256_base64(&archive);
|
||||
let decoded_content_length = archive.len().to_string();
|
||||
let encoded_body = encode_unsigned_aws_chunked_with_sha256_trailer(&archive);
|
||||
let path = format!("/{BUCKET}/{archive_key}");
|
||||
|
||||
let signer = SigV4::new(&env);
|
||||
let extra_signed_headers = [
|
||||
("content-encoding", "aws-chunked"),
|
||||
("x-amz-decoded-content-length", decoded_content_length.as_str()),
|
||||
("x-amz-meta-snowball-auto-extract", "true"),
|
||||
("x-amz-sdk-checksum-algorithm", "SHA256"),
|
||||
("x-amz-trailer", "x-amz-checksum-sha256"),
|
||||
];
|
||||
let headers = signer.sign_with_extra_headers("PUT", &path, "", UNSIGNED_PAYLOAD_TRAILER, &extra_signed_headers);
|
||||
|
||||
let response = local_http_client()
|
||||
.put(format!("{}{}", env.url, path))
|
||||
.header("authorization", &headers.authorization)
|
||||
.header("content-encoding", "aws-chunked")
|
||||
.header("x-amz-content-sha256", &headers.content_sha256)
|
||||
.header("x-amz-date", &headers.amz_date)
|
||||
.header("x-amz-decoded-content-length", &decoded_content_length)
|
||||
.header("x-amz-meta-snowball-auto-extract", "true")
|
||||
.header("x-amz-sdk-checksum-algorithm", "SHA256")
|
||||
.header("x-amz-trailer", "x-amz-checksum-sha256")
|
||||
.body(encoded_body)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let response_checksum = response
|
||||
.headers()
|
||||
.get("x-amz-checksum-sha256")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let response_body = response.text().await?;
|
||||
assert_eq!(status.as_u16(), 200, "valid streaming Snowball PUT failed, body:\n{response_body}");
|
||||
assert_eq!(response_checksum.as_deref(), Some(expected_checksum.as_str()));
|
||||
|
||||
let member = env
|
||||
.create_s3_client()
|
||||
.get_object()
|
||||
.bucket(BUCKET)
|
||||
.key(member_key)
|
||||
.send()
|
||||
.await?;
|
||||
let stored = member.body.collect().await?.into_bytes();
|
||||
assert_eq!(stored.as_ref(), member_body);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// (b) A valid AccessKeyId paired with the wrong secret key must be rejected
|
||||
/// with SignatureDoesNotMatch / 403.
|
||||
#[tokio::test]
|
||||
@@ -376,6 +549,68 @@ async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::e
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// s3s v0.16 validates the aws-chunked decoded length while RustFS consumes the
|
||||
/// body stream. Mismatches are client body errors and must not leak as 500s.
|
||||
#[tokio::test]
|
||||
async fn aws_chunked_decoded_length_mismatch_returns_incomplete_body() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
setup(&mut env).await?;
|
||||
|
||||
for (key, declared_len) in [
|
||||
("decoded-length-overrun.bin", 3_usize),
|
||||
("decoded-length-shortfall.bin", 9_usize),
|
||||
] {
|
||||
let decoded = b"decoded";
|
||||
assert_ne!(declared_len, decoded.len(), "test case must exercise a mismatch");
|
||||
let encoded_body = encode_unsigned_aws_chunked_with_sha256_trailer(decoded);
|
||||
let decoded_content_length = declared_len.to_string();
|
||||
let path = format!("/{BUCKET}/{key}");
|
||||
let signer = SigV4::new(&env);
|
||||
let extra_signed_headers = [
|
||||
("content-encoding", "aws-chunked"),
|
||||
("x-amz-decoded-content-length", decoded_content_length.as_str()),
|
||||
("x-amz-trailer", "x-amz-checksum-sha256"),
|
||||
];
|
||||
let headers = signer.sign_with_extra_headers("PUT", &path, "", UNSIGNED_PAYLOAD_TRAILER, &extra_signed_headers);
|
||||
|
||||
let response = local_http_client()
|
||||
.put(format!("{}{}", env.url, path))
|
||||
.header("authorization", &headers.authorization)
|
||||
.header("content-encoding", "aws-chunked")
|
||||
.header("x-amz-content-sha256", &headers.content_sha256)
|
||||
.header("x-amz-date", &headers.amz_date)
|
||||
.header("x-amz-decoded-content-length", &decoded_content_length)
|
||||
.header("x-amz-trailer", "x-amz-checksum-sha256")
|
||||
.body(encoded_body)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"decoded length mismatch must be a client error, body:\n{body}"
|
||||
);
|
||||
assert_error_code(&body, "IncompleteBody");
|
||||
|
||||
let absent = env
|
||||
.create_s3_client()
|
||||
.get_object()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("decoded length mismatch must not publish an object");
|
||||
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// (e) A request whose `x-amz-date` is skewed beyond the server's tolerance
|
||||
/// (s3s default 900s / 15 min) must be rejected with RequestTimeTooSkewed /
|
||||
/// 403. The signature is otherwise valid: the credential-scope date and
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! On-demand migration backfill job scenarios (ODM-12, rustfs/backlog#2159):
|
||||
//! a full backfill of a small-object source, cancellation, and resuming from
|
||||
//! the persisted continuation token after a server restart.
|
||||
|
||||
use super::common::{BackfillOp, BackfillRequest, ODM_SERVER_ENV, OdmSourceSpec, OdmTestEnv, SeedObject};
|
||||
use crate::fake_s3_target::Operation;
|
||||
use bytes::Bytes;
|
||||
use std::time::Duration;
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-backfill-source";
|
||||
const LOCAL_BUCKET: &str = "odm-backfill-local";
|
||||
const KEY_PREFIX: &str = "cold/";
|
||||
/// Keys per scenario. The fake source retains at most 4,096 object versions
|
||||
/// and 4,096 journal entries, and one pull is a HEAD plus a GET, so a
|
||||
/// scenario that asserts on the journal stays below ~2,000 keys. The job
|
||||
/// lists 1,000 keys per page, so this still spans several pages and exercises
|
||||
/// the continuation token, which is what the scenarios are about.
|
||||
const SEEDED_KEYS: usize = 1500;
|
||||
|
||||
fn key(i: usize) -> String {
|
||||
format!("{KEY_PREFIX}{i:05}")
|
||||
}
|
||||
|
||||
/// Content that identifies the key so a mis-stored object is caught.
|
||||
fn body(i: usize) -> Bytes {
|
||||
Bytes::from(format!("object-{i:05}-payload"))
|
||||
}
|
||||
|
||||
fn seed(env: &OdmTestEnv, count: usize) {
|
||||
let objects: Vec<SeedObject> = (0..count).map(|i| SeedObject::new(key(i), body(i))).collect();
|
||||
let etags = env.seed_source(SOURCE_BUCKET, &objects);
|
||||
assert_eq!(etags.len(), count);
|
||||
}
|
||||
|
||||
async fn configure(env: &OdmTestEnv, spec: &OdmSourceSpec) -> TestResult {
|
||||
let response = env.configure_source(LOCAL_BUCKET, spec).await?;
|
||||
assert_eq!(response.status, 200, "configure: {}", response.body);
|
||||
// The probe issued a one-key listing; count only the job's traffic.
|
||||
env.source.take_requests();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn source_lists(env: &OdmTestEnv) -> Vec<Option<String>> {
|
||||
env.source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|record| record.operation == Operation::ListObjectsV2)
|
||||
.map(|record| record.continuation_token)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_gets(env: &OdmTestEnv) -> usize {
|
||||
env.source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|record| record.operation == Operation::GetObject)
|
||||
.count()
|
||||
}
|
||||
|
||||
fn counter(job: &serde_json::Value, name: &str) -> u64 {
|
||||
job[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {job}"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_pulls_every_source_object_across_list_pages() -> TestResult {
|
||||
const COUNT: usize = SEEDED_KEYS;
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket(SOURCE_BUCKET);
|
||||
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
|
||||
seed(&env, COUNT);
|
||||
configure(&env, &env.fake_source_spec(SOURCE_BUCKET)).await?;
|
||||
|
||||
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
|
||||
assert_eq!(started.status, 200, "start: {}", started.body);
|
||||
let job = started.json()?["job"].clone();
|
||||
assert_eq!(job["state"], "running");
|
||||
assert_eq!(job["skip_existing"], "always");
|
||||
assert_eq!(job["dry_run"], false);
|
||||
let job_id = job["job_id"].as_str().expect("job id").to_string();
|
||||
|
||||
// A second start while the job holds its lease is a conflict.
|
||||
let again = env
|
||||
.backfill(LOCAL_BUCKET, BackfillOp::Start(BackfillRequest::default()))
|
||||
.await?;
|
||||
assert_eq!(again.status, 409, "second start: {}", again.body);
|
||||
assert!(again.body.contains("OnDemandMigrationBackfillRunning"), "{}", again.body);
|
||||
|
||||
let done = env
|
||||
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(240), |job| job["state"] == "completed")
|
||||
.await?;
|
||||
assert_eq!(done["job_id"], job_id.as_str());
|
||||
assert_eq!(counter(&done, "listed"), COUNT as u64);
|
||||
assert_eq!(counter(&done, "enqueued"), COUNT as u64);
|
||||
assert_eq!(counter(&done, "pulled"), COUNT as u64);
|
||||
assert_eq!(counter(&done, "failed"), 0);
|
||||
assert_eq!(counter(&done, "skipped_existing"), 0);
|
||||
assert!(done["continuation_token"].is_null(), "a finished job carries no cursor");
|
||||
assert_eq!(done["last_key"], key(COUNT - 1));
|
||||
assert!(done["failed_keys"].as_array().is_some_and(Vec::is_empty));
|
||||
let expected_bytes: u64 = (0..COUNT).map(|i| body(i).len() as u64).sum();
|
||||
assert_eq!(counter(&done, "bytes"), expected_bytes);
|
||||
|
||||
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
|
||||
for i in [0, 999, 1000, 1200, COUNT - 1] {
|
||||
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
|
||||
}
|
||||
|
||||
let lists = source_lists(&env);
|
||||
assert_eq!(lists.len(), COUNT.div_ceil(1000), "{COUNT} keys at 1000 per page: {lists:?}");
|
||||
assert!(lists[0].is_none(), "the first page starts without a cursor");
|
||||
assert!(lists[1..].iter().all(Option::is_some), "every later page carries the cursor");
|
||||
assert_eq!(source_gets(&env), COUNT, "every object is fetched exactly once");
|
||||
|
||||
// The status endpoint summarises the same job.
|
||||
let status = env.status(LOCAL_BUCKET).await?;
|
||||
assert_eq!(status.status, 200);
|
||||
let summary = status.json()?["backfill"].clone();
|
||||
assert_eq!(summary["job_id"], job_id.as_str());
|
||||
assert_eq!(summary["state"], "completed");
|
||||
assert_eq!(counter(&summary, "pulled"), COUNT as u64);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_cancel_stops_enqueueing_and_persists_cancelled() -> TestResult {
|
||||
const COUNT: usize = SEEDED_KEYS;
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket(SOURCE_BUCKET);
|
||||
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
|
||||
seed(&env, COUNT);
|
||||
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
spec.policy.max_concurrent_pulls = 1;
|
||||
configure(&env, &spec).await?;
|
||||
|
||||
// Cancelling before any job exists is a 404, not a silent success.
|
||||
let nothing = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
|
||||
assert_eq!(nothing.status, 404, "cancel without a job: {}", nothing.body);
|
||||
assert!(nothing.body.contains("NoSuchBackfillJob"), "{}", nothing.body);
|
||||
let unread = env.backfill(LOCAL_BUCKET, BackfillOp::Status).await?;
|
||||
assert_eq!(unread.status, 404, "status without a job: {}", unread.body);
|
||||
|
||||
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
|
||||
assert_eq!(started.status, 200, "start: {}", started.body);
|
||||
env.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(60), |job| {
|
||||
job["state"] == "running" && counter(job, "enqueued") > 0
|
||||
})
|
||||
.await?;
|
||||
|
||||
let cancelled = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
|
||||
assert_eq!(cancelled.status, 200, "cancel: {}", cancelled.body);
|
||||
let job = cancelled.json()?["job"].clone();
|
||||
assert_eq!(job["state"], "cancelled");
|
||||
let enqueued_at_cancel = counter(&job, "enqueued");
|
||||
assert!(enqueued_at_cancel < COUNT as u64, "the job was cancelled mid-way: {job}");
|
||||
|
||||
// Nothing is queued after the cancel: the checkpoint and the source
|
||||
// traffic both stop moving once the few in-flight pulls drain.
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let persisted = env.backfill_job(LOCAL_BUCKET).await?.expect("checkpoint kept for inspection");
|
||||
assert_eq!(persisted["state"], "cancelled");
|
||||
assert_eq!(counter(&persisted, "enqueued"), enqueued_at_cancel);
|
||||
let gets_after_drain = source_gets(&env);
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
assert_eq!(source_gets(&env), gets_after_drain, "no source GET after the cancel drained");
|
||||
assert!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await? < COUNT);
|
||||
|
||||
// Cancel is idempotent and the status endpoint reports the final state.
|
||||
let again = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
|
||||
assert_eq!(again.status, 200, "second cancel: {}", again.body);
|
||||
assert_eq!(again.json()?["job"]["state"], "cancelled");
|
||||
let status = env.status(LOCAL_BUCKET).await?;
|
||||
assert_eq!(status.json()?["backfill"]["state"], "cancelled");
|
||||
|
||||
// A cancelled job releases the bucket: a new job can start.
|
||||
let restarted = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
|
||||
assert_eq!(restarted.status, 200, "restart after cancel: {}", restarted.body);
|
||||
assert_ne!(restarted.json()?["job"]["job_id"], job["job_id"]);
|
||||
let _ = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_resumes_from_continuation_token_after_restart() -> TestResult {
|
||||
const COUNT: usize = SEEDED_KEYS;
|
||||
let mut env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket(SOURCE_BUCKET);
|
||||
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
|
||||
seed(&env, COUNT);
|
||||
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
spec.policy.max_concurrent_pulls = 2;
|
||||
configure(&env, &spec).await?;
|
||||
|
||||
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
|
||||
assert_eq!(started.status, 200, "start: {}", started.body);
|
||||
let job_id = started.json()?["job"]["job_id"].as_str().expect("job id").to_string();
|
||||
|
||||
// Wait for the first page to be committed (cursor persisted), then kill
|
||||
// the server while the job is still running.
|
||||
let mid = env
|
||||
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(120), |job| {
|
||||
job["state"] == "running" && job["continuation_token"].is_string()
|
||||
})
|
||||
.await?;
|
||||
assert!(counter(&mid, "listed") >= 1000 && counter(&mid, "listed") < COUNT as u64, "{mid}");
|
||||
env.source.take_requests();
|
||||
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
|
||||
|
||||
let done = env
|
||||
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(300), |job| job["state"] == "completed")
|
||||
.await?;
|
||||
assert_eq!(done["job_id"], job_id.as_str(), "the same job continues after the restart");
|
||||
assert_eq!(counter(&done, "failed"), 0);
|
||||
assert!(
|
||||
counter(&done, "listed") >= COUNT as u64,
|
||||
"the resumed job listed the rest (the interrupted page is listed twice): {done}"
|
||||
);
|
||||
// Keys pulled before the crash are re-listed and skipped, never re-pulled;
|
||||
// a pull whose report died with the old process is counted neither way,
|
||||
// so only the lower bound and the queue accounting are exact.
|
||||
assert!(counter(&done, "pulled") + counter(&done, "skipped_existing") >= COUNT as u64, "{done}");
|
||||
assert!(counter(&done, "pulled") <= counter(&done, "enqueued"), "{done}");
|
||||
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
|
||||
for i in [0, 500, 999, 1000, COUNT - 1] {
|
||||
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
|
||||
}
|
||||
|
||||
let lists = source_lists(&env);
|
||||
assert!(!lists.is_empty(), "the resumed job listed the source");
|
||||
assert!(
|
||||
lists.iter().all(Option::is_some),
|
||||
"after the restart every source listing carries a continuation-token: {lists:?}"
|
||||
);
|
||||
assert!(
|
||||
lists.len() <= COUNT.div_ceil(1000),
|
||||
"the listing did not start over from the first page: {lists:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Concurrency limits of on-demand migration (rustfs/backlog#2158):
|
||||
//! single-flight on one key, the `max_concurrent_pulls` ceiling, and a full
|
||||
//! background pull queue.
|
||||
//!
|
||||
//! The point of each case is what the source is spared, so the source
|
||||
//! journal (`count_requests`) carries the assertion in every one of them.
|
||||
|
||||
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
|
||||
use crate::fake_s3_target::Operation;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use bytes::Bytes;
|
||||
use std::time::Duration;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-concurrency-source";
|
||||
/// Background pulls land after the response that queued them.
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
fn source_get_count(env: &OdmTestEnv, key: &str) -> usize {
|
||||
env.source.count_requests(Operation::GetObject, key)
|
||||
}
|
||||
|
||||
/// Case 9: 32 concurrent misses on one key coalesce into a single-flight
|
||||
/// pull. At most two source GETs are allowed: the leader plus one follower
|
||||
/// that gave up waiting and streamed through.
|
||||
#[tokio::test]
|
||||
async fn test_odm_concurrent_misses_on_one_key_coalesce() -> TestResult {
|
||||
let bucket = "odm-concurrency-singleflight";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
env.client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let key = "singleflight/asset.bin";
|
||||
let body = payload(512 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let responses: Vec<RawResponse> = futures::future::try_join_all((0..32).map(|_| env.raw_get(bucket, key))).await?;
|
||||
for (index, response) in responses.iter().enumerate() {
|
||||
assert_eq!(response.status, 200, "reader {index}: {}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body, "reader {index} received different bytes");
|
||||
}
|
||||
|
||||
let source_gets = source_get_count(&env, key);
|
||||
assert!(
|
||||
(1..=2).contains(&source_gets),
|
||||
"32 concurrent misses must not become {source_gets} source GETs"
|
||||
);
|
||||
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the leader stores the object");
|
||||
env.assert_local_present(bucket, key, &body).await;
|
||||
let versions = env.client.list_object_versions().bucket(bucket).prefix(key).send().await?;
|
||||
assert_eq!(
|
||||
versions.versions().len(),
|
||||
1,
|
||||
"the coalesced pull commits exactly one version: {:?}",
|
||||
versions.versions()
|
||||
);
|
||||
assert_eq!(
|
||||
source_get_count(&env, key),
|
||||
source_gets,
|
||||
"nothing pulls the object again once it is local"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 10: 64 misses on distinct keys never exceed `max_concurrent_pulls`
|
||||
/// in flight, and all of them eventually land.
|
||||
#[tokio::test]
|
||||
async fn test_odm_concurrent_pulls_respect_the_configured_ceiling() -> TestResult {
|
||||
let bucket = "odm-concurrency-ceiling";
|
||||
const MAX_CONCURRENT_PULLS: u32 = 4;
|
||||
const KEYS: usize = 64;
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.max_concurrent_pulls = MAX_CONCURRENT_PULLS;
|
||||
})
|
||||
.await?;
|
||||
|
||||
let body = payload(256 * 1024);
|
||||
let keys: Vec<String> = (0..KEYS).map(|index| format!("ceiling/object-{index:03}.bin")).collect();
|
||||
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
|
||||
env.seed_source(SOURCE_BUCKET, &seeds);
|
||||
|
||||
let reads = futures::future::try_join_all(keys.iter().map(|key| env.raw_get(bucket, key)));
|
||||
let (responses, peak_inflight) = env.peak_inflight_pulls(bucket, reads).await?;
|
||||
let responses = responses?;
|
||||
for (key, response) in keys.iter().zip(&responses) {
|
||||
assert_eq!(response.status, 200, "{key}: {}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body, "{key} received different bytes");
|
||||
}
|
||||
assert!(
|
||||
peak_inflight <= u64::from(MAX_CONCURRENT_PULLS),
|
||||
"in-flight pulls peaked at {peak_inflight}, above the configured {MAX_CONCURRENT_PULLS}"
|
||||
);
|
||||
assert!(
|
||||
peak_inflight >= 1,
|
||||
"the poll never observed a pull in flight, so the ceiling assertion proves nothing"
|
||||
);
|
||||
|
||||
for key in &keys {
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{key} must be stored locally");
|
||||
assert_eq!(source_get_count(&env, key), 1, "{key} is pulled exactly once");
|
||||
}
|
||||
assert_eq!(env.status_counter(bucket, "/inflight_pulls").await?, 0, "every pull slot is released");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 11: with a small background queue, a burst of Range reads overflows
|
||||
/// it. The overflow is counted and dropped, never turned into a client
|
||||
/// failure: every reader still gets its 206 from the source.
|
||||
#[tokio::test]
|
||||
async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients() -> TestResult {
|
||||
let bucket = "odm-concurrency-queue-full";
|
||||
const REQUESTS: usize = 100;
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.pull_queue_capacity = 8;
|
||||
spec.policy.max_concurrent_pulls = 1;
|
||||
})
|
||||
.await?;
|
||||
|
||||
let body = payload(128 * 1024);
|
||||
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
|
||||
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
|
||||
env.seed_source(SOURCE_BUCKET, &seeds);
|
||||
|
||||
let responses: Vec<RawResponse> = futures::future::try_join_all(
|
||||
keys.iter()
|
||||
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
|
||||
)
|
||||
.await?;
|
||||
for (key, response) in keys.iter().zip(&responses) {
|
||||
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body.slice(0..1024), "{key} served the wrong range");
|
||||
assert_eq!(
|
||||
response.header("content-range"),
|
||||
Some(format!("bytes 0-1023/{}", body.len()).as_str()),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
|
||||
let queue_full = env
|
||||
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
|
||||
.await?;
|
||||
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
|
||||
|
||||
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
|
||||
assert!(
|
||||
ranged_reads >= REQUESTS,
|
||||
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
|
||||
);
|
||||
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
|
||||
assert!(
|
||||
dropped > 0,
|
||||
"the overflowed keys are the ones with no backfill GET, but every key got one"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Source-failure scenarios for on-demand migration (rustfs/backlog#2158):
|
||||
//! access denied, the circuit breaker, first-byte and mid-body stream
|
||||
//! failures (a cut body and a stalled one), ETag integrity, the negative
|
||||
//! cache, and an unsupported (SSE-C) source object.
|
||||
//!
|
||||
//! Every case asserts what the source was asked for, not only what the
|
||||
//! client received: a fault that silently turned into a second source
|
||||
//! request would otherwise pass.
|
||||
|
||||
use super::common::{BoxError, OdmTestEnv, SeedObject, start_configured_env};
|
||||
use crate::fake_s3_target::{FaultAction, Operation};
|
||||
use bytes::Bytes;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-fault-source";
|
||||
/// Header the GET/HEAD paths add when the answer came from the source.
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// Status of the `SourceUnavailable` error the `propagate` policy returns.
|
||||
const SOURCE_UNAVAILABLE_STATUS: u16 = 424;
|
||||
/// Background pulls and their counters land after the response.
|
||||
const SETTLE: Duration = Duration::from_secs(60);
|
||||
/// Consecutive counted source failures that open the breaker
|
||||
/// (`BREAKER_FAILURE_THRESHOLD` in ecstore).
|
||||
const BREAKER_FAILURE_THRESHOLD: usize = 5;
|
||||
|
||||
/// Position-dependent payload so a misaligned or truncated copy is caught.
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
/// A source object with a well-formed but deliberately wrong single-part
|
||||
/// ETag: the fake source retains `x-rustfs-source-etag` verbatim, so HEAD
|
||||
/// and GET advertise an MD5 the body does not have.
|
||||
async fn seed_with_etag(env: &OdmTestEnv, key: &str, body: Bytes, etag: &str) -> TestResult {
|
||||
let response = env
|
||||
.source_client()
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(body))
|
||||
.customize()
|
||||
.mutate_request({
|
||||
let etag = etag.to_string();
|
||||
move |request| {
|
||||
request.headers_mut().insert("x-rustfs-source-etag", etag.clone());
|
||||
}
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
response.e_tag(),
|
||||
Some(format!("\"{etag}\"").as_str()),
|
||||
"the fake source stores the announced ETag"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A source object that reports SSE-C: the fake source echoes the customer
|
||||
/// algorithm it captured from the replication passthrough transport header.
|
||||
async fn seed_with_ssec(env: &OdmTestEnv, key: &str, body: Bytes) -> TestResult {
|
||||
env.source_client()
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(body))
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("x-rustfs-replication-ssec-algorithm", "AES256");
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 1: a 403 from the source is a configuration error, not a health
|
||||
/// signal. `propagate` answers 424 and records the class; `not_found` hides
|
||||
/// it as a 404. Neither counts toward the breaker.
|
||||
#[tokio::test]
|
||||
async fn test_odm_source_access_denied_propagates_without_opening_the_breaker() -> TestResult {
|
||||
let propagating = "odm-fault-denied-propagate";
|
||||
let hiding = "odm-fault-denied-notfound";
|
||||
let env = start_configured_env(propagating, SOURCE_BUCKET, |_| {}).await?;
|
||||
let mut hiding_spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
hiding_spec.policy.source_error = "not_found".to_string();
|
||||
env.configure_and_wait(hiding, &hiding_spec).await?;
|
||||
|
||||
let propagate_key = "denied/propagate.bin";
|
||||
let hidden_key = "denied/hidden.bin";
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new(propagate_key, payload(4096)),
|
||||
SeedObject::new(hidden_key, payload(4096)),
|
||||
],
|
||||
);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::HeadObject, propagate_key, FaultAction::ResponseStatus(403), 1);
|
||||
let denied = env.raw_get(propagating, propagate_key).await?;
|
||||
assert_eq!(denied.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&denied.body));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&denied.body).contains("SourceUnavailable"),
|
||||
"the propagated error names the ODM source code: {}",
|
||||
String::from_utf8_lossy(&denied.body)
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, propagate_key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, propagate_key),
|
||||
0,
|
||||
"a denied HEAD never reaches the body"
|
||||
);
|
||||
|
||||
let status = env.status_json(propagating).await?;
|
||||
assert_eq!(
|
||||
status.pointer("/last_source_error/class").and_then(|v| v.as_str()),
|
||||
Some("access_denied"),
|
||||
"{status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status.pointer("/breaker/state").and_then(|v| v.as_str()),
|
||||
Some("closed"),
|
||||
"a configuration error must not open the breaker: {status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/requests_total/get/source_error")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(1),
|
||||
"{status}"
|
||||
);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::HeadObject, hidden_key, FaultAction::ResponseStatus(403), 1);
|
||||
let hidden = env.raw_get(hiding, hidden_key).await?;
|
||||
assert_eq!(hidden.status, 404, "{}", String::from_utf8_lossy(&hidden.body));
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, hidden_key), 1);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, hidden_key), 0);
|
||||
|
||||
env.assert_local_absent(propagating, propagate_key).await;
|
||||
env.assert_local_absent(hiding, hidden_key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 2: repeated transport failures open the breaker; while it is open
|
||||
/// the source is not touched at all, and the half-open probe after the open
|
||||
/// window closes it again. The open window is a compiled-in 30 s constant
|
||||
/// (`BREAKER_OPEN_DURATION`), so this case waits in real time.
|
||||
///
|
||||
/// The source client disables SDK retries, so one logical source call is
|
||||
/// exactly one wire request: the script is exactly as deep as the number of
|
||||
/// breaker failures it has to produce, and the scripted fault count and the
|
||||
/// observed source request count must agree.
|
||||
#[tokio::test]
|
||||
async fn test_odm_repeated_source_errors_open_the_breaker_and_recover() -> TestResult {
|
||||
let bucket = "odm-fault-breaker";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let key = "breaker/doc.bin";
|
||||
let body = payload(8192);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::HeadObject, key, FaultAction::ResponseStatus(503), BREAKER_FAILURE_THRESHOLD);
|
||||
for attempt in 1..=BREAKER_FAILURE_THRESHOLD {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(
|
||||
response.status,
|
||||
SOURCE_UNAVAILABLE_STATUS,
|
||||
"attempt {attempt}: {}",
|
||||
String::from_utf8_lossy(&response.body)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
env.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/breaker/state")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("open"),
|
||||
"the threshold of consecutive source failures must open the breaker"
|
||||
);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
BREAKER_FAILURE_THRESHOLD,
|
||||
"every counted failure is exactly one source request"
|
||||
);
|
||||
|
||||
// With the script cleared, the only thing that can still fail a read is
|
||||
// the open breaker itself.
|
||||
env.source.clear_faults();
|
||||
let source_requests = env.source.count_requests(Operation::HeadObject, key);
|
||||
let rejected = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(rejected.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&rejected.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
source_requests,
|
||||
"an open breaker never touches the source"
|
||||
);
|
||||
assert!(
|
||||
env.status_counter(bucket, "/counters/requests_total/get/breaker_open")
|
||||
.await?
|
||||
>= 1,
|
||||
"the rejected request is counted as breaker_open"
|
||||
);
|
||||
|
||||
// Half-open admits exactly one probe once the open window elapses.
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
let recovered = loop {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
if response.status == 200 {
|
||||
break response;
|
||||
}
|
||||
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS);
|
||||
assert!(Instant::now() < deadline, "the breaker never left the open state");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
};
|
||||
assert_eq!(recovered.body, body, "the recovered read serves the source bytes");
|
||||
assert_eq!(recovered.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
source_requests + 1,
|
||||
"only the half-open probe reached the source"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
|
||||
assert_eq!(
|
||||
env.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/breaker/state")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("closed"),
|
||||
"a successful probe closes the breaker"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 3: a source that holds the response past `first_byte_ms` is a
|
||||
/// timeout, and the client never sees a 200 head. One logical source call is
|
||||
/// one wire request, so a single scripted stall is enough to fail the read.
|
||||
#[tokio::test]
|
||||
async fn test_odm_source_stall_times_out_before_the_first_byte() -> TestResult {
|
||||
let bucket = "odm-fault-stall";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.source_timeout.first_byte_ms = 500;
|
||||
})
|
||||
.await?;
|
||||
let key = "stall/doc.bin";
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(4096))]);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::HeadObject, key, FaultAction::Stall(Duration::from_secs(5)), 1);
|
||||
let started = Instant::now();
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
let elapsed = started.elapsed();
|
||||
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
1,
|
||||
"the stalled HEAD is the only source request"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"the read timeout must cut the attempt short, took {elapsed:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
0,
|
||||
"a timed-out HEAD never starts a body read"
|
||||
);
|
||||
assert_eq!(
|
||||
env.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/last_source_error/class")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("timeout"),
|
||||
);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 4: the source cuts the body of an inline pull. The client sees a
|
||||
/// short read, nothing is stored, and no multipart upload is left behind.
|
||||
#[tokio::test]
|
||||
async fn test_odm_inline_pull_aborts_when_the_source_body_is_cut() -> TestResult {
|
||||
let bucket = "odm-fault-inline-cut";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let key = "cut/inline.bin";
|
||||
let body = payload(256 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
env.source
|
||||
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(1024), 1);
|
||||
// The client sees a transport failure while reading the body: the
|
||||
// announced Content-Length is never delivered.
|
||||
env.raw_get(bucket, key)
|
||||
.await
|
||||
.expect_err("a cut source body must not read back as a complete object");
|
||||
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"an aborted inline pull is not retried on the same request"
|
||||
);
|
||||
// Give a stray background pull time to appear before asserting absence.
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
|
||||
assert!(
|
||||
uploads.uploads().is_empty(),
|
||||
"an aborted pull leaves no multipart upload: {:?}",
|
||||
uploads.uploads()
|
||||
);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"nothing re-reads the source afterwards"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 5: the source answers, sends part of the body and then goes quiet
|
||||
/// for longer than `source_timeout.idle_ms`. The inline tee must end both
|
||||
/// ends: the client gets a short read rather than a silently truncated 200,
|
||||
/// the pull is counted as a source timeout, and nothing (object or multipart
|
||||
/// upload) is left behind locally.
|
||||
#[tokio::test]
|
||||
async fn test_odm_inline_pull_aborts_when_the_source_body_stalls() -> TestResult {
|
||||
let bucket = "odm-fault-inline-stall";
|
||||
const IDLE_MS: u64 = 1_000;
|
||||
let idle = Duration::from_millis(IDLE_MS);
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.source_timeout.idle_ms = IDLE_MS;
|
||||
})
|
||||
.await?;
|
||||
let key = "stall/inline.bin";
|
||||
let body = payload(256 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
// The head and the first slice arrive at once; the source then pauses for
|
||||
// four times the idle budget, which is what a stalled source looks like.
|
||||
env.source.inject_for_key(
|
||||
Operation::GetObject,
|
||||
key,
|
||||
FaultAction::SlowSendBody {
|
||||
chunk_bytes: 32 * 1024,
|
||||
delay: idle * 4,
|
||||
},
|
||||
1,
|
||||
);
|
||||
let started = Instant::now();
|
||||
env.raw_get(bucket, key)
|
||||
.await
|
||||
.expect_err("a stalled source body must not read back as a complete object");
|
||||
let elapsed = started.elapsed();
|
||||
assert!(
|
||||
elapsed < idle * 4,
|
||||
"the idle budget, not the source's own pause, must end the read (took {elapsed:?})"
|
||||
);
|
||||
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"an aborted inline pull is not retried on the same request"
|
||||
);
|
||||
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/source_timeout", 1, SETTLE)
|
||||
.await?;
|
||||
// The leader releases its slot just after it records the failure.
|
||||
let deadline = Instant::now() + SETTLE;
|
||||
loop {
|
||||
let inflight = env
|
||||
.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/inflight_pulls")
|
||||
.and_then(|value| value.as_u64());
|
||||
if inflight == Some(0) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "the aborted pull never released its slot: {inflight:?}");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
|
||||
assert!(
|
||||
uploads.uploads().is_empty(),
|
||||
"a stalled pull leaves no multipart upload: {:?}",
|
||||
uploads.uploads()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 6: the background pull of a large object hits a cut body, counts the
|
||||
/// failure, and the retry stores the object.
|
||||
#[tokio::test]
|
||||
async fn test_odm_background_pull_retries_a_truncated_source_body() -> TestResult {
|
||||
let bucket = "odm-fault-background-cut";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| spec.policy.inline_max_bytes = 4096).await?;
|
||||
let key = "cut/background.bin";
|
||||
let body = payload(512 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
// The faults are consumed in order by the two GETs the large-object path
|
||||
// makes: the passthrough that answers the client (unaffected), then the
|
||||
// background pull (cut).
|
||||
env.source
|
||||
.inject_for_key(Operation::GetObject, key, FaultAction::Delay(Duration::ZERO), 1);
|
||||
env.source
|
||||
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(2048), 1);
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body, "the passthrough is unaffected by the pull's fault");
|
||||
|
||||
// The cut body ends the pull attempt as a retryable source transport
|
||||
// failure; the retry stores the object, so the pull as a whole succeeds
|
||||
// and no failure is counted (only a pull that gives up is).
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the retry must store the object");
|
||||
env.assert_local_present(bucket, key, &body).await;
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
3,
|
||||
"one passthrough, one cut pull, one successful retry"
|
||||
);
|
||||
let status = env.status_json(bucket).await?;
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/pulled_objects_total/background")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(1),
|
||||
"{status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/pull_failures_total")
|
||||
.and_then(|failures| failures.as_object())
|
||||
.map(|failures| failures.values().filter_map(serde_json::Value::as_u64).sum::<u64>()),
|
||||
Some(0),
|
||||
"a retried attempt is not a failed pull: {status}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 7: the source advertises an ETag its bytes do not match. The client
|
||||
/// still gets every byte; the write-back is discarded as an integrity
|
||||
/// failure and nothing is stored.
|
||||
#[tokio::test]
|
||||
async fn test_odm_wrong_source_etag_discards_the_write_back() -> TestResult {
|
||||
let bucket = "odm-fault-etag";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let key = "etag/mismatch.bin";
|
||||
let body = payload(64 * 1024);
|
||||
seed_with_etag(&env, key, body.clone(), "0123456789abcdef0123456789abcdef").await?;
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(response.body, body, "the client receives the complete source bytes");
|
||||
|
||||
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/etag_mismatch", 1, SETTLE)
|
||||
.await?;
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"a discarded write-back is not re-read"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 8: a source miss is remembered for `negative_cache_ttl_secs`, and
|
||||
/// re-checked once the entry expires.
|
||||
#[tokio::test]
|
||||
async fn test_odm_source_not_found_is_negative_cached_for_the_ttl() -> TestResult {
|
||||
let bucket = "odm-fault-negative-cache";
|
||||
let ttl = Duration::from_secs(3);
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.negative_cache_ttl_secs = ttl.as_secs();
|
||||
})
|
||||
.await?;
|
||||
let key = "negative/nowhere.bin";
|
||||
|
||||
for attempt in 1..=10 {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 404, "attempt {attempt}: {}", String::from_utf8_lossy(&response.body));
|
||||
}
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
1,
|
||||
"nine of the ten misses stop at the negative cache"
|
||||
);
|
||||
assert!(
|
||||
env.status_counter(bucket, "/counters/requests_total/get/negative_cached")
|
||||
.await?
|
||||
>= 9,
|
||||
"the cached misses are counted"
|
||||
);
|
||||
|
||||
tokio::time::sleep(ttl + Duration::from_secs(2)).await;
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 404);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
2,
|
||||
"an expired entry re-checks the source once"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 9: an SSE-C source object cannot be migrated (the key belongs to the
|
||||
/// source's client), so the read fails as unsupported without a body read.
|
||||
#[tokio::test]
|
||||
async fn test_odm_ssec_source_object_is_unsupported() -> TestResult {
|
||||
let bucket = "odm-fault-ssec";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let key = "ssec/secret.bin";
|
||||
seed_with_ssec(&env, key, payload(4096)).await?;
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
0,
|
||||
"an unsupported object is rejected on the HEAD"
|
||||
);
|
||||
assert_eq!(
|
||||
env.status_json(bucket)
|
||||
.await?
|
||||
.pointer("/counters/requests_total/get/unsupported")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(1),
|
||||
);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Basic read-through scenarios (rustfs/backlog#2156): inline pull and
|
||||
//! local persistence, large-object passthrough with background backfill,
|
||||
//! Range passthrough, source 404, `versionId` reads, a disabled bucket, and
|
||||
//! the HEAD passthrough that stores nothing (rustfs/backlog#2155).
|
||||
//! Every source-side expectation is asserted on the fake source's journal.
|
||||
|
||||
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject};
|
||||
use crate::fake_s3_target::{BucketMode, Operation};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use bytes::Bytes;
|
||||
use std::time::Duration;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-get-source";
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// Background pulls run after the response; generous for a loaded CI host.
|
||||
const BACKFILL_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Position-dependent payload so a misaligned or truncated copy is caught.
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
/// RustFS with `local_bucket` migrating from `SOURCE_BUCKET` on the fake
|
||||
/// source (unversioned, like a plain migration source); `adjust` tweaks the
|
||||
/// policy before it is installed. Returns once the runtime consults the
|
||||
/// source.
|
||||
async fn configured_env(local_bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
|
||||
env.rustfs.create_test_bucket(local_bucket).await?;
|
||||
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
adjust(&mut spec);
|
||||
let response = env.configure_source(local_bucket, &spec).await?;
|
||||
assert_eq!(response.status, 200, "configure on-demand migration: {}", response.body);
|
||||
env.wait_until_source_consulted(local_bucket).await?;
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
fn source_get_ranges(env: &OdmTestEnv, key: &str) -> Vec<Option<String>> {
|
||||
env.source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|record| record.operation == Operation::GetObject && record.key.as_deref() == Some(key))
|
||||
.map(|record| record.range)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_miss_pulls_inline_and_serves_locally_afterwards() -> TestResult {
|
||||
let bucket = "odm-get-inline";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "inline/report.bin";
|
||||
let body = payload(200 * 1024);
|
||||
let etag = env
|
||||
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
|
||||
.remove(0);
|
||||
let quoted_etag = format!("\"{etag}\"");
|
||||
|
||||
let first = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
|
||||
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
|
||||
assert_eq!(first.header("etag"), Some(quoted_etag.as_str()), "inline answers carry the source ETag");
|
||||
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert_eq!(first.header("accept-ranges"), Some("bytes"));
|
||||
assert_eq!(first.body, body, "the client receives the source bytes");
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "exactly one source GET");
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
|
||||
assert!(
|
||||
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
|
||||
"the inline pull must store the object locally"
|
||||
);
|
||||
let second = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(second.status, 200);
|
||||
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
|
||||
assert_eq!(second.body, body, "the local copy is the source bytes");
|
||||
assert_eq!(second.header("etag"), Some(quoted_etag.as_str()), "preserve_etag keeps the source ETag");
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"the second GET is served locally"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_large_object_streams_through_and_backfills_in_background() -> TestResult {
|
||||
let bucket = "odm-get-large";
|
||||
let env = configured_env(bucket, |spec| spec.policy.inline_max_bytes = 4096).await?;
|
||||
let key = "large/archive.bin";
|
||||
let body = payload(512 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(response.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert_eq!(response.body, body, "the passthrough streams the whole object");
|
||||
|
||||
assert!(
|
||||
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
|
||||
"the background pull must store the object locally"
|
||||
);
|
||||
env.assert_local_present(bucket, key, &body).await;
|
||||
assert_eq!(
|
||||
source_get_ranges(&env, key),
|
||||
vec![None, None],
|
||||
"one passthrough GET plus one background pull, both unranged"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_range_streams_206_and_backfills_the_whole_object() -> TestResult {
|
||||
let bucket = "odm-get-range";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "range/video.bin";
|
||||
let body = payload(100_000);
|
||||
let etag = env
|
||||
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
|
||||
.remove(0);
|
||||
|
||||
let response = env
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.range("bytes=10-19")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.content_range(), Some("bytes 10-19/100000"), "the source's 206 is passed through");
|
||||
assert_eq!(response.content_length(), Some(10));
|
||||
assert_eq!(response.e_tag(), Some(format!("\"{etag}\"").as_str()));
|
||||
assert_eq!(response.body.collect().await?.into_bytes(), body.slice(10..20));
|
||||
assert_eq!(
|
||||
source_get_ranges(&env, key),
|
||||
vec![Some("bytes=10-19".to_string())],
|
||||
"the Range is forwarded"
|
||||
);
|
||||
|
||||
assert!(
|
||||
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
|
||||
"serve_and_backfill must pull the whole object"
|
||||
);
|
||||
env.assert_local_present(bucket, key, &body).await;
|
||||
assert_eq!(
|
||||
source_get_ranges(&env, key),
|
||||
vec![Some("bytes=10-19".to_string()), None],
|
||||
"the background pull fetches the whole object"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_source_not_found_is_404_and_negative_cached() -> TestResult {
|
||||
let bucket = "odm-get-missing";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "missing/nowhere.bin";
|
||||
|
||||
for attempt in 1..=2 {
|
||||
let err = env
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a key missing on both sides is 404");
|
||||
assert_eq!(err.code(), Some("NoSuchKey"), "attempt {attempt}: {err:?}");
|
||||
}
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a source miss never pulls");
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
1,
|
||||
"the second miss stops at the negative cache"
|
||||
);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_with_version_id_does_not_consult_the_source() -> TestResult {
|
||||
let bucket = "odm-get-versioned";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
env.client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let key = "versioned/doc.bin";
|
||||
let body = payload(1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let err = env
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id("11111111-2222-4333-8444-555555555555")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a version read cannot be answered by the source");
|
||||
assert!(
|
||||
matches!(err.code(), Some("NoSuchVersion") | Some("NoSuchKey")),
|
||||
"unexpected error: {err:?}"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
|
||||
// The same key without versionId is still migrated: the gate is per request.
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(response.body, body);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_after_disable_does_not_consult_the_source() -> TestResult {
|
||||
let bucket = "odm-get-disabled";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "disabled/doc.bin";
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(1024))]);
|
||||
|
||||
let response = env.disable(bucket).await?;
|
||||
assert_eq!(response.status, 204, "{}", response.body);
|
||||
|
||||
let err = env
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a disabled bucket answers locally");
|
||||
assert_eq!(err.code(), Some("NoSuchKey"), "{err:?}");
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A HEAD miss is answered from the source but must not store anything: the
|
||||
/// key stays absent locally, so a second HEAD consults the source again. This
|
||||
/// is the smoke-lane guard for the HEAD passthrough (rustfs/backlog#2155).
|
||||
#[tokio::test]
|
||||
async fn head_miss_answers_from_the_source_without_persisting() -> TestResult {
|
||||
let bucket = "odm-head-passthrough";
|
||||
let env = configured_env(bucket, |_| {}).await?;
|
||||
let key = "head/report.bin";
|
||||
let body = payload(32 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let head = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
|
||||
assert_eq!(head.status, 200, "{}", String::from_utf8_lossy(&head.body));
|
||||
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
|
||||
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert!(head.body.is_empty(), "a HEAD answer carries no body");
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a HEAD must never pull the body");
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
|
||||
let again = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
|
||||
assert_eq!(again.status, 200, "{}", String::from_utf8_lossy(&again.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
2,
|
||||
"nothing was written back, so the second HEAD consults the source again"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Self-test of the ODM harness (rustfs/backlog#2151): the fake source's
|
||||
//! migration-facing surface (ListObjectsV2 paging, `Range`, unversioned
|
||||
//! buckets, metadata replay, fault actions) and the two-server environment.
|
||||
//! No ODM behavior is exercised here.
|
||||
|
||||
use super::common::{OdmTestEnv, SeedObject, fake_source_client, start_source_rustfs};
|
||||
use crate::fake_s3_target::{BucketMode, FakeS3Target, FakeS3TargetOptions, FaultAction, Operation, SeedMetadata};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTime};
|
||||
use bytes::Bytes;
|
||||
use std::collections::BTreeSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-source";
|
||||
|
||||
/// Position-dependent payload so a misaligned range read is caught.
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
async fn fake_source() -> Result<(FakeS3Target, Client), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let source = FakeS3Target::start().await?;
|
||||
source.create_bucket(SOURCE_BUCKET);
|
||||
let client = fake_source_client(&source);
|
||||
Ok((source, client))
|
||||
}
|
||||
|
||||
/// Full ListObjectsV2 traversal. Returns `(keys, common prefixes, pages)` and
|
||||
/// checks the page shape on the way: every page except the last is full and
|
||||
/// truncated, the last carries no continuation token.
|
||||
async fn list_all(
|
||||
client: &Client,
|
||||
prefix: Option<&str>,
|
||||
delimiter: Option<&str>,
|
||||
start_after: Option<&str>,
|
||||
max_keys: i32,
|
||||
) -> Result<(Vec<String>, Vec<String>, usize), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut keys = Vec::new();
|
||||
let mut prefixes = Vec::new();
|
||||
let mut pages = 0usize;
|
||||
let mut token: Option<String> = None;
|
||||
loop {
|
||||
let page = client
|
||||
.list_objects_v2()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.set_prefix(prefix.map(str::to_string))
|
||||
.set_delimiter(delimiter.map(str::to_string))
|
||||
.set_start_after(start_after.map(str::to_string))
|
||||
.max_keys(max_keys)
|
||||
.set_continuation_token(token.clone())
|
||||
.send()
|
||||
.await?;
|
||||
pages += 1;
|
||||
let page_keys: Vec<String> = page
|
||||
.contents()
|
||||
.iter()
|
||||
.filter_map(|object| object.key().map(str::to_string))
|
||||
.collect();
|
||||
let page_prefixes: Vec<String> = page
|
||||
.common_prefixes()
|
||||
.iter()
|
||||
.filter_map(|common| common.prefix().map(str::to_string))
|
||||
.collect();
|
||||
let entries = page_keys.len() + page_prefixes.len();
|
||||
assert_eq!(page.key_count(), Some(entries as i32), "KeyCount must count keys and prefixes");
|
||||
assert_eq!(page.continuation_token(), token.as_deref(), "the request token must be echoed");
|
||||
keys.extend(page_keys);
|
||||
prefixes.extend(page_prefixes);
|
||||
if page.is_truncated() == Some(true) {
|
||||
assert_eq!(entries as i32, max_keys, "every truncated page must be full");
|
||||
token = Some(
|
||||
page.next_continuation_token()
|
||||
.expect("truncated page must carry a continuation token")
|
||||
.to_string(),
|
||||
);
|
||||
} else {
|
||||
assert!(page.next_continuation_token().is_none(), "final page must not carry a token");
|
||||
return Ok((keys, prefixes, pages));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_list_objects_v2_paginates_with_delimiter() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
let mut expected_keys = BTreeSet::new();
|
||||
for directory in 0..30 {
|
||||
for file in 0..30 {
|
||||
expected_keys.insert(format!("d{directory:02}/k{file:03}"));
|
||||
}
|
||||
}
|
||||
for index in 0..100 {
|
||||
expected_keys.insert(format!("top-{index:03}"));
|
||||
}
|
||||
assert_eq!(expected_keys.len(), 1000);
|
||||
for key in &expected_keys {
|
||||
source.put_seed_object(SOURCE_BUCKET, key.clone(), Bytes::from(key.clone()), &SeedMetadata::new());
|
||||
}
|
||||
// A key whose current version is a delete marker must stay hidden.
|
||||
client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("hidden/marker")
|
||||
.body(ByteStream::from_static(b"gone"))
|
||||
.send()
|
||||
.await?;
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("hidden/marker")
|
||||
.send()
|
||||
.await?;
|
||||
let expected_sorted: Vec<String> = expected_keys.iter().cloned().collect();
|
||||
let expected_prefixes: Vec<String> = (0..30).map(|directory| format!("d{directory:02}/")).collect();
|
||||
let expected_top: Vec<String> = (0..100).map(|index| format!("top-{index:03}")).collect();
|
||||
|
||||
// Flat traversal in byte order, 1000 keys in pages of 7.
|
||||
let (keys, prefixes, pages) = list_all(&client, None, None, None, 7).await?;
|
||||
assert_eq!(keys, expected_sorted);
|
||||
assert!(prefixes.is_empty());
|
||||
assert_eq!(pages, 143);
|
||||
|
||||
// Delimiter folding: 30 common prefixes then 100 top-level keys, pages of 7.
|
||||
let (keys, prefixes, pages) = list_all(&client, None, Some("/"), None, 7).await?;
|
||||
assert_eq!(prefixes, expected_prefixes);
|
||||
assert_eq!(keys, expected_top);
|
||||
assert_eq!(pages, 19);
|
||||
|
||||
// Empty prefix equals no prefix.
|
||||
let (keys, _, _) = list_all(&client, Some(""), None, None, 1000).await?;
|
||||
assert_eq!(keys, expected_sorted);
|
||||
|
||||
// No match: empty, not truncated, no token.
|
||||
let (keys, prefixes, pages) = list_all(&client, Some("zzz/"), Some("/"), None, 7).await?;
|
||||
assert!(keys.is_empty() && prefixes.is_empty());
|
||||
assert_eq!(pages, 1);
|
||||
let (keys, _, _) = list_all(&client, Some("hidden/"), None, None, 7).await?;
|
||||
assert!(keys.is_empty(), "a current delete marker must hide its key");
|
||||
|
||||
// Exact page boundary: 30 keys under one directory, max-keys=30 -> one
|
||||
// untruncated page.
|
||||
let (keys, prefixes, pages) = list_all(&client, Some("d05/"), Some("/"), None, 30).await?;
|
||||
assert_eq!(keys.len(), 30);
|
||||
assert!(prefixes.is_empty());
|
||||
assert_eq!(pages, 1);
|
||||
|
||||
// start-after skips keys at or before the marker.
|
||||
let (keys, _, _) = list_all(&client, None, None, Some("top-097"), 1000).await?;
|
||||
assert_eq!(keys, ["top-098", "top-099"]);
|
||||
|
||||
// max-keys is clamped to 1000; exactly 1000 keys fit in one page.
|
||||
let (keys, _, pages) = list_all(&client, None, None, None, 5000).await?;
|
||||
assert_eq!(keys.len(), 1000);
|
||||
assert_eq!(pages, 1);
|
||||
|
||||
let listings: Vec<_> = source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|record| record.operation == Operation::ListObjectsV2)
|
||||
.collect();
|
||||
assert!(listings.len() >= 143 + 19);
|
||||
assert!(listings.iter().any(|record| record.prefix.as_deref() == Some("d05/")));
|
||||
assert!(
|
||||
listings.iter().any(|record| record.continuation_token.is_some()),
|
||||
"resumed pages must journal their continuation token"
|
||||
);
|
||||
assert!(listings.iter().all(|record| record.user_agent.is_some()));
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_range_get_variants_and_416() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
let body = payload(1000);
|
||||
source.put_seed_object(SOURCE_BUCKET, "ranged", body.clone(), &SeedMetadata::new());
|
||||
|
||||
for (range, expected_range, expected_slice) in [
|
||||
("bytes=10-19", "bytes 10-19/1000", &body[10..20]),
|
||||
("bytes=990-", "bytes 990-999/1000", &body[990..]),
|
||||
("bytes=-5", "bytes 995-999/1000", &body[995..]),
|
||||
("bytes=0-5000", "bytes 0-999/1000", &body[..]),
|
||||
] {
|
||||
let output = client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("ranged")
|
||||
.range(range)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(output.content_range(), Some(expected_range), "{range}");
|
||||
assert_eq!(output.accept_ranges(), Some("bytes"), "{range}");
|
||||
assert_eq!(output.content_length(), Some(expected_slice.len() as i64), "{range}");
|
||||
let collected = output.body.collect().await?.into_bytes();
|
||||
assert_eq!(collected.as_ref(), expected_slice, "{range}");
|
||||
}
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("ranged")
|
||||
.range("bytes=10-19")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(head.content_range(), Some("bytes 10-19/1000"));
|
||||
assert_eq!(head.content_length(), Some(10));
|
||||
|
||||
for range in ["bytes=1000-", "bytes=-0"] {
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("ranged")
|
||||
.range(range)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("unsatisfiable range must fail");
|
||||
let response = error.raw_response().expect("416 must retain the raw response");
|
||||
assert_eq!(response.status().as_u16(), 416, "{range}");
|
||||
assert_eq!(response.headers().get("content-range"), Some("bytes */1000"), "{range}");
|
||||
assert_eq!(error.code(), Some("InvalidRange"), "{range}");
|
||||
}
|
||||
|
||||
let ranged = source
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.operation == Operation::GetObject && record.range.as_deref() == Some("bytes=10-19"))
|
||||
.expect("the Range header must be journaled verbatim");
|
||||
assert_eq!(ranged.key.as_deref(), Some("ranged"));
|
||||
assert!(source.count_requests(Operation::GetObject, "ranged") >= 6);
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_unversioned_bucket_overwrites_and_deletes() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
source.create_bucket_with_mode("plain-source", BucketMode::Unversioned);
|
||||
let versioning = client.get_bucket_versioning().bucket("plain-source").send().await?;
|
||||
assert!(versioning.status().is_none(), "unversioned bucket must report no versioning status");
|
||||
|
||||
let first = client
|
||||
.put_object()
|
||||
.bucket("plain-source")
|
||||
.key("doc")
|
||||
.body(ByteStream::from_static(b"first"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(first.version_id().is_none());
|
||||
let second = client
|
||||
.put_object()
|
||||
.bucket("plain-source")
|
||||
.key("doc")
|
||||
.body(ByteStream::from_static(b"second"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(second.version_id().is_none());
|
||||
let get = client.get_object().bucket("plain-source").key("doc").send().await?;
|
||||
assert!(get.version_id().is_none(), "GET must not return x-amz-version-id");
|
||||
assert_eq!(get.body.collect().await?.into_bytes().as_ref(), b"second");
|
||||
let head = client.head_object().bucket("plain-source").key("doc").send().await?;
|
||||
assert!(head.version_id().is_none(), "HEAD must not return x-amz-version-id");
|
||||
assert_eq!(source.stored_versions("plain-source", "doc").len(), 1, "overwrite must replace in place");
|
||||
|
||||
let deleted = client.delete_object().bucket("plain-source").key("doc").send().await?;
|
||||
assert!(deleted.delete_marker().is_none() && deleted.version_id().is_none());
|
||||
let missing = client
|
||||
.get_object()
|
||||
.bucket("plain-source")
|
||||
.key("doc")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("deleted object must be gone");
|
||||
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(missing.code(), Some("NoSuchKey"));
|
||||
let missing_head = client
|
||||
.head_object()
|
||||
.bucket("plain-source")
|
||||
.key("doc")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("deleted object must fail HEAD");
|
||||
assert_eq!(missing_head.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert!(source.stored_versions("plain-source", "doc").is_empty(), "DELETE must not leave a marker");
|
||||
|
||||
// The versioned bucket on the same target keeps its version ids.
|
||||
let versioned = client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("doc")
|
||||
.body(ByteStream::from_static(b"versioned"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(versioned.version_id().is_some());
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_replays_standard_and_user_metadata() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
let body = payload(4096);
|
||||
let expected_etag = format!("\"{}\"", {
|
||||
use md5::Digest as _;
|
||||
hex_simd::encode_to_string(md5::Md5::digest(&body), hex_simd::AsciiCase::Lower)
|
||||
});
|
||||
// 2026-01-01T00:00:00Z rendered as an HTTP date by the SDK.
|
||||
let expires = DateTime::from_secs(1_767_225_600);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("meta")
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.content_type("application/x-odm")
|
||||
.content_encoding("gzip")
|
||||
.content_disposition("attachment; filename=\"meta.bin\"")
|
||||
.content_language("en-US")
|
||||
.cache_control("max-age=60")
|
||||
.expires(expires)
|
||||
.metadata("Foo-Bar", "mixed case name")
|
||||
.metadata("UPPER", "upper name")
|
||||
.metadata("already-lower", "lower name")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let head = client.head_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
|
||||
let get = client.get_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
|
||||
for (label, content_type, content_encoding, content_disposition, content_language, cache_control, expires_string, e_tag) in [
|
||||
(
|
||||
"HEAD",
|
||||
head.content_type(),
|
||||
head.content_encoding(),
|
||||
head.content_disposition(),
|
||||
head.content_language(),
|
||||
head.cache_control(),
|
||||
head.expires_string(),
|
||||
head.e_tag(),
|
||||
),
|
||||
(
|
||||
"GET",
|
||||
get.content_type(),
|
||||
get.content_encoding(),
|
||||
get.content_disposition(),
|
||||
get.content_language(),
|
||||
get.cache_control(),
|
||||
get.expires_string(),
|
||||
get.e_tag(),
|
||||
),
|
||||
] {
|
||||
assert_eq!(content_type, Some("application/x-odm"), "{label}");
|
||||
assert_eq!(content_encoding, Some("gzip"), "{label}");
|
||||
assert_eq!(content_disposition, Some("attachment; filename=\"meta.bin\""), "{label}");
|
||||
assert_eq!(content_language, Some("en-US"), "{label}");
|
||||
assert_eq!(cache_control, Some("max-age=60"), "{label}");
|
||||
assert_eq!(expires_string, Some("Thu, 01 Jan 2026 00:00:00 GMT"), "{label}");
|
||||
assert_eq!(e_tag, Some(expected_etag.as_str()), "{label}");
|
||||
}
|
||||
for metadata in [head.metadata(), get.metadata()] {
|
||||
let metadata = metadata.expect("user metadata must be replayed");
|
||||
assert_eq!(metadata.get("foo-bar").map(String::as_str), Some("mixed case name"));
|
||||
assert_eq!(metadata.get("upper").map(String::as_str), Some("upper name"));
|
||||
assert_eq!(metadata.get("already-lower").map(String::as_str), Some("lower name"));
|
||||
assert!(!metadata.contains_key("Foo-Bar") && !metadata.contains_key("UPPER"));
|
||||
}
|
||||
assert!(head.last_modified().is_some());
|
||||
assert_eq!(head.last_modified(), get.last_modified());
|
||||
assert_eq!(head.content_length(), Some(4096));
|
||||
assert_eq!(get.body.collect().await?.into_bytes(), body);
|
||||
|
||||
// Seeded objects replay the same way.
|
||||
let seeded_etag = source.put_seed_object(
|
||||
SOURCE_BUCKET,
|
||||
"seeded",
|
||||
Bytes::from_static(b"seeded"),
|
||||
&SeedMetadata::new()
|
||||
.content_type("text/plain")
|
||||
.content_encoding("identity")
|
||||
.cache_control("no-store")
|
||||
.user_metadata("Origin", "seed"),
|
||||
);
|
||||
let seeded = client.head_object().bucket(SOURCE_BUCKET).key("seeded").send().await?;
|
||||
assert_eq!(seeded.e_tag(), Some(format!("\"{seeded_etag}\"").as_str()));
|
||||
assert_eq!(seeded.content_type(), Some("text/plain"));
|
||||
assert_eq!(seeded.content_encoding(), Some("identity"));
|
||||
assert_eq!(seeded.cache_control(), Some("no-store"));
|
||||
assert_eq!(
|
||||
seeded
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.get("origin"))
|
||||
.map(String::as_str),
|
||||
Some("seed")
|
||||
);
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_fault_actions_truncate_stall_and_status() -> TestResult {
|
||||
let (source, client) = fake_source().await?;
|
||||
let body = payload(4096);
|
||||
source.put_seed_object(SOURCE_BUCKET, "faulty", body.clone(), &SeedMetadata::new());
|
||||
|
||||
// TruncateBodyAt: headers promise 4096 bytes, the body ends after 100.
|
||||
source.inject_for_key(Operation::GetObject, "faulty", FaultAction::TruncateBodyAt(100), 1);
|
||||
let truncated = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
|
||||
assert_eq!(truncated.content_length(), Some(4096));
|
||||
let short_read = truncated
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect_err("a truncated body must fail to collect");
|
||||
let short_read = short_read.to_string();
|
||||
assert!(!short_read.is_empty());
|
||||
|
||||
// ResponseStatus: arbitrary status with the matching S3 error code.
|
||||
for (code, expected_code) in [
|
||||
(429u16, "SlowDown"),
|
||||
(404, "NoSuchKey"),
|
||||
(500, "InternalError"),
|
||||
(503, "ServiceUnavailable"),
|
||||
] {
|
||||
source.inject(Operation::GetObject, FaultAction::ResponseStatus(code), 1);
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("faulty")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("scripted status must fail");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(code));
|
||||
assert_eq!(error.code(), Some(expected_code));
|
||||
}
|
||||
|
||||
// Stall: the fully computed response is held before its first byte.
|
||||
source.inject(Operation::HeadObject, FaultAction::Stall(Duration::from_millis(400)), 1);
|
||||
let started = Instant::now();
|
||||
let stalled = client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
|
||||
assert!(started.elapsed() >= Duration::from_millis(350), "stall must delay the first byte");
|
||||
assert_eq!(stalled.content_length(), Some(4096));
|
||||
let post_stall_started = Instant::now();
|
||||
client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
|
||||
assert!(post_stall_started.elapsed() < Duration::from_millis(350), "stall is consumed once");
|
||||
|
||||
// The object is intact once the script is drained.
|
||||
let intact = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
|
||||
assert_eq!(intact.body.collect().await?.into_bytes(), body);
|
||||
|
||||
assert_eq!(source.count_requests(Operation::GetObject, "faulty"), 6);
|
||||
assert_eq!(source.count_requests(Operation::HeadObject, "faulty"), 2);
|
||||
assert_eq!(source.count_requests(Operation::GetObject, "other"), 0);
|
||||
let records = source.requests();
|
||||
assert!(
|
||||
records.iter().all(|record| record
|
||||
.user_agent
|
||||
.as_deref()
|
||||
.is_some_and(|agent| agent.contains("aws-sdk-rust"))),
|
||||
"the SDK user agent must be journaled"
|
||||
);
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.any(|record| record.fault == Some(FaultAction::TruncateBodyAt(100)))
|
||||
);
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.any(|record| record.fault == Some(FaultAction::Stall(Duration::from_millis(400))))
|
||||
);
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fake_source_raised_object_cap_accepts_large_put() -> TestResult {
|
||||
let source = FakeS3Target::start_with_options(FakeS3TargetOptions {
|
||||
max_object_bytes: 96 * 1024 * 1024,
|
||||
})
|
||||
.await?;
|
||||
source.create_bucket(SOURCE_BUCKET);
|
||||
let client = fake_source_client(&source);
|
||||
let len = 64 * 1024 * 1024 + 1;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("large")
|
||||
.body(ByteStream::from(vec![7u8; len]))
|
||||
.send()
|
||||
.await?;
|
||||
let head = client.head_object().bucket(SOURCE_BUCKET).key("large").send().await?;
|
||||
assert_eq!(head.content_length(), Some(len as i64));
|
||||
let tail = client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("large")
|
||||
.range("bytes=-1")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(tail.content_range(), Some(format!("bytes {}-{}/{len}", len - 1, len - 1).as_str()));
|
||||
source.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn odm_env_starts_rustfs_and_fake_source() -> TestResult {
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket(SOURCE_BUCKET);
|
||||
let local_bucket = "odm-local";
|
||||
env.rustfs.create_test_bucket(local_bucket).await?;
|
||||
|
||||
let etags = env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("seed/a", Bytes::from_static(b"alpha")),
|
||||
SeedObject::new("seed/b", Bytes::from_static(b"beta"))
|
||||
.with_metadata(SeedMetadata::new().content_type("text/plain").user_metadata("Kind", "seed")),
|
||||
],
|
||||
);
|
||||
assert_eq!(etags.len(), 2);
|
||||
assert!(env.source.requests().is_empty(), "seeding must not touch the journal");
|
||||
let source_client = env.source_client();
|
||||
let seeded = source_client.head_object().bucket(SOURCE_BUCKET).key("seed/b").send().await?;
|
||||
assert_eq!(seeded.content_type(), Some("text/plain"));
|
||||
assert_eq!(seeded.e_tag(), Some(format!("\"{}\"", etags[1]).as_str()));
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, "seed/b"), 1);
|
||||
|
||||
env.assert_local_absent(local_bucket, "seed/a").await;
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(local_bucket)
|
||||
.key("seed/a")
|
||||
.body(ByteStream::from_static(b"alpha"))
|
||||
.send()
|
||||
.await?;
|
||||
env.assert_local_present(local_bucket, "seed/a", b"alpha").await;
|
||||
env.assert_local_absent(local_bucket, "seed/b").await;
|
||||
|
||||
let spec = env.fake_source_spec(SOURCE_BUCKET).to_json();
|
||||
assert_eq!(spec["version"], 1);
|
||||
assert_eq!(spec["enabled"], true);
|
||||
assert_eq!(spec["source"]["provider"], "s3");
|
||||
assert_eq!(spec["source"]["endpoint"], env.source.endpoint());
|
||||
assert_eq!(spec["source"]["bucket"], SOURCE_BUCKET);
|
||||
assert_eq!(spec["source"]["credentials"]["secret_key"], "fake-secret");
|
||||
assert_eq!(spec["policy"]["source_timeout"]["first_byte_ms"], 15_000);
|
||||
assert!(spec["policy"]["bandwidth_limit_bytes_per_sec"].is_null());
|
||||
let debug = format!("{:?}", env.fake_source_spec(SOURCE_BUCKET));
|
||||
assert!(!debug.contains("fake-secret"), "Debug output must redact the secret");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_source_rustfs_round_trips_put_get() -> TestResult {
|
||||
let env = OdmTestEnv::start().await?;
|
||||
let source = start_source_rustfs().await?;
|
||||
assert_ne!(source.url, env.rustfs.url, "the source must be a separate instance");
|
||||
|
||||
source.create_test_bucket(SOURCE_BUCKET).await?;
|
||||
let source_client = source.create_s3_client();
|
||||
let body = payload(70_000);
|
||||
let put = source_client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("real/object")
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.content_type("application/octet-stream")
|
||||
.send()
|
||||
.await?;
|
||||
assert!(put.e_tag().is_some());
|
||||
let get = source_client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key("real/object")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(get.content_type(), Some("application/octet-stream"));
|
||||
assert_eq!(get.body.collect().await?.into_bytes(), body);
|
||||
|
||||
let visible_to_primary = env
|
||||
.client
|
||||
.list_buckets()
|
||||
.send()
|
||||
.await?
|
||||
.buckets()
|
||||
.iter()
|
||||
.any(|bucket| bucket.name() == Some(SOURCE_BUCKET));
|
||||
assert!(!visible_to_primary, "the two servers must not share state");
|
||||
let spec = super::common::OdmSourceSpec::for_rustfs_source(&source, SOURCE_BUCKET).to_json();
|
||||
assert_eq!(spec["source"]["provider"], "rustfs");
|
||||
assert_eq!(spec["source"]["endpoint"], source.url);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! How on-demand migration composes with the rest of the bucket surface
|
||||
//! (rustfs/backlog#2158): default encryption, Object Lock, quota,
|
||||
//! notifications, replication, versioning and delete markers, the disable
|
||||
//! switch, and the admin view.
|
||||
//!
|
||||
//! A pulled object goes through the internal put path, so it must be
|
||||
//! indistinguishable from a client PUT. Each case pins both the resulting
|
||||
//! local object and what the source was asked for.
|
||||
|
||||
use super::common::{
|
||||
AdminResponse, BoxError, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env,
|
||||
start_configured_env_with,
|
||||
};
|
||||
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
|
||||
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
|
||||
use crate::object_lock::common::put_object_lock_configuration;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
|
||||
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault,
|
||||
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, VersioningConfiguration,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use local_ip_address::local_ip;
|
||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-interaction-source";
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// `userIdentity.principalId` every write-back event carries.
|
||||
const ODM_PRINCIPAL_ID: &str = "rustfs-on-demand-migration";
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
async fn admin(
|
||||
env: &RustFSTestEnvironment,
|
||||
method: http::Method,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
) -> Result<AdminResponse, BoxError> {
|
||||
let url = format!("{}{path}", env.url);
|
||||
let body = body.map(|value| serde_json::to_vec(&value)).transpose()?;
|
||||
let content_type = body.is_some().then_some("application/json");
|
||||
let response = signed_request(method, &url, &env.access_key, &env.secret_key, body, content_type).await?;
|
||||
Ok(AdminResponse {
|
||||
status: response.status().as_u16(),
|
||||
body: response.text().await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn enable_versioning(env: &OdmTestEnv, bucket: &str) -> TestResult {
|
||||
env.client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 12: a bucket that encrypts by default stores the pulled object
|
||||
/// encrypted, and it reads back as plaintext afterwards without touching the
|
||||
/// source again.
|
||||
#[tokio::test]
|
||||
async fn test_odm_pulled_object_uses_bucket_default_encryption() -> TestResult {
|
||||
let bucket = "odm-interaction-sse";
|
||||
let env = start_configured_env_with(
|
||||
OdmEnvOptions {
|
||||
local_kms: true,
|
||||
..OdmEnvOptions::default()
|
||||
},
|
||||
bucket,
|
||||
SOURCE_BUCKET,
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
env.client
|
||||
.put_bucket_encryption()
|
||||
.bucket(bucket)
|
||||
.server_side_encryption_configuration(
|
||||
ServerSideEncryptionConfiguration::builder()
|
||||
.rules(
|
||||
ServerSideEncryptionRule::builder()
|
||||
.apply_server_side_encryption_by_default(
|
||||
ServerSideEncryptionByDefault::builder()
|
||||
.sse_algorithm(ServerSideEncryption::Aes256)
|
||||
.build()?,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let key = "sse/report.bin";
|
||||
let body = payload(128 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let first = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
|
||||
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(first.body, body);
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
|
||||
|
||||
let second = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
|
||||
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "the second read is local");
|
||||
assert_eq!(
|
||||
second.header("x-amz-server-side-encryption"),
|
||||
Some("AES256"),
|
||||
"the write-back honours the bucket default encryption"
|
||||
);
|
||||
assert_eq!(second.body, body, "the encrypted copy reads back as the source bytes");
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"the encrypted local copy serves the second read"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 13: a pulled object inherits the bucket's default Object Lock
|
||||
/// retention, so it cannot be deleted while the retention holds.
|
||||
#[tokio::test]
|
||||
async fn test_odm_pulled_object_inherits_object_lock_retention() -> TestResult {
|
||||
let bucket = "odm-interaction-object-lock";
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
|
||||
env.client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
put_object_lock_configuration(&env.client, bucket, ObjectLockRetentionMode::Compliance, Some(1), None).await?;
|
||||
let spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
env.configure_and_wait(bucket, &spec).await?;
|
||||
|
||||
let key = "locked/record.bin";
|
||||
let body = payload(32 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let pulled = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
|
||||
assert_eq!(pulled.body, body);
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
|
||||
|
||||
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
|
||||
assert_eq!(
|
||||
head.object_lock_mode().map(|mode| mode.as_str()),
|
||||
Some("COMPLIANCE"),
|
||||
"the default retention mode is applied to the pulled object"
|
||||
);
|
||||
assert!(head.object_lock_retain_until_date().is_some(), "a retain-until date is set");
|
||||
|
||||
let version_id = head.version_id().ok_or("an Object Lock bucket is versioned")?.to_string();
|
||||
let error = env
|
||||
.client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(&version_id)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a COMPLIANCE-retained version cannot be deleted");
|
||||
assert_eq!(error.code(), Some("AccessDenied"), "{error:?}");
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"the rejected delete never consults the source"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 14: the write-back obeys the bucket quota. The client is still
|
||||
/// served from the source, but nothing is stored and the failure is counted.
|
||||
#[tokio::test]
|
||||
async fn test_odm_write_back_respects_the_bucket_quota() -> TestResult {
|
||||
let bucket = "odm-interaction-quota";
|
||||
let env = start_configured_env_with(
|
||||
OdmEnvOptions {
|
||||
env: vec![("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")],
|
||||
..OdmEnvOptions::default()
|
||||
},
|
||||
bucket,
|
||||
SOURCE_BUCKET,
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Fill the bucket past the quota it is about to get, so the write-back's
|
||||
// admission check has to reject it.
|
||||
let filler = payload(2 * 1024 * 1024);
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("quota/filler.bin")
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(filler.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
wait_for_bucket_usage(&env, bucket, filler.len() as u64).await?;
|
||||
set_bucket_quota(&env, bucket, 1024 * 1024).await?;
|
||||
|
||||
let key = "quota/oversized.bin";
|
||||
let body = payload(2 * 1024 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(response.body, body, "a full bucket still serves the client from the source");
|
||||
|
||||
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/quota", 1, SETTLE)
|
||||
.await?;
|
||||
env.assert_local_absent(bucket, key).await;
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"the rejected write-back is not retried against the source"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The quota route answers 503 until the durable-quota capability is
|
||||
/// confirmed on the fresh single-node deployment, so the write is retried.
|
||||
async fn set_bucket_quota(env: &OdmTestEnv, bucket: &str, quota_bytes: u64) -> TestResult {
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let response = admin(
|
||||
&env.rustfs,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/quota/{bucket}"),
|
||||
Some(serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" })),
|
||||
)
|
||||
.await?;
|
||||
if response.status < 300 {
|
||||
return Ok(());
|
||||
}
|
||||
if response.status != 503 || Instant::now() >= deadline {
|
||||
return Err(format!("set quota for {bucket}: {} {}", response.status, response.body).into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_bucket_usage(env: &OdmTestEnv, bucket: &str, at_least: u64) -> TestResult {
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let response = admin(&env.rustfs, http::Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
|
||||
if response.status == 200 {
|
||||
let usage = serde_json::from_str::<Value>(&response.body)?
|
||||
.get("current_usage")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
if usage >= at_least {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("bucket usage for {bucket} did not reach {at_least} bytes: {}", response.body).into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Case 15: a pull emits an ordinary creation event attributed to the
|
||||
/// migration principal, and `emit_events=false` silences it.
|
||||
#[tokio::test]
|
||||
async fn test_odm_pull_emits_object_created_events_unless_disabled() -> TestResult {
|
||||
let emitting = "odm-interaction-events";
|
||||
let silent = "odm-interaction-events-off";
|
||||
// The collector binds first: the outbound guard rejects a webhook
|
||||
// endpoint on a private address unless its origin is allowed at startup.
|
||||
let (endpoint, mut events) = spawn_event_collector().await?;
|
||||
let allowed_origin = reqwest::Url::parse(&endpoint)?.origin().ascii_serialization();
|
||||
let env = start_configured_env_with(
|
||||
OdmEnvOptions {
|
||||
env: vec![(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())],
|
||||
..OdmEnvOptions::default()
|
||||
},
|
||||
emitting,
|
||||
SOURCE_BUCKET,
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
let mut silent_spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
silent_spec.policy.emit_events = false;
|
||||
env.configure_and_wait(silent, &silent_spec).await?;
|
||||
|
||||
let target = "odm-events";
|
||||
let switches = admin(
|
||||
&env.rustfs,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/module-switches",
|
||||
Some(serde_json::json!({ "notify_enabled": true, "audit_enabled": false })),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(switches.status, 200, "{}", switches.body);
|
||||
let queue_dir = format!("{}/notify-queue-{target}", env.rustfs.temp_dir);
|
||||
tokio::fs::create_dir_all(&queue_dir).await?;
|
||||
let configured = admin(
|
||||
&env.rustfs,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/target/notify_webhook/{target}"),
|
||||
Some(serde_json::json!({
|
||||
"key_values": [
|
||||
{ "key": "endpoint", "value": endpoint },
|
||||
{ "key": "queue_dir", "value": queue_dir },
|
||||
]
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
wait_for_target_online(&env.rustfs, target).await?;
|
||||
for bucket in [emitting, silent] {
|
||||
put_notification_config(&env, bucket, target).await?;
|
||||
}
|
||||
|
||||
// Control: an ordinary client PUT must produce an event, so a missing
|
||||
// one below is about the write-back and not about the pipeline.
|
||||
let control_key = "events/control.bin";
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(emitting)
|
||||
.key(control_key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(payload(1024)))
|
||||
.send()
|
||||
.await?;
|
||||
let control = wait_for_event(&mut events, emitting, control_key, Duration::from_secs(60))
|
||||
.await
|
||||
.ok_or("the notification pipeline delivered no event for a plain PUT")?;
|
||||
assert_eq!(
|
||||
control.pointer("/eventName").and_then(Value::as_str),
|
||||
Some("s3:ObjectCreated:Put"),
|
||||
"{control}"
|
||||
);
|
||||
|
||||
let emitting_key = "events/pulled.bin";
|
||||
let silent_key = "events/quiet.bin";
|
||||
let body = payload(16 * 1024);
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new(emitting_key, body.clone()),
|
||||
SeedObject::new(silent_key, body.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
for (bucket, key) in [(emitting, emitting_key), (silent, silent_key)] {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{bucket}: {}", String::from_utf8_lossy(&response.body));
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{bucket}/{key} must be stored");
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "{bucket}/{key}");
|
||||
}
|
||||
|
||||
let record = wait_for_event(&mut events, emitting, emitting_key, Duration::from_secs(60))
|
||||
.await
|
||||
.ok_or("no creation event for the pulled object")?;
|
||||
assert_eq!(
|
||||
record.pointer("/eventName").and_then(Value::as_str),
|
||||
Some("s3:ObjectCreated:Put"),
|
||||
"{record}"
|
||||
);
|
||||
assert_eq!(
|
||||
record.pointer("/userIdentity/principalId").and_then(Value::as_str),
|
||||
Some(ODM_PRINCIPAL_ID),
|
||||
"{record}"
|
||||
);
|
||||
|
||||
// The silent bucket's object landed before the event above was observed,
|
||||
// so a missing event here is a decision, not a race.
|
||||
assert!(
|
||||
wait_for_event(&mut events, silent, silent_key, Duration::from_secs(5))
|
||||
.await
|
||||
.is_none(),
|
||||
"emit_events=false must not publish a creation event"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn put_notification_config(env: &OdmTestEnv, bucket: &str, target: &str) -> TestResult {
|
||||
let queue = QueueConfiguration::builder()
|
||||
.id(format!("{bucket}-rule"))
|
||||
.queue_arn(format!("arn:rustfs:sqs:us-east-1:{target}:webhook"))
|
||||
.events(Event::from("s3:ObjectCreated:*"))
|
||||
.filter(
|
||||
NotificationConfigurationFilter::builder()
|
||||
.key(
|
||||
S3KeyFilter::builder()
|
||||
.filter_rules(FilterRule::builder().name(FilterRuleName::Prefix).value("events/").build())
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build()?;
|
||||
env.client
|
||||
.put_bucket_notification_configuration()
|
||||
.bucket(bucket)
|
||||
.notification_configuration(NotificationConfiguration::builder().queue_configurations(queue).build())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_target_online(env: &RustFSTestEnvironment, target: &str) -> TestResult {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let response = admin(env, http::Method::GET, "/rustfs/admin/v3/target/list", None).await?;
|
||||
if response.status == 200 {
|
||||
let body: Value = serde_json::from_str(&response.body)?;
|
||||
let online = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
|
||||
endpoints.iter().any(|endpoint| {
|
||||
endpoint["account_id"].as_str() == Some(target) && endpoint["status"].as_str() == Some("online")
|
||||
})
|
||||
});
|
||||
if online {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("webhook target {target} did not come online: {}", response.body).into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal HTTP receiver: answers everything 200 (so the target's
|
||||
/// reachability probe reports online) and forwards parsed POST bodies.
|
||||
async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Value>), BoxError> {
|
||||
let listener = TcpListener::bind("0.0.0.0:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let endpoint = format!("http://{}/events", std::net::SocketAddr::new(local_ip()?, port));
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut stream, _)) = listener.accept().await {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 4096];
|
||||
let mut content_length = 0usize;
|
||||
let mut header_end = None;
|
||||
while header_end.is_none() {
|
||||
match stream.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
|
||||
}
|
||||
header_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
|
||||
}
|
||||
let header_end = header_end.expect("loop exits only with a header end");
|
||||
let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
|
||||
for line in headers.split("\r\n").skip(1) {
|
||||
if let Some((name, value)) = line.split_once(':')
|
||||
&& name.trim().eq_ignore_ascii_case("content-length")
|
||||
{
|
||||
content_length = value.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let body_offset = header_end + 4;
|
||||
while buffer.len() - body_offset < content_length {
|
||||
match stream.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
|
||||
}
|
||||
}
|
||||
let _ = stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||
.await;
|
||||
let _ = stream.shutdown().await;
|
||||
if let Ok(value) = serde_json::from_slice::<Value>(&buffer[body_offset..body_offset + content_length]) {
|
||||
let _ = tx.send(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok((endpoint, rx))
|
||||
}
|
||||
|
||||
/// The first delivered record for `bucket`/`key`, or `None` on timeout.
|
||||
async fn wait_for_event(
|
||||
events: &mut mpsc::UnboundedReceiver<Value>,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
timeout: Duration,
|
||||
) -> Option<Value> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.checked_duration_since(Instant::now())?;
|
||||
let envelope = tokio::time::timeout(remaining, events.recv()).await.ok()??;
|
||||
for record in envelope["Records"].as_array().into_iter().flatten() {
|
||||
// S3 event notifications URL-encode the object key.
|
||||
let record_key = record.pointer("/s3/object/key").and_then(Value::as_str).map(|raw| {
|
||||
urlencoding::decode(raw)
|
||||
.map(|decoded| decoded.into_owned())
|
||||
.unwrap_or_else(|_| raw.to_string())
|
||||
});
|
||||
if record.pointer("/s3/bucket/name").and_then(Value::as_str) == Some(bucket) && record_key.as_deref() == Some(key) {
|
||||
return Some(record.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Case 16: a pulled object enters the replication pipeline like any other
|
||||
/// write, and a configuration whose source is one of the bucket's own
|
||||
/// replication targets is rejected.
|
||||
#[tokio::test]
|
||||
async fn test_odm_pulled_object_replicates_and_target_as_source_is_rejected() -> TestResult {
|
||||
let bucket = "odm-interaction-replication";
|
||||
let replica_bucket = "odm-replica";
|
||||
let fast_env = replication_fast_env();
|
||||
let env = start_configured_env_with(
|
||||
OdmEnvOptions {
|
||||
env: fast_env.clone(),
|
||||
..OdmEnvOptions::default()
|
||||
},
|
||||
bucket,
|
||||
SOURCE_BUCKET,
|
||||
|_| {},
|
||||
)
|
||||
.await?;
|
||||
let replica = FakeS3Target::start().await?;
|
||||
replica.create_bucket(replica_bucket);
|
||||
|
||||
enable_versioning(&env, bucket).await?;
|
||||
let arn = set_remote_target(&env.rustfs, bucket, &replica.address(), replica_bucket).await?;
|
||||
put_bucket_replication(&env.rustfs, bucket, &arn).await?;
|
||||
|
||||
let key = "replicated/asset.bin";
|
||||
let body = payload(64 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
|
||||
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
|
||||
assert_eq!(response.body, body);
|
||||
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
|
||||
|
||||
let deadline = Instant::now() + SETTLE;
|
||||
while !replica.has_object(replica_bucket, key) {
|
||||
assert!(Instant::now() < deadline, "the pulled object was never replicated to the target");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, key),
|
||||
1,
|
||||
"replication reads the local copy, never the migration source"
|
||||
);
|
||||
|
||||
let looping = OdmSourceSpec::for_fake_source(&replica, replica_bucket);
|
||||
let rejected = env.configure_source(bucket, &looping).await?;
|
||||
assert_eq!(
|
||||
rejected.status, 400,
|
||||
"a bucket may not migrate from its own replication target: {}",
|
||||
rejected.body
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_remote_target(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
endpoint: &str,
|
||||
target_bucket: &str,
|
||||
) -> Result<String, BoxError> {
|
||||
let response = admin(
|
||||
env,
|
||||
http::Method::PUT,
|
||||
&format!("/rustfs/admin/v3/set-remote-target?bucket={}", urlencoding::encode(bucket)),
|
||||
Some(serde_json::json!({
|
||||
"endpoint": endpoint,
|
||||
"credentials": { "accessKey": FAKE_ACCESS_KEY, "secretKey": FAKE_SECRET_KEY },
|
||||
"targetbucket": target_bucket,
|
||||
"secure": false,
|
||||
"skipTlsVerify": false,
|
||||
"type": "replication"
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
if response.status != 200 {
|
||||
return Err(format!("set remote target: {} {}", response.status, response.body).into());
|
||||
}
|
||||
Ok(serde_json::from_str(&response.body)?)
|
||||
}
|
||||
|
||||
async fn put_bucket_replication(env: &RustFSTestEnvironment, bucket: &str, arn: &str) -> TestResult {
|
||||
let body = format!(
|
||||
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Role></Role>
|
||||
<Rule>
|
||||
<ID>odm-rule</ID>
|
||||
<Priority>1</Priority>
|
||||
<Status>Enabled</Status>
|
||||
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
|
||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||
<Destination><Bucket>{arn}</Bucket></Destination>
|
||||
</Rule>
|
||||
</ReplicationConfiguration>"#
|
||||
);
|
||||
let url = format!("{}/{bucket}?replication", env.url);
|
||||
let response = signed_request(
|
||||
http::Method::PUT,
|
||||
&url,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
Some(body.into_bytes()),
|
||||
Some("application/xml"),
|
||||
)
|
||||
.await?;
|
||||
if response.status() != 200 {
|
||||
let status = response.status();
|
||||
return Err(format!("put bucket replication: {status} {}", response.text().await.unwrap_or_default()).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 17: a local delete marker is the authoritative answer in a versioned
|
||||
/// bucket, while an unversioned delete leaves nothing behind and the key is
|
||||
/// migrated again.
|
||||
#[tokio::test]
|
||||
async fn test_odm_delete_marker_shadows_the_source_but_a_plain_delete_does_not() -> TestResult {
|
||||
let versioned = "odm-interaction-delete-marker";
|
||||
let unversioned = "odm-interaction-plain-delete";
|
||||
let env = start_configured_env(versioned, SOURCE_BUCKET, |_| {}).await?;
|
||||
let spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
env.configure_and_wait(unversioned, &spec).await?;
|
||||
enable_versioning(&env, versioned).await?;
|
||||
|
||||
let key = "deleted/doc.bin";
|
||||
let source_body = payload(8 * 1024);
|
||||
let local_body = payload(4 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, source_body.clone())]);
|
||||
|
||||
for bucket in [versioned, unversioned] {
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(local_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
env.client.delete_object().bucket(bucket).key(key).send().await?;
|
||||
}
|
||||
|
||||
let shadowed = env.raw_get(versioned, key).await?;
|
||||
assert_eq!(shadowed.status, 404, "{}", String::from_utf8_lossy(&shadowed.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, key),
|
||||
0,
|
||||
"a local delete marker answers without the source"
|
||||
);
|
||||
|
||||
let migrated = env.raw_get(unversioned, key).await?;
|
||||
assert_eq!(migrated.status, 200, "{}", String::from_utf8_lossy(&migrated.body));
|
||||
assert_eq!(migrated.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(migrated.body, source_body, "an unversioned delete leaves the source authoritative");
|
||||
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 18: deleting the configuration stops all source traffic without
|
||||
/// touching what was already migrated, and reinstalling it resumes.
|
||||
#[tokio::test]
|
||||
async fn test_odm_disable_keeps_pulled_objects_and_stops_source_traffic() -> TestResult {
|
||||
let bucket = "odm-interaction-disable";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let pulled_key = "disable/pulled.bin";
|
||||
let untouched_key = "disable/untouched.bin";
|
||||
let body = payload(32 * 1024);
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new(pulled_key, body.clone()),
|
||||
SeedObject::new(untouched_key, body.clone()),
|
||||
],
|
||||
);
|
||||
|
||||
let pulled = env.raw_get(bucket, pulled_key).await?;
|
||||
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
|
||||
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?);
|
||||
|
||||
let disabled = env.disable(bucket).await?;
|
||||
assert_eq!(disabled.status, 204, "{}", disabled.body);
|
||||
|
||||
let still_readable = env.raw_get(bucket, pulled_key).await?;
|
||||
assert_eq!(still_readable.status, 200, "{}", String::from_utf8_lossy(&still_readable.body));
|
||||
assert_eq!(still_readable.body, body, "a migrated object survives the disable");
|
||||
assert_eq!(still_readable.header(ODM_RESPONSE_HEADER), None);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, pulled_key), 1);
|
||||
|
||||
let missing = env.raw_get(bucket, untouched_key).await?;
|
||||
assert_eq!(missing.status, 404, "{}", String::from_utf8_lossy(&missing.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, untouched_key),
|
||||
0,
|
||||
"a disabled bucket never reaches the source"
|
||||
);
|
||||
|
||||
let spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
env.configure_and_wait(bucket, &spec).await?;
|
||||
let resumed = env.raw_get(bucket, untouched_key).await?;
|
||||
assert_eq!(resumed.status, 200, "{}", String::from_utf8_lossy(&resumed.body));
|
||||
assert_eq!(resumed.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(resumed.body, body);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, untouched_key), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 19: the admin surface an operator sees — the configuration read back
|
||||
/// without its secret, and a status document whose counters match the source
|
||||
/// journal exactly.
|
||||
#[tokio::test]
|
||||
async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source() -> TestResult {
|
||||
let bucket = "odm-interaction-admin";
|
||||
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
|
||||
let hit_key = "admin/present.bin";
|
||||
let miss_key = "admin/absent.bin";
|
||||
let body = payload(16 * 1024);
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(hit_key, body.clone())]);
|
||||
|
||||
let config = env.get_config(bucket).await?;
|
||||
assert_eq!(config.status, 200, "{}", config.body);
|
||||
let config = config.json()?;
|
||||
assert_eq!(
|
||||
config
|
||||
.pointer("/config/source/credentials/secret_key")
|
||||
.and_then(Value::as_str),
|
||||
Some("REDACTED"),
|
||||
"{config}"
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.pointer("/config/source/credentials/access_key")
|
||||
.and_then(Value::as_str),
|
||||
Some(FAKE_ACCESS_KEY),
|
||||
"the access key stays readable: {config}"
|
||||
);
|
||||
assert!(
|
||||
!config.to_string().contains(FAKE_SECRET_KEY),
|
||||
"the secret must not appear anywhere in the response"
|
||||
);
|
||||
|
||||
let hit = env.raw_get(bucket, hit_key).await?;
|
||||
assert_eq!(hit.status, 200, "{}", String::from_utf8_lossy(&hit.body));
|
||||
for _ in 0..2 {
|
||||
let miss = env.raw_get(bucket, miss_key).await?;
|
||||
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
||||
}
|
||||
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
|
||||
|
||||
let status = env.status_json(bucket).await?;
|
||||
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
||||
assert_eq!(status.pointer("/enabled").and_then(Value::as_bool), Some(true), "{status}");
|
||||
assert_eq!(status.pointer("/module_enabled").and_then(Value::as_bool), Some(true), "{status}");
|
||||
assert_eq!(status.pointer("/provider").and_then(Value::as_str), Some("s3"), "{status}");
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/requests_total/get/source_hit")
|
||||
.and_then(Value::as_u64),
|
||||
Some(1),
|
||||
"one source hit, matching the one source GET: {status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/requests_total/get/source_miss")
|
||||
.and_then(Value::as_u64),
|
||||
Some(1),
|
||||
"only the first miss reached the source: {status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/requests_total/get/negative_cached")
|
||||
.and_then(Value::as_u64),
|
||||
Some(1),
|
||||
"the second miss stopped at the negative cache: {status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status
|
||||
.pointer("/counters/pulled_objects_total/inline")
|
||||
.and_then(Value::as_u64),
|
||||
Some(1),
|
||||
"{status}"
|
||||
);
|
||||
assert_eq!(
|
||||
status.pointer("/counters/pulled_bytes_total").and_then(Value::as_u64),
|
||||
Some(body.len() as u64),
|
||||
"{status}"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, hit_key), 1);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, miss_key),
|
||||
1,
|
||||
"the status counters and the source journal agree"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Provider interoperability cases (ODM-20, rustfs/backlog#2167).
|
||||
//!
|
||||
//! One body per case, run against whichever source the environment names:
|
||||
//! the in-process fake source locally, a MinIO container or a real cloud
|
||||
//! provider under `.github/workflows/on-demand-migration-interop.yml`. The
|
||||
//! source is resolved by [`OdmInteropEnv`], so a provider difference in
|
||||
//! path-style addressing, region handling, ETag shape or list pagination
|
||||
//! shows up as one of these assertions failing rather than as a second,
|
||||
//! drifting copy of the suite.
|
||||
//!
|
||||
//! Consequently these cases assert only on what every S3 implementation has
|
||||
//! to agree on — what the client receives and what RustFS stored — never on
|
||||
//! the fake source's request journal, which a real provider does not have.
|
||||
//! The journal-backed expectations stay in `get_basic_test.rs` and
|
||||
//! `interaction_test.rs`.
|
||||
//!
|
||||
//! The first three cases are the minimum a cloud provider is asked for (GET
|
||||
//! miss, HEAD miss, merged list pagination); the backfill case runs against
|
||||
//! the MinIO container, whose object count the lane raises well past the fake
|
||||
//! source's caps.
|
||||
|
||||
use super::common::{BackfillRequest, BoxError, OdmInteropEnv, SeedObject, interop_backfill_objects};
|
||||
use bytes::Bytes;
|
||||
use std::time::Duration;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// Background pulls land after the response that triggered them; generous for
|
||||
/// a loaded runner talking to a container.
|
||||
const SETTLE: Duration = Duration::from_secs(90);
|
||||
|
||||
/// Position-dependent payload so a misaligned or truncated copy is caught.
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
/// A GET miss is answered from the source with the source's own ETag, and the
|
||||
/// object it stored serves every later read locally.
|
||||
#[tokio::test]
|
||||
async fn interop_get_miss_pulls_from_the_source_and_serves_locally() -> TestResult {
|
||||
let case =
|
||||
OdmInteropEnv::start("interop_get_miss_pulls_from_the_source_and_serves_locally", "odm-interop-get", |_| {}).await?;
|
||||
let key = "interop/report.bin";
|
||||
let body = payload(200 * 1024);
|
||||
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
|
||||
let quoted_etag = format!("\"{etag}\"");
|
||||
|
||||
let first = case.env.raw_get(&case.bucket, key).await?;
|
||||
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
|
||||
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
|
||||
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert_eq!(
|
||||
first.header("etag"),
|
||||
Some(quoted_etag.as_str()),
|
||||
"the source ETag is passed through unchanged"
|
||||
);
|
||||
assert_eq!(first.body, body, "the client receives the source bytes");
|
||||
|
||||
assert!(
|
||||
case.env.wait_local_listed(&case.bucket, key, SETTLE).await?,
|
||||
"the inline pull must store the object locally"
|
||||
);
|
||||
let second = case.env.raw_get(&case.bucket, key).await?;
|
||||
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
|
||||
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
|
||||
assert_eq!(second.body, body, "the local copy is the source bytes");
|
||||
assert_eq!(
|
||||
second.header("etag"),
|
||||
Some(quoted_etag.as_str()),
|
||||
"preserve_etag keeps the source ETag on the stored object"
|
||||
);
|
||||
case.finish().await
|
||||
}
|
||||
|
||||
/// A HEAD miss is proxied with the source's size and ETag and stores nothing.
|
||||
#[tokio::test]
|
||||
async fn interop_head_miss_answers_from_the_source_without_persisting() -> TestResult {
|
||||
let case =
|
||||
OdmInteropEnv::start("interop_head_miss_answers_from_the_source_without_persisting", "odm-interop-head", |_| {}).await?;
|
||||
let key = "interop/head-only.bin";
|
||||
let body = payload(9_000);
|
||||
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
|
||||
|
||||
let head = case
|
||||
.env
|
||||
.raw_object_request(http::Method::HEAD, &case.bucket, key, &[])
|
||||
.await?;
|
||||
assert_eq!(head.status, 200, "HEAD must be answered from the source");
|
||||
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
|
||||
assert_eq!(head.header("etag"), Some(format!("\"{etag}\"").as_str()));
|
||||
assert!(head.body.is_empty(), "a HEAD carries no body");
|
||||
case.env.assert_local_absent(&case.bucket, key).await;
|
||||
|
||||
// A key the source does not hold is a plain 404, not a source error.
|
||||
let missing = case
|
||||
.env
|
||||
.raw_object_request(http::Method::HEAD, &case.bucket, "interop/absent.bin", &[])
|
||||
.await?;
|
||||
assert_eq!(missing.status, 404, "a source miss is a 404");
|
||||
case.finish().await
|
||||
}
|
||||
|
||||
/// The merged `ListObjectsV2` pages the source namespace in byte order, keeps
|
||||
/// every page within `max_keys`, and lets a local object win a shared key.
|
||||
#[tokio::test]
|
||||
async fn interop_list_through_pages_the_source_namespace() -> TestResult {
|
||||
const SOURCE_KEYS: usize = 120;
|
||||
const PAGE_SIZE: i32 = 50;
|
||||
const SOURCE_BODY_LEN: usize = 3;
|
||||
const LOCAL_BODY_LEN: usize = 11;
|
||||
|
||||
let case = OdmInteropEnv::start("interop_list_through_pages_the_source_namespace", "odm-interop-list", |spec| {
|
||||
spec.policy.list_through = true
|
||||
})
|
||||
.await?;
|
||||
let keys: Vec<String> = (0..SOURCE_KEYS).map(|index| format!("page/obj-{index:05}")).collect();
|
||||
let seeds: Vec<SeedObject> = keys
|
||||
.iter()
|
||||
.map(|key| SeedObject::new(key.clone(), payload(SOURCE_BODY_LEN)))
|
||||
.collect();
|
||||
case.seed(&seeds).await?;
|
||||
|
||||
// Five keys the local bucket also holds, with a body length that tells the
|
||||
// two sides apart in the listing.
|
||||
let shared: Vec<String> = keys.iter().step_by(25).cloned().collect();
|
||||
for key in &shared {
|
||||
case.env
|
||||
.client
|
||||
.put_object()
|
||||
.bucket(&case.bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(payload(LOCAL_BODY_LEN)))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut listed: Vec<(String, i64)> = Vec::new();
|
||||
let mut token: Option<String> = None;
|
||||
let mut completed = false;
|
||||
for _ in 0..SOURCE_KEYS {
|
||||
let page = case
|
||||
.env
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(&case.bucket)
|
||||
.prefix("page/")
|
||||
.max_keys(PAGE_SIZE)
|
||||
.set_continuation_token(token.take())
|
||||
.send()
|
||||
.await?;
|
||||
assert!(page.contents().len() <= PAGE_SIZE as usize, "a merged page must not exceed max_keys");
|
||||
for object in page.contents() {
|
||||
listed.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
|
||||
}
|
||||
if !page.is_truncated().unwrap_or(false) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
token = Some(
|
||||
page.next_continuation_token()
|
||||
.ok_or("truncated merged page without a continuation token")?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
assert!(completed, "the merged listing did not terminate");
|
||||
|
||||
let listed_keys: Vec<String> = listed.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(listed_keys, keys, "the merged listing is the source namespace in byte order");
|
||||
for (key, size) in &listed {
|
||||
let expected = if shared.contains(key) {
|
||||
LOCAL_BODY_LEN
|
||||
} else {
|
||||
SOURCE_BODY_LEN
|
||||
};
|
||||
assert_eq!(*size, expected as i64, "{key} must be reported by the side that wins it");
|
||||
}
|
||||
case.finish().await
|
||||
}
|
||||
|
||||
/// A backfill pulls every object under the run's source prefix. The count
|
||||
/// comes from the environment: the fake source caps out around 4,096 stored
|
||||
/// versions, while the MinIO lane runs the full production-shaped batch.
|
||||
#[tokio::test]
|
||||
async fn interop_backfill_pulls_every_source_object() -> TestResult {
|
||||
const KEY_PREFIX: &str = "cold/";
|
||||
let count = interop_backfill_objects()?;
|
||||
assert!(count > 0, "the backfill case needs at least one source object");
|
||||
let case = OdmInteropEnv::start("interop_backfill_pulls_every_source_object", "odm-interop-backfill", |_| {}).await?;
|
||||
|
||||
let objects: Vec<SeedObject> = (0..count)
|
||||
.map(|index| SeedObject::new(format!("{KEY_PREFIX}{index:06}"), Bytes::from(format!("object-{index:06}"))))
|
||||
.collect();
|
||||
case.seed(&objects).await?;
|
||||
|
||||
let started = case.env.start_backfill(&case.bucket, BackfillRequest::default()).await?;
|
||||
assert_eq!(started.status, 200, "start backfill: {}", started.body);
|
||||
|
||||
// One pull is a HEAD plus a GET plus a local write; the ceiling scales
|
||||
// with the object count so raising it in the workflow does not need a
|
||||
// second knob here.
|
||||
let timeout = Duration::from_secs(180 + count as u64 / 5);
|
||||
let done = case
|
||||
.env
|
||||
.wait_for_backfill(&case.bucket, timeout, |job| job["state"] == "completed")
|
||||
.await?;
|
||||
for (name, expected) in [
|
||||
("listed", count as u64),
|
||||
("enqueued", count as u64),
|
||||
("pulled", count as u64),
|
||||
("failed", 0),
|
||||
] {
|
||||
assert_eq!(
|
||||
done[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {done}")),
|
||||
expected,
|
||||
"backfill {name}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
case.env.local_key_count(&case.bucket, KEY_PREFIX).await?,
|
||||
count,
|
||||
"every source object must be stored locally"
|
||||
);
|
||||
case.env
|
||||
.assert_local_present(&case.bucket, &objects[count - 1].key, &objects[count - 1].body)
|
||||
.await;
|
||||
case.finish().await
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Optional merged `ListObjectsV2` (`policy.list_through`, ODM-17,
|
||||
//! rustfs/backlog#2164): full pagination over a source and a local namespace,
|
||||
//! common-prefix union under a delimiter, the continuation-token contract, and
|
||||
//! the two `source_error` behaviours when the source listing fails.
|
||||
|
||||
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env};
|
||||
use crate::fake_s3_target::{FaultAction, Operation};
|
||||
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||
use bytes::Bytes;
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const SOURCE_BUCKET: &str = "odm-list-source";
|
||||
const LIST_HEADER: &str = "x-rustfs-on-demand-migration-list";
|
||||
|
||||
/// Byte lengths that tell a local object from a source one in a listing.
|
||||
const SOURCE_BODY_LEN: usize = 3;
|
||||
const LOCAL_BODY_LEN: usize = 11;
|
||||
|
||||
fn body(len: usize) -> Bytes {
|
||||
vec![b'x'; len].into()
|
||||
}
|
||||
|
||||
/// RustFS migrating `bucket` from `SOURCE_BUCKET` with `list_through` on.
|
||||
async fn list_through_env(bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
|
||||
start_configured_env(bucket, SOURCE_BUCKET, |spec| {
|
||||
spec.policy.list_through = true;
|
||||
adjust(spec);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Every key the bucket lists, walked through the merged continuation token.
|
||||
/// Also returns the size each page reported per key and the page sizes, so a
|
||||
/// caller can assert who won a shared key and that no page exceeded `max_keys`.
|
||||
async fn walk_listing(
|
||||
env: &OdmTestEnv,
|
||||
bucket: &str,
|
||||
delimiter: Option<&str>,
|
||||
max_keys: i32,
|
||||
) -> Result<(Vec<(String, i64)>, Vec<String>, Vec<usize>), BoxError> {
|
||||
let mut objects = Vec::new();
|
||||
let mut prefixes = Vec::new();
|
||||
let mut page_sizes = Vec::new();
|
||||
let mut token: Option<String> = None;
|
||||
for _ in 0..1000 {
|
||||
let page = env
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(max_keys)
|
||||
.set_delimiter(delimiter.map(str::to_string))
|
||||
.set_continuation_token(token.take())
|
||||
.send()
|
||||
.await?;
|
||||
let listed = page.contents().len() + page.common_prefixes().len();
|
||||
page_sizes.push(listed);
|
||||
for object in page.contents() {
|
||||
objects.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
|
||||
}
|
||||
for prefix in page.common_prefixes() {
|
||||
prefixes.push(prefix.prefix().unwrap_or_default().to_string());
|
||||
}
|
||||
if !page.is_truncated().unwrap_or(false) {
|
||||
return Ok((objects, prefixes, page_sizes));
|
||||
}
|
||||
token = Some(
|
||||
page.next_continuation_token()
|
||||
.ok_or("truncated page without a continuation token")?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Err("merged listing did not terminate".into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_merges_the_whole_namespace_across_full_pagination() -> TestResult {
|
||||
let bucket = "odm-list-merge";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
|
||||
// 2000 source keys, 80 of them also local, plus 10 local-only keys that
|
||||
// interleave between source keys ("obj-00010x" sorts after "obj-00010").
|
||||
let source_keys: Vec<String> = (0..2000).map(|index| format!("obj-{index:05}")).collect();
|
||||
let seeds: Vec<SeedObject> = source_keys
|
||||
.iter()
|
||||
.map(|key| SeedObject::new(key.clone(), body(SOURCE_BODY_LEN)))
|
||||
.collect();
|
||||
env.seed_source(SOURCE_BUCKET, &seeds);
|
||||
|
||||
let shared: Vec<String> = source_keys.iter().step_by(25).cloned().collect();
|
||||
let local_only: Vec<String> = (0..10).map(|index| format!("obj-{:05}x", index * 7)).collect();
|
||||
for key in shared.iter().chain(local_only.iter()) {
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
let max_keys = 97;
|
||||
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, None, max_keys).await?;
|
||||
assert!(prefixes.is_empty(), "no delimiter means no common prefixes");
|
||||
|
||||
let mut expected: Vec<String> = source_keys.iter().chain(local_only.iter()).cloned().collect();
|
||||
expected.sort();
|
||||
expected.dedup();
|
||||
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(listed, expected, "the merged listing is the sorted, deduplicated union");
|
||||
assert!(
|
||||
page_sizes.iter().all(|size| *size <= max_keys as usize),
|
||||
"no page may exceed max_keys: {page_sizes:?}"
|
||||
);
|
||||
|
||||
let shared_sizes: Vec<i64> = objects
|
||||
.iter()
|
||||
.filter(|(key, _)| shared.contains(key))
|
||||
.map(|(_, size)| *size)
|
||||
.collect();
|
||||
assert_eq!(shared_sizes.len(), shared.len(), "every shared key is listed exactly once");
|
||||
assert!(
|
||||
shared_sizes.iter().all(|size| *size == LOCAL_BODY_LEN as i64),
|
||||
"the local object wins a key both sides hold"
|
||||
);
|
||||
let source_sizes: Vec<i64> = objects
|
||||
.iter()
|
||||
.filter(|(key, _)| !shared.contains(key) && !local_only.contains(key))
|
||||
.map(|(_, size)| *size)
|
||||
.collect();
|
||||
assert!(
|
||||
source_sizes.iter().all(|size| *size == SOURCE_BODY_LEN as i64),
|
||||
"source-only keys report the source's own size"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_unions_common_prefixes_under_a_delimiter() -> TestResult {
|
||||
let bucket = "odm-list-delimiter";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("p1/a", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("p1/b", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("p2/a", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("top-s", body(SOURCE_BODY_LEN)),
|
||||
],
|
||||
);
|
||||
for key in ["p1/c", "p3/a", "top-l"] {
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
// A page size of two forces the prefix union to survive page boundaries.
|
||||
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, Some("/"), 2).await?;
|
||||
assert_eq!(prefixes, vec!["p1/", "p2/", "p3/"], "prefixes are unioned and deduplicated");
|
||||
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(listed, vec!["top-l", "top-s"]);
|
||||
assert!(page_sizes.iter().all(|size| *size <= 2), "{page_sizes:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_propagates_a_source_listing_failure() -> TestResult {
|
||||
let bucket = "odm-list-propagate";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("local")
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
env.source
|
||||
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
|
||||
let failure = env
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("propagate must surface the source failure");
|
||||
let failure = failure.into_service_error();
|
||||
assert_eq!(failure.meta().code(), Some("SourceUnavailable"), "{failure:?}");
|
||||
|
||||
// The next listing sees a healthy source again and merges both sides.
|
||||
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
|
||||
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(listed, vec!["local", "remote"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_degrades_to_local_only_under_the_not_found_policy() -> TestResult {
|
||||
let bucket = "odm-list-degrade";
|
||||
let env = list_through_env(bucket, |spec| spec.policy.source_error = "not_found".to_string()).await?;
|
||||
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("local")
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
env.source
|
||||
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
|
||||
let degraded = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
|
||||
assert_eq!(degraded.status, 200, "{}", String::from_utf8_lossy(°raded.body));
|
||||
assert_eq!(
|
||||
degraded.header(LIST_HEADER),
|
||||
Some("local_only"),
|
||||
"a degraded listing must say so in the response header"
|
||||
);
|
||||
let xml = String::from_utf8_lossy(°raded.body).to_string();
|
||||
assert!(xml.contains("<Key>local</Key>"), "{xml}");
|
||||
assert!(!xml.contains("<Key>remote</Key>"), "a degraded listing shows local state only: {xml}");
|
||||
|
||||
let healthy = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
|
||||
assert_eq!(healthy.status, 200);
|
||||
assert_eq!(healthy.header(LIST_HEADER), None, "a healthy merge carries no degradation marker");
|
||||
assert!(String::from_utf8_lossy(&healthy.body).contains("<Key>remote</Key>"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
|
||||
let bucket = "odm-list-token";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("a", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("b", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("c", body(SOURCE_BODY_LEN)),
|
||||
],
|
||||
);
|
||||
|
||||
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
|
||||
let token = page.next_continuation_token().ok_or("first page must be truncated")?;
|
||||
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
|
||||
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
|
||||
|
||||
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":2").as_bytes());
|
||||
let rejected = env
|
||||
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}"))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
rejected.status,
|
||||
400,
|
||||
"a bumped token version is a client error: {}",
|
||||
String::from_utf8_lossy(&rejected.body)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_merged_token_keeps_paginating_after_list_through_is_turned_off() -> TestResult {
|
||||
let bucket = "odm-list-token-off";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("s1", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("s2", body(SOURCE_BODY_LEN)),
|
||||
],
|
||||
);
|
||||
for key in ["l1", "l2"] {
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
|
||||
assert_eq!(page.contents()[0].key(), Some("l1"));
|
||||
let token = page
|
||||
.next_continuation_token()
|
||||
.ok_or("first page must be truncated")?
|
||||
.to_string();
|
||||
|
||||
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
|
||||
spec.policy.list_through = false;
|
||||
env.configure_and_wait(bucket, &spec).await?;
|
||||
|
||||
let resumed = env
|
||||
.client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.max_keys(10)
|
||||
.continuation_token(token)
|
||||
.send()
|
||||
.await?;
|
||||
let listed: Vec<&str> = resumed.contents().iter().filter_map(|object| object.key()).collect();
|
||||
assert_eq!(listed, vec!["l2"], "a merged token falls back to its local cursor");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_local_delete_marker_hides_the_source_key_from_a_merged_listing() -> TestResult {
|
||||
let bucket = "odm-list-delete-marker";
|
||||
let env = list_through_env(bucket, |_| {}).await?;
|
||||
env.client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
env.seed_source(
|
||||
SOURCE_BUCKET,
|
||||
&[
|
||||
SeedObject::new("kept", body(SOURCE_BODY_LEN)),
|
||||
SeedObject::new("shadowed", body(SOURCE_BODY_LEN)),
|
||||
],
|
||||
);
|
||||
|
||||
env.client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("shadowed")
|
||||
.body(body(LOCAL_BODY_LEN).into())
|
||||
.send()
|
||||
.await?;
|
||||
env.client.delete_object().bucket(bucket).key("shadowed").send().await?;
|
||||
|
||||
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
|
||||
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
|
||||
assert_eq!(
|
||||
listed,
|
||||
vec!["kept"],
|
||||
"a local delete marker shadows the source key the same way it does on GET"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! On-demand migration (ODM) end-to-end suite (rustfs/backlog#2147).
|
||||
//!
|
||||
//! `common` is the shared environment: one RustFS under test, one programmable
|
||||
//! fake S3 source, admin-API wrappers, seeding and local-state assertions.
|
||||
//! `harness_self_test` proves the harness itself; `get_basic_test` covers the
|
||||
//! GET read-through (rustfs/backlog#2156) and `backfill_test` the background
|
||||
//! backfill job (ODM-12, rustfs/backlog#2159); `list_through_test` covers the
|
||||
//! optional merged `ListObjectsV2` (ODM-17, rustfs/backlog#2164). The fault, concurrency,
|
||||
//! interaction and real-source matrix is rustfs/backlog#2158; its lane split
|
||||
//! lives in `.config/nextest.toml` (fault / concurrency / real source run
|
||||
//! nightly, the rest in the merge lane). `interop_test` is the provider
|
||||
//! interoperability lane (ODM-20, rustfs/backlog#2167): the same case bodies
|
||||
//! against the fake source locally and against a real provider named by the
|
||||
//! environment in `.github/workflows/on-demand-migration-interop.yml`.
|
||||
|
||||
pub mod common;
|
||||
|
||||
mod backfill_test;
|
||||
mod concurrency_test;
|
||||
mod fault_test;
|
||||
mod get_basic_test;
|
||||
mod harness_self_test;
|
||||
mod interaction_test;
|
||||
mod interop_test;
|
||||
mod list_through_test;
|
||||
mod real_source_test;
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! On-demand migration against a real RustFS source (rustfs/backlog#2158).
|
||||
//!
|
||||
//! These cases start a second (and, for the loop guard, a third) RustFS
|
||||
//! process, so they carry the `_real_single_node` marker and run in the
|
||||
//! nightly lane. A real source keeps no request journal, so "the source was
|
||||
//! not consulted" is proven by removing the object from the source and
|
||||
//! showing the read still succeeds, or by pointing the *second* server at a
|
||||
//! fake source whose journal must stay empty.
|
||||
|
||||
use super::common::{
|
||||
AdminResponse, BoxError, ODM_ADMIN_ROUTE, OdmSourceSpec, OdmTestEnv, RawResponse, SeedObject, start_source_rustfs,
|
||||
start_source_rustfs_with_odm,
|
||||
};
|
||||
use crate::common::{RustFSTestEnvironment, signed_request};
|
||||
use crate::fake_s3_target::{BucketMode, Operation};
|
||||
use aws_sdk_s3::Client;
|
||||
use bytes::Bytes;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type TestResult = Result<(), BoxError>;
|
||||
|
||||
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
|
||||
/// Reinstalled configurations are applied asynchronously; every phase polls
|
||||
/// for the new behavior instead of sleeping.
|
||||
const APPLY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SETTLE: Duration = Duration::from_secs(60);
|
||||
|
||||
fn payload(len: usize) -> Bytes {
|
||||
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
|
||||
}
|
||||
|
||||
/// `PUT /rustfs/admin/v3/on-demand-migration/{bucket}` against any server,
|
||||
/// not just the one under test.
|
||||
async fn configure_odm(env: &RustFSTestEnvironment, bucket: &str, spec: &OdmSourceSpec) -> Result<AdminResponse, BoxError> {
|
||||
let url = format!("{}{ODM_ADMIN_ROUTE}/{bucket}", env.url);
|
||||
let body = serde_json::to_vec(&spec.to_json())?;
|
||||
let response = signed_request(
|
||||
http::Method::PUT,
|
||||
&url,
|
||||
&env.access_key,
|
||||
&env.secret_key,
|
||||
Some(body),
|
||||
Some("application/json"),
|
||||
)
|
||||
.await?;
|
||||
Ok(AdminResponse {
|
||||
status: response.status().as_u16(),
|
||||
body: response.text().await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn put_object(client: &Client, bucket: &str, key: &str, body: Bytes) -> TestResult {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from(body))
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls a read against the server under test until it answers `expected`.
|
||||
/// This is how a reinstalled configuration is waited for when the source
|
||||
/// keeps no journal to probe.
|
||||
async fn wait_for_get_status(env: &OdmTestEnv, bucket: &str, key: &str, expected: u16) -> Result<RawResponse, BoxError> {
|
||||
let deadline = Instant::now() + APPLY_TIMEOUT;
|
||||
loop {
|
||||
let response = env.raw_get(bucket, key).await?;
|
||||
if response.status == expected {
|
||||
return Ok(response);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"GET {bucket}/{key} stayed at {} instead of {expected}: {}",
|
||||
response.status,
|
||||
String::from_utf8_lossy(&response.body)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Case 20: a second RustFS as the migration source — the pull, a HEAD
|
||||
/// passthrough, a Range read, and both prefix knobs.
|
||||
#[tokio::test]
|
||||
async fn test_odm_rustfs_source_serves_pull_head_range_and_prefixes_real_single_node() -> TestResult {
|
||||
let bucket = "odm-real-source";
|
||||
let source_bucket = "odm-real-origin";
|
||||
let source = start_source_rustfs().await?;
|
||||
let source_client = source.create_s3_client();
|
||||
source.create_test_bucket(source_bucket).await?;
|
||||
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.rustfs.create_test_bucket(bucket).await?;
|
||||
let spec = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
|
||||
let configured = configure_odm(&env.rustfs, bucket, &spec).await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
|
||||
// Phase 1: a miss is pulled and stored; removing it from the source
|
||||
// afterwards proves the second read never goes back to the source.
|
||||
let pulled_key = "real/pulled.bin";
|
||||
let pulled_body = payload(256 * 1024);
|
||||
put_object(&source_client, source_bucket, pulled_key, pulled_body.clone()).await?;
|
||||
let pulled = wait_for_get_status(&env, bucket, pulled_key, 200).await?;
|
||||
assert_eq!(pulled.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(pulled.body, pulled_body, "the client receives the source bytes");
|
||||
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?, "the pull must store the object");
|
||||
source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key(pulled_key)
|
||||
.send()
|
||||
.await?;
|
||||
let local = env.raw_get(bucket, pulled_key).await?;
|
||||
assert_eq!(local.status, 200, "{}", String::from_utf8_lossy(&local.body));
|
||||
assert_eq!(local.header(ODM_RESPONSE_HEADER), None, "a local hit is not marked");
|
||||
assert_eq!(local.body, pulled_body, "the object is served from the local copy");
|
||||
|
||||
// Phase 2: HEAD proxies metadata without storing anything.
|
||||
let head_key = "real/head-only.bin";
|
||||
let head_body = payload(9_000);
|
||||
put_object(&source_client, source_bucket, head_key, head_body.clone()).await?;
|
||||
let head = env.raw_object_request(http::Method::HEAD, bucket, head_key, &[]).await?;
|
||||
assert_eq!(head.status, 200, "HEAD must be answered from the source");
|
||||
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(head.header("content-length"), Some(head_body.len().to_string().as_str()));
|
||||
env.assert_local_absent(bucket, head_key).await;
|
||||
|
||||
// Phase 3: a Range read is passed through as a 206.
|
||||
let range_key = "real/range.bin";
|
||||
let range_body = payload(100_000);
|
||||
put_object(&source_client, source_bucket, range_key, range_body.clone()).await?;
|
||||
let ranged = env
|
||||
.raw_object_request(http::Method::GET, bucket, range_key, &[("range", "bytes=100-199")])
|
||||
.await?;
|
||||
assert_eq!(ranged.status, 206, "{}", String::from_utf8_lossy(&ranged.body));
|
||||
assert_eq!(ranged.header("content-range"), Some("bytes 100-199/100000"));
|
||||
assert_eq!(ranged.body, range_body.slice(100..200));
|
||||
|
||||
// Phase 4: `filter.prefix` decides which local keys may consult the
|
||||
// source at all.
|
||||
let allowed_key = "allowed/doc.bin";
|
||||
let denied_key = "denied/doc.bin";
|
||||
let filtered_body = payload(4_096);
|
||||
put_object(&source_client, source_bucket, allowed_key, filtered_body.clone()).await?;
|
||||
put_object(&source_client, source_bucket, denied_key, filtered_body.clone()).await?;
|
||||
let mut filtered = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
|
||||
filtered.filter.prefix = Some("allowed/".to_string());
|
||||
let response = configure_odm(&env.rustfs, bucket, &filtered).await?;
|
||||
assert_eq!(response.status, 200, "{}", response.body);
|
||||
let denied = wait_for_get_status(&env, bucket, denied_key, 404).await?;
|
||||
assert_eq!(denied.header(ODM_RESPONSE_HEADER), None);
|
||||
env.assert_local_absent(bucket, denied_key).await;
|
||||
let allowed = wait_for_get_status(&env, bucket, allowed_key, 200).await?;
|
||||
assert_eq!(allowed.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(allowed.body, filtered_body);
|
||||
|
||||
// Phase 5: `filter.source_prefix` rewrites the key on the way out, so a
|
||||
// local key resolves to a different key in the source bucket.
|
||||
let rewritten_key = "rewritten/doc.bin";
|
||||
let rewritten_body = payload(2_048);
|
||||
put_object(&source_client, source_bucket, &format!("archive/{rewritten_key}"), rewritten_body.clone()).await?;
|
||||
let mut rewriting = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
|
||||
rewriting.filter.source_prefix = Some("archive/".to_string());
|
||||
let response = configure_odm(&env.rustfs, bucket, &rewriting).await?;
|
||||
assert_eq!(response.status, 200, "{}", response.body);
|
||||
let rewritten = wait_for_get_status(&env, bucket, rewritten_key, 200).await?;
|
||||
assert_eq!(rewritten.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(rewritten.body, rewritten_body, "the source prefix is prepended to the local key");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Case 21: two migrating servers pointed at each other must not build a
|
||||
/// request loop. The middle server also has a fake source of its own, whose
|
||||
/// journal is the evidence: a key it would happily fetch for a direct client
|
||||
/// is never fetched for a request that arrived with the anti-loop marker.
|
||||
#[tokio::test]
|
||||
async fn test_odm_chained_sources_stop_at_the_loop_guard_real_single_node() -> TestResult {
|
||||
let bucket = "odm-loop-guard";
|
||||
let fake_bucket = "odm-loop-fake";
|
||||
let env = OdmTestEnv::start().await?;
|
||||
env.source.create_bucket_with_mode(fake_bucket, BucketMode::Unversioned);
|
||||
env.rustfs.create_test_bucket(bucket).await?;
|
||||
|
||||
let middle = start_source_rustfs_with_odm().await?;
|
||||
let middle_client = middle.create_s3_client();
|
||||
middle.create_test_bucket(bucket).await?;
|
||||
let middle_spec = OdmSourceSpec::for_fake_source(&env.source, fake_bucket);
|
||||
let configured = configure_odm(&middle, bucket, &middle_spec).await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
|
||||
let chained = OdmSourceSpec::for_rustfs_source(&middle, bucket);
|
||||
let configured = configure_odm(&env.rustfs, bucket, &chained).await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
|
||||
// The first hop works: an object that only the middle server holds is
|
||||
// migrated to the server under test.
|
||||
let present_key = "loop/present.bin";
|
||||
let present_body = payload(16 * 1024);
|
||||
put_object(&middle_client, bucket, present_key, present_body.clone()).await?;
|
||||
let served = wait_for_get_status(&env, bucket, present_key, 200).await?;
|
||||
assert_eq!(served.header(ODM_RESPONSE_HEADER), Some("source"));
|
||||
assert_eq!(served.body, present_body, "the first hop serves the middle server's object");
|
||||
|
||||
// The second hop does not: this key exists only on the middle server's
|
||||
// own source, and the anti-loop marker stops the chain there.
|
||||
let guarded_key = "loop/chain-guard.bin";
|
||||
let guarded_body = payload(8 * 1024);
|
||||
env.seed_source(fake_bucket, &[SeedObject::new(guarded_key, guarded_body.clone())]);
|
||||
let guarded = env.raw_get(bucket, guarded_key).await?;
|
||||
assert_eq!(guarded.status, 404, "{}", String::from_utf8_lossy(&guarded.body));
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, guarded_key),
|
||||
0,
|
||||
"a chained request must not reach a third source"
|
||||
);
|
||||
assert_eq!(env.source.count_requests(Operation::GetObject, guarded_key), 0);
|
||||
|
||||
// Proof that the guard, and not a broken configuration, is what stopped
|
||||
// it: the same key served directly by the middle server does reach the
|
||||
// fake source.
|
||||
let direct = middle_client.get_object().bucket(bucket).key(guarded_key).send().await?;
|
||||
assert_eq!(direct.body.collect().await?.into_bytes(), guarded_body);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::GetObject, guarded_key),
|
||||
1,
|
||||
"an unmarked request does consult the middle server's source"
|
||||
);
|
||||
|
||||
// Now make the pair mutual and prove the read still terminates.
|
||||
let mutual = OdmSourceSpec::for_rustfs_source(&env.rustfs, bucket);
|
||||
let configured = configure_odm(&middle, bucket, &mutual).await?;
|
||||
assert_eq!(configured.status, 200, "{}", configured.body);
|
||||
|
||||
let mutual_key = "loop/mutual.bin";
|
||||
let started = Instant::now();
|
||||
let response = wait_for_get_status(&env, bucket, mutual_key, 404).await?;
|
||||
assert_eq!(response.header(ODM_RESPONSE_HEADER), None);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"a mutual configuration must not loop, took {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
assert_eq!(
|
||||
env.source.count_requests(Operation::HeadObject, mutual_key),
|
||||
0,
|
||||
"the fake source is out of the chain once the pair is mutual"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -856,6 +856,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn scanner_dirty_usage_snapshot(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn background_heal_status(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::BackgroundHealStatusRequest>,
|
||||
@@ -904,6 +911,13 @@ impl NodeService for MinimalLockNodeService {
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadTransitionTierConfigResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn tier_daily_stats(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::TierDailyStatsRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::TierDailyStatsResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a gRPC lock server on a random port
|
||||
|
||||
@@ -21,5 +21,6 @@ mod head_tls_bodyless_test;
|
||||
mod lifecycle;
|
||||
mod lock;
|
||||
mod node_interact_test;
|
||||
mod s3_select_compression;
|
||||
mod sql;
|
||||
mod tiering;
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
#![cfg(test)]
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use async_compression::tokio::write::BzEncoder;
|
||||
use aws_sdk_s3::{
|
||||
Client,
|
||||
error::ProvideErrorMetadata,
|
||||
operation::select_object_content::{SelectObjectContentOutput, builders::SelectObjectContentFluentBuilder},
|
||||
types::{
|
||||
CompressionType, CsvInput, CsvOutput, ExpressionType, FileHeaderInfo, InputSerialization, JsonInput, JsonOutput,
|
||||
JsonType, OutputSerialization, SelectObjectContentEventStream,
|
||||
},
|
||||
};
|
||||
use aws_smithy_types::event_stream::RawMessage;
|
||||
use bytes::Bytes;
|
||||
use flate2::{Compression, write::GzEncoder};
|
||||
use std::{error::Error, io::Cursor, time::Duration};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const BUCKET: &str = "s3-select-compression";
|
||||
const SELECT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
type TestResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
||||
|
||||
async fn create_test_environment(extra_env: &[(&str, &str)]) -> TestResult<(RustFSTestEnvironment, Client)> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], extra_env).await?;
|
||||
let client = env.create_s3_client();
|
||||
client.create_bucket().bucket(BUCKET).send().await?;
|
||||
Ok((env, client))
|
||||
}
|
||||
|
||||
async fn put_object(client: &Client, key: &str, body: &[u8]) -> TestResult<()> {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.body(Bytes::copy_from_slice(body).into())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gzip(input: &[u8]) -> TestResult<Vec<u8>> {
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, input)?;
|
||||
Ok(encoder.finish()?)
|
||||
}
|
||||
|
||||
async fn bzip2(input: &[u8]) -> TestResult<Vec<u8>> {
|
||||
let mut encoder = BzEncoder::new(Cursor::new(Vec::new()));
|
||||
encoder.write_all(input).await?;
|
||||
encoder.shutdown().await?;
|
||||
Ok(encoder.into_inner().into_inner())
|
||||
}
|
||||
|
||||
fn csv_select_request(
|
||||
client: &Client,
|
||||
key: &str,
|
||||
compression: CompressionType,
|
||||
expression: &str,
|
||||
) -> SelectObjectContentFluentBuilder {
|
||||
client
|
||||
.select_object_content()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.expression(expression)
|
||||
.expression_type(ExpressionType::Sql)
|
||||
.input_serialization(
|
||||
InputSerialization::builder()
|
||||
.compression_type(compression)
|
||||
.csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build())
|
||||
.build(),
|
||||
)
|
||||
.output_serialization(OutputSerialization::builder().csv(CsvOutput::builder().build()).build())
|
||||
}
|
||||
|
||||
fn json_select_request(
|
||||
client: &Client,
|
||||
key: &str,
|
||||
compression: CompressionType,
|
||||
json_type: JsonType,
|
||||
) -> SelectObjectContentFluentBuilder {
|
||||
client
|
||||
.select_object_content()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.expression("SELECT name FROM S3Object")
|
||||
.expression_type(ExpressionType::Sql)
|
||||
.input_serialization(
|
||||
InputSerialization::builder()
|
||||
.compression_type(compression)
|
||||
.json(JsonInput::builder().set_type(Some(json_type)).build())
|
||||
.build(),
|
||||
)
|
||||
.output_serialization(OutputSerialization::builder().json(JsonOutput::builder().build()).build())
|
||||
}
|
||||
|
||||
async fn collect_success(
|
||||
mut response: SelectObjectContentOutput,
|
||||
compressed_bytes: usize,
|
||||
processed_bytes: usize,
|
||||
) -> TestResult<Vec<u8>> {
|
||||
tokio::time::timeout(SELECT_RESPONSE_TIMEOUT, async move {
|
||||
let mut records = Vec::new();
|
||||
let mut stats = None;
|
||||
let mut saw_end = false;
|
||||
|
||||
while let Some(event) = response.payload.recv().await? {
|
||||
assert!(!saw_end, "Select emitted an event after End");
|
||||
match event {
|
||||
SelectObjectContentEventStream::Records(event) => {
|
||||
assert!(stats.is_none(), "Select emitted Records after Stats");
|
||||
if let Some(payload) = event.payload {
|
||||
records.extend_from_slice(payload.as_ref());
|
||||
}
|
||||
}
|
||||
SelectObjectContentEventStream::Stats(event) => {
|
||||
assert!(stats.is_none(), "Select emitted more than one Stats event");
|
||||
stats = event.details;
|
||||
}
|
||||
SelectObjectContentEventStream::End(_) => {
|
||||
assert!(stats.is_some(), "Select emitted End before Stats");
|
||||
saw_end = true;
|
||||
}
|
||||
_ => assert!(stats.is_none(), "Select emitted a non-terminal event after Stats"),
|
||||
}
|
||||
}
|
||||
|
||||
let stats = stats.ok_or("Select response ended without a Stats event")?;
|
||||
assert_eq!(stats.bytes_scanned(), Some(i64::try_from(compressed_bytes)?));
|
||||
assert_eq!(stats.bytes_processed(), Some(i64::try_from(processed_bytes)?));
|
||||
assert_eq!(stats.bytes_returned(), Some(i64::try_from(records.len())?));
|
||||
assert!(saw_end, "Select response ended without an End event");
|
||||
Ok::<_, Box<dyn Error + Send + Sync>>(records)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| -> Box<dyn Error + Send + Sync> { "Select response timed out".into() })?
|
||||
}
|
||||
|
||||
async fn assert_truncated_stream_failure(mut response: SelectObjectContentOutput) -> TestResult<()> {
|
||||
tokio::time::timeout(SELECT_RESPONSE_TIMEOUT, async move {
|
||||
loop {
|
||||
match response.payload.recv().await {
|
||||
Err(error) => {
|
||||
// S3 Select request-level errors use `error` frames, which this SDK version exposes as raw response errors.
|
||||
if let Some(code) = error.code() {
|
||||
assert_eq!(code, "TruncatedInput", "unexpected modeled event-stream error: {error:?}");
|
||||
} else if let aws_sdk_s3::error::SdkError::ResponseError(context) = &error
|
||||
&& let RawMessage::Decoded(message) = context.raw()
|
||||
{
|
||||
let header = |name: &str| {
|
||||
message
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|header| header.name().as_str() == name)
|
||||
.and_then(|header| header.value().as_string().ok())
|
||||
.map(|value| value.as_str())
|
||||
};
|
||||
assert_eq!(header(":message-type"), Some("error"));
|
||||
assert_eq!(header(":error-code"), Some("TruncatedInput"));
|
||||
} else {
|
||||
panic!("unexpected event-stream error: {error:?}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Some(SelectObjectContentEventStream::Stats(_))) | Ok(Some(SelectObjectContentEventStream::End(_))) => {
|
||||
return Err("truncated compressed input reached a success terminal event".into());
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => return Err("truncated compressed input ended without an error event".into()),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| -> Box<dyn Error + Send + Sync> { "truncated Select response timed out".into() })?
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_select_object_content_compressed_csv_and_json() -> TestResult<()> {
|
||||
const CSV: &[u8] = b"name,age\nAlice,30\nBob,25\n";
|
||||
const JSON_LINES: &[u8] = b"{\"name\":\"Alice\"}\n{\"name\":\"Bob\"}\n";
|
||||
const JSON_DOCUMENT: &[u8] = br#"[{"name":"Alice"},{"name":"Bob"}]"#;
|
||||
|
||||
let (_env, client) = create_test_environment(&[]).await?;
|
||||
|
||||
let gzip_csv = gzip(CSV)?;
|
||||
put_object(&client, "records.csv.gz", &gzip_csv).await?;
|
||||
let gzip_csv_records = collect_success(
|
||||
csv_select_request(&client, "records.csv.gz", CompressionType::Gzip, "SELECT * FROM S3Object")
|
||||
.send()
|
||||
.await?,
|
||||
gzip_csv.len(),
|
||||
CSV.len(),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(gzip_csv_records, b"Alice,30\nBob,25\n");
|
||||
|
||||
let bzip_csv = bzip2(CSV).await?;
|
||||
put_object(&client, "records.csv.bz2", &bzip_csv).await?;
|
||||
let bzip_csv_records = collect_success(
|
||||
csv_select_request(&client, "records.csv.bz2", CompressionType::Bzip2, "SELECT * FROM S3Object")
|
||||
.send()
|
||||
.await?,
|
||||
bzip_csv.len(),
|
||||
CSV.len(),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(bzip_csv_records, gzip_csv_records);
|
||||
|
||||
let gzip_json_lines = gzip(JSON_LINES)?;
|
||||
put_object(&client, "json-lines", &gzip_json_lines).await?;
|
||||
let gzip_json_records = collect_success(
|
||||
json_select_request(&client, "json-lines", CompressionType::Gzip, JsonType::Lines)
|
||||
.send()
|
||||
.await?,
|
||||
gzip_json_lines.len(),
|
||||
JSON_LINES.len(),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(gzip_json_records, JSON_LINES);
|
||||
|
||||
let bzip_json_lines = bzip2(JSON_LINES).await?;
|
||||
put_object(&client, "records.jsonl.bz2", &bzip_json_lines).await?;
|
||||
let bzip_json_records = collect_success(
|
||||
json_select_request(&client, "records.jsonl.bz2", CompressionType::Bzip2, JsonType::Lines)
|
||||
.send()
|
||||
.await?,
|
||||
bzip_json_lines.len(),
|
||||
JSON_LINES.len(),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(bzip_json_records, gzip_json_records);
|
||||
|
||||
let gzip_json_document = gzip(JSON_DOCUMENT)?;
|
||||
put_object(&client, "document.json.gz", &gzip_json_document).await?;
|
||||
let document_records = collect_success(
|
||||
json_select_request(&client, "document.json.gz", CompressionType::Gzip, JsonType::Document)
|
||||
.send()
|
||||
.await?,
|
||||
gzip_json_document.len(),
|
||||
JSON_DOCUMENT.len(),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(document_records, JSON_LINES);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_select_object_content_invalid_compressed_stream_fails() -> TestResult<()> {
|
||||
const CSV: &[u8] = b"name\nAlice\n";
|
||||
|
||||
let (_env, client) = create_test_environment(&[]).await?;
|
||||
|
||||
put_object(&client, "invalid.csv.gz", CSV).await?;
|
||||
let invalid = csv_select_request(&client, "invalid.csv.gz", CompressionType::Gzip, "SELECT * FROM S3Object")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("invalid GZIP header must fail before streaming");
|
||||
assert_eq!(
|
||||
invalid.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidCompressionFormat")
|
||||
);
|
||||
|
||||
put_object(&client, "empty.csv.gz", b"").await?;
|
||||
let empty = csv_select_request(&client, "empty.csv.gz", CompressionType::Gzip, "SELECT * FROM S3Object")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("empty GZIP input must fail as truncated");
|
||||
assert_eq!(empty.as_service_error().and_then(ProvideErrorMetadata::code), Some("TruncatedInput"));
|
||||
|
||||
let mut truncated = bzip2(CSV).await?;
|
||||
truncated.pop();
|
||||
put_object(&client, "truncated.csv.bz2", &truncated).await?;
|
||||
let truncated = csv_select_request(&client, "truncated.csv.bz2", CompressionType::Bzip2, "SELECT * FROM S3Object")
|
||||
.send()
|
||||
.await?;
|
||||
assert_truncated_stream_failure(truncated).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_select_object_content_compressed_disconnect_releases_query() -> TestResult<()> {
|
||||
const OBJECT: &str = "disconnect.csv.gz";
|
||||
const ROWS: usize = 16 * 1024;
|
||||
const RELEASE_ATTEMPTS: usize = 20;
|
||||
const RELEASE_BACKOFF: Duration = Duration::from_millis(25);
|
||||
|
||||
let (_env, client) = create_test_environment(&[("RUSTFS_S3SELECT_MAX_CONCURRENT_QUERIES", "1")]).await?;
|
||||
let row = format!("{}\n", "x".repeat(1023));
|
||||
let mut body = Vec::with_capacity("value\n".len() + ROWS * row.len());
|
||||
body.extend_from_slice(b"value\n");
|
||||
for _ in 0..ROWS {
|
||||
body.extend_from_slice(row.as_bytes());
|
||||
}
|
||||
let compressed = gzip(&body)?;
|
||||
put_object(&client, OBJECT, &compressed).await?;
|
||||
|
||||
let first = csv_select_request(&client, OBJECT, CompressionType::Gzip, "SELECT * FROM S3Object")
|
||||
.send()
|
||||
.await?;
|
||||
let saturated = csv_select_request(&client, OBJECT, CompressionType::Gzip, "SELECT * FROM S3Object")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("the unread compressed response should retain the only query permit");
|
||||
assert_eq!(saturated.as_service_error().and_then(ProvideErrorMetadata::code), Some("SlowDown"));
|
||||
|
||||
drop(first);
|
||||
let second = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
for attempt in 0..RELEASE_ATTEMPTS {
|
||||
match csv_select_request(&client, OBJECT, CompressionType::Gzip, "SELECT * FROM S3Object")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => return Ok::<_, Box<dyn Error + Send + Sync>>(response),
|
||||
Err(error)
|
||||
if error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("SlowDown")
|
||||
&& attempt + 1 < RELEASE_ATTEMPTS =>
|
||||
{
|
||||
tokio::time::sleep(RELEASE_BACKOFF).await;
|
||||
}
|
||||
Err(error) if error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("SlowDown") => {
|
||||
return Err("disconnected compressed Select retained its query permit".into());
|
||||
}
|
||||
Err(error) => return Err(format!("unexpected Select error after disconnect: {error}").into()),
|
||||
}
|
||||
}
|
||||
Err("query permit release retry loop ended unexpectedly".into())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| -> Box<dyn Error + Send + Sync> { "compressed Select did not release its query permit".into() })??;
|
||||
drop(second);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,7 +17,8 @@ use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::types::{
|
||||
CsvInput, CsvOutput, ExpressionType, FileHeaderInfo, InputSerialization, JsonInput, JsonOutput, JsonType, OutputSerialization,
|
||||
CsvInput, CsvOutput, ExpressionType, FileHeaderInfo, InputSerialization, JsonInput, JsonOutput, JsonType,
|
||||
OutputSerialization, RequestProgress,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use std::error::Error;
|
||||
@@ -26,6 +27,9 @@ use std::time::Duration;
|
||||
const BUCKET: &str = "test-sql-bucket";
|
||||
const CSV_OBJECT: &str = "test-data.csv";
|
||||
const JSON_OBJECT: &str = "test-data.json";
|
||||
const JSON_DOCUMENT_OBJECT: &str = "nested-data.json";
|
||||
const JSON_ROOT_ARRAY_OBJECT: &str = "root-array.json";
|
||||
const JSON_ROOT_SCALAR_ARRAY_OBJECT: &str = "root-scalars.json";
|
||||
const SELECT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
type TestResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
||||
@@ -73,6 +77,69 @@ async fn upload_test_json(client: &Client) -> TestResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_nested_json_document(client: &Client) -> TestResult<()> {
|
||||
let json_data = r#"{"departments":[{"employees":[{"name":"Alice","active":true},{"name":"Bob","active":false}]},{"employees":[{"name":"Charlie","active":true}]}]}"#;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(JSON_DOCUMENT_OBJECT)
|
||||
.body(Bytes::from_static(json_data.as_bytes()).into())
|
||||
.send()
|
||||
.await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(JSON_ROOT_ARRAY_OBJECT)
|
||||
.body(Bytes::from_static(br#"[{"name":"Alice"},{"name":"Bob"}]"#).into())
|
||||
.send()
|
||||
.await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(JSON_ROOT_SCALAR_ARRAY_OBJECT)
|
||||
.body(Bytes::from_static(b"[1,2]").into())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn select_json_document(client: &Client, key: &str, expression: &str) -> TestResult<String> {
|
||||
let response = client
|
||||
.select_object_content()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.expression(expression)
|
||||
.expression_type(ExpressionType::Sql)
|
||||
.input_serialization(
|
||||
InputSerialization::builder()
|
||||
.json(JsonInput::builder().set_type(Some(JsonType::Document)).build())
|
||||
.build(),
|
||||
)
|
||||
.output_serialization(OutputSerialization::builder().json(JsonOutput::builder().build()).build())
|
||||
.send()
|
||||
.await?;
|
||||
process_select_response(response).await
|
||||
}
|
||||
|
||||
fn csv_select_request(
|
||||
client: &Client,
|
||||
key: &str,
|
||||
) -> aws_sdk_s3::operation::select_object_content::builders::SelectObjectContentFluentBuilder {
|
||||
client
|
||||
.select_object_content()
|
||||
.bucket(BUCKET)
|
||||
.key(key)
|
||||
.expression("SELECT * FROM S3Object")
|
||||
.expression_type(ExpressionType::Sql)
|
||||
.input_serialization(
|
||||
InputSerialization::builder()
|
||||
.csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build())
|
||||
.build(),
|
||||
)
|
||||
.output_serialization(OutputSerialization::builder().csv(CsvOutput::builder().build()).build())
|
||||
}
|
||||
|
||||
async fn process_select_response(
|
||||
mut event_stream: aws_sdk_s3::operation::select_object_content::SelectObjectContentOutput,
|
||||
) -> TestResult<String> {
|
||||
@@ -104,6 +171,209 @@ async fn process_select_response(
|
||||
.map_err(|_| -> Box<dyn Error + Send + Sync> { "Select response timed out".into() })?
|
||||
}
|
||||
|
||||
async fn assert_input_byte_stats(
|
||||
client: &Client,
|
||||
object: &str,
|
||||
body: &[u8],
|
||||
expression: &str,
|
||||
input_serialization: InputSerialization,
|
||||
output_serialization: OutputSerialization,
|
||||
progress_enabled: bool,
|
||||
) -> TestResult<()> {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(object)
|
||||
.body(Bytes::copy_from_slice(body).into())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let mut request = client
|
||||
.select_object_content()
|
||||
.bucket(BUCKET)
|
||||
.key(object)
|
||||
.expression(expression)
|
||||
.expression_type(ExpressionType::Sql)
|
||||
.input_serialization(input_serialization)
|
||||
.output_serialization(output_serialization);
|
||||
if progress_enabled {
|
||||
request = request.request_progress(RequestProgress::builder().enabled(true).build());
|
||||
}
|
||||
let response = request.send().await?;
|
||||
|
||||
let mut payload = response.payload;
|
||||
let mut records_len = 0_u64;
|
||||
let mut last_progress: Option<aws_sdk_s3::types::Progress> = None;
|
||||
let mut stats = None;
|
||||
let mut saw_end = false;
|
||||
tokio::time::timeout(SELECT_RESPONSE_TIMEOUT, async {
|
||||
// The AWS SDK validates both event-stream CRCs before yielding an event.
|
||||
while let Some(event) = payload.recv().await? {
|
||||
assert!(!saw_end, "Select emitted an event after End");
|
||||
match event {
|
||||
aws_sdk_s3::types::SelectObjectContentEventStream::Records(records) => {
|
||||
assert!(stats.is_none(), "Select emitted Records after Stats");
|
||||
if let Some(bytes) = records.payload {
|
||||
records_len = records_len.saturating_add(u64::try_from(bytes.as_ref().len())?);
|
||||
}
|
||||
}
|
||||
aws_sdk_s3::types::SelectObjectContentEventStream::Progress(event) => {
|
||||
assert!(stats.is_none(), "Select emitted Progress after Stats");
|
||||
let details = event.details.ok_or("Progress event did not contain details")?;
|
||||
if let Some(previous) = last_progress.as_ref() {
|
||||
assert!(details.bytes_scanned() >= previous.bytes_scanned());
|
||||
assert!(details.bytes_processed() >= previous.bytes_processed());
|
||||
assert!(details.bytes_returned() >= previous.bytes_returned());
|
||||
}
|
||||
last_progress = Some(details);
|
||||
}
|
||||
aws_sdk_s3::types::SelectObjectContentEventStream::Stats(event) => {
|
||||
assert!(stats.is_none(), "Select emitted more than one Stats event");
|
||||
stats = event.details;
|
||||
}
|
||||
aws_sdk_s3::types::SelectObjectContentEventStream::End(_) => {
|
||||
assert!(stats.is_some(), "Select emitted End before Stats");
|
||||
saw_end = true;
|
||||
}
|
||||
_ => assert!(stats.is_none(), "Select emitted a non-terminal event after Stats"),
|
||||
}
|
||||
}
|
||||
Ok::<(), Box<dyn Error + Send + Sync>>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| -> Box<dyn Error + Send + Sync> { "Select response timed out".into() })??;
|
||||
|
||||
let stats = stats.ok_or("Select response ended without a Stats event")?;
|
||||
let input_len = i64::try_from(body.len())?;
|
||||
assert_eq!(stats.bytes_scanned(), Some(input_len));
|
||||
assert_eq!(stats.bytes_processed(), Some(input_len));
|
||||
assert_eq!(stats.bytes_returned(), Some(i64::try_from(records_len)?));
|
||||
if progress_enabled {
|
||||
if let Some(progress) = last_progress {
|
||||
assert!(stats.bytes_scanned() >= progress.bytes_scanned());
|
||||
assert!(stats.bytes_processed() >= progress.bytes_processed());
|
||||
assert!(stats.bytes_returned() >= progress.bytes_returned());
|
||||
}
|
||||
} else {
|
||||
assert!(last_progress.is_none(), "disabled request progress emitted a Progress event");
|
||||
}
|
||||
assert!(saw_end, "Select response ended without an End event");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_select_object_content_http_event_order_crc_and_input_byte_stats() -> TestResult<()> {
|
||||
const CSV_BODY: &[u8] = b"name,age\nAlice,30\nBob,25\n";
|
||||
const JSON_LINES_BODY: &[u8] = b"{\"name\":\"Alice\"}\n{\"name\":\"Bob\"}\n";
|
||||
const JSON_DOCUMENT_BODY: &[u8] = b"[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]";
|
||||
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
setup_test_bucket(&client).await?;
|
||||
assert_input_byte_stats(
|
||||
&client,
|
||||
"input-metrics.csv",
|
||||
CSV_BODY,
|
||||
"SELECT name FROM S3Object",
|
||||
InputSerialization::builder()
|
||||
.csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build())
|
||||
.build(),
|
||||
OutputSerialization::builder().csv(CsvOutput::builder().build()).build(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
assert_input_byte_stats(
|
||||
&client,
|
||||
"input-metrics.jsonl",
|
||||
JSON_LINES_BODY,
|
||||
"SELECT name FROM S3Object",
|
||||
InputSerialization::builder()
|
||||
.json(JsonInput::builder().set_type(Some(JsonType::Lines)).build())
|
||||
.build(),
|
||||
OutputSerialization::builder().json(JsonOutput::builder().build()).build(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
assert_input_byte_stats(
|
||||
&client,
|
||||
"input-metrics.json",
|
||||
JSON_DOCUMENT_BODY,
|
||||
"SELECT name FROM S3Object",
|
||||
InputSerialization::builder()
|
||||
.json(JsonInput::builder().set_type(Some(JsonType::Document)).build())
|
||||
.build(),
|
||||
OutputSerialization::builder().json(JsonOutput::builder().build()).build(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
assert_input_byte_stats(
|
||||
&client,
|
||||
"input-metrics-without-progress.csv",
|
||||
CSV_BODY,
|
||||
"SELECT name FROM S3Object",
|
||||
InputSerialization::builder()
|
||||
.csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build())
|
||||
.build(),
|
||||
OutputSerialization::builder().csv(CsvOutput::builder().build()).build(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_select_object_content_http_disconnect_releases_query() -> TestResult<()> {
|
||||
const OBJECT: &str = "disconnect.csv";
|
||||
const ROWS: usize = 16 * 1024;
|
||||
const RELEASE_BACKOFF: Duration = Duration::from_millis(25);
|
||||
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_S3SELECT_MAX_CONCURRENT_QUERIES", "1")])
|
||||
.await?;
|
||||
let client = env.create_s3_client();
|
||||
setup_test_bucket(&client).await?;
|
||||
|
||||
let row = format!("{}\n", "x".repeat(1023));
|
||||
let mut body = Vec::with_capacity("value\n".len() + ROWS * row.len());
|
||||
body.extend_from_slice(b"value\n");
|
||||
for _ in 0..ROWS {
|
||||
body.extend_from_slice(row.as_bytes());
|
||||
}
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key(OBJECT)
|
||||
.body(Bytes::from(body).into())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Leaving this response body unread fills the bounded HTTP/event channels before the query can finish.
|
||||
let first = csv_select_request(&client, OBJECT).send().await?;
|
||||
let saturated = csv_select_request(&client, OBJECT)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("the first HTTP stream should retain the only query permit");
|
||||
assert_eq!(saturated.as_service_error().and_then(ProvideErrorMetadata::code), Some("SlowDown"));
|
||||
|
||||
drop(first);
|
||||
let second = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
match csv_select_request(&client, OBJECT).send().await {
|
||||
Ok(response) => return Ok::<_, Box<dyn Error + Send + Sync>>(response),
|
||||
Err(error) if error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("SlowDown") => {
|
||||
tokio::time::sleep(RELEASE_BACKOFF).await;
|
||||
}
|
||||
Err(error) => return Err(format!("unexpected Select error after disconnect: {error}").into()),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| -> Box<dyn Error + Send + Sync> { "disconnected Select did not release its query permit".into() })??;
|
||||
drop(second);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_select_object_content_csv_basic() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
@@ -228,6 +498,107 @@ async fn test_select_object_content_json_basic() -> TestResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_select_object_content_nested_json_source_path() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
setup_test_bucket(&client).await?;
|
||||
upload_nested_json_document(&client).await?;
|
||||
|
||||
let result = select_json_document(
|
||||
&client,
|
||||
JSON_DOCUMENT_OBJECT,
|
||||
"SELECT e.name FROM S3Object[*].departments[*].employees[*] AS e WHERE e.active = true",
|
||||
)
|
||||
.await?;
|
||||
let names: Vec<String> = result
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| -> TestResult<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(line)?;
|
||||
Ok(value["name"].as_str().ok_or("missing name field")?.to_string())
|
||||
})
|
||||
.collect::<TestResult<_>>()?;
|
||||
|
||||
assert_eq!(names, vec!["Alice", "Charlie"]);
|
||||
|
||||
let terminal_scalars = select_json_document(
|
||||
&client,
|
||||
JSON_DOCUMENT_OBJECT,
|
||||
"SELECT NAME FROM S3Object[*].DEPARTMENTS[*].employees[*].NAME",
|
||||
)
|
||||
.await?;
|
||||
let scalar_names: Vec<String> = terminal_scalars
|
||||
.lines()
|
||||
.map(|line| -> TestResult<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(line)?;
|
||||
Ok(value["name"].as_str().ok_or("missing scalar name field")?.to_string())
|
||||
})
|
||||
.collect::<TestResult<_>>()?;
|
||||
assert_eq!(scalar_names, vec!["Alice", "Bob", "Charlie"]);
|
||||
|
||||
let aliased_scalars = select_json_document(
|
||||
&client,
|
||||
JSON_DOCUMENT_OBJECT,
|
||||
"SELECT v FROM S3Object[*].departments[*].employees[*].name AS v",
|
||||
)
|
||||
.await?;
|
||||
let aliased_names: Vec<String> = aliased_scalars
|
||||
.lines()
|
||||
.map(|line| -> TestResult<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(line)?;
|
||||
Ok(value["v"].as_str().ok_or("missing aliased scalar field")?.to_string())
|
||||
})
|
||||
.collect::<TestResult<_>>()?;
|
||||
assert_eq!(aliased_names, vec!["Alice", "Bob", "Charlie"]);
|
||||
|
||||
let root_array = select_json_document(&client, JSON_ROOT_ARRAY_OBJECT, "SELECT c.name FROM S3Object[*][*] AS c").await?;
|
||||
let root_names: Vec<String> = root_array
|
||||
.lines()
|
||||
.map(|line| -> TestResult<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(line)?;
|
||||
Ok(value["name"].as_str().ok_or("missing root-array name field")?.to_string())
|
||||
})
|
||||
.collect::<TestResult<_>>()?;
|
||||
assert_eq!(root_names, vec!["Alice", "Bob"]);
|
||||
|
||||
let root_index = select_json_document(&client, JSON_ROOT_ARRAY_OBJECT, "SELECT c.name FROM S3Object[*][0] AS c").await?;
|
||||
let root_index_value: serde_json::Value = serde_json::from_str(root_index.trim())?;
|
||||
assert_eq!(root_index_value["name"], "Alice");
|
||||
|
||||
let root_scalars = select_json_document(&client, JSON_ROOT_SCALAR_ARRAY_OBJECT, "SELECT V FROM S3Object AS V").await?;
|
||||
let scalar_values: Vec<i64> = root_scalars
|
||||
.lines()
|
||||
.map(|line| -> TestResult<i64> {
|
||||
let value: serde_json::Value = serde_json::from_str(line)?;
|
||||
Ok(value["v"].as_i64().ok_or("missing root scalar value")?)
|
||||
})
|
||||
.collect::<TestResult<_>>()?;
|
||||
assert_eq!(scalar_values, vec![1, 2]);
|
||||
|
||||
let implicit_root_scalars =
|
||||
select_json_document(&client, JSON_ROOT_SCALAR_ARRAY_OBJECT, "SELECT S3Object FROM S3Object").await?;
|
||||
let implicit_scalar_values: Vec<i64> = implicit_root_scalars
|
||||
.lines()
|
||||
.map(|line| -> TestResult<i64> {
|
||||
let value: serde_json::Value = serde_json::from_str(line)?;
|
||||
Ok(value["s3object"].as_i64().ok_or("missing implicit root scalar value")?)
|
||||
})
|
||||
.collect::<TestResult<_>>()?;
|
||||
assert_eq!(implicit_scalar_values, vec![1, 2]);
|
||||
|
||||
let quoted_root_scalars =
|
||||
select_json_document(&client, JSON_ROOT_SCALAR_ARRAY_OBJECT, "SELECT \"S3Object\" FROM \"S3Object\"").await?;
|
||||
let quoted_scalar_values: Vec<i64> = quoted_root_scalars
|
||||
.lines()
|
||||
.map(|line| -> TestResult<i64> {
|
||||
let value: serde_json::Value = serde_json::from_str(line)?;
|
||||
Ok(value["S3Object"].as_i64().ok_or("missing quoted root scalar value")?)
|
||||
})
|
||||
.collect::<TestResult<_>>()?;
|
||||
assert_eq!(quoted_scalar_values, vec![1, 2]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn test_select_object_content_csv_limit() -> TestResult<()> {
|
||||
let (_env, client) = create_test_environment().await?;
|
||||
|
||||
@@ -35,19 +35,22 @@ mod tests {
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tokio::time::{Duration, Instant, interval};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{Duration, Instant, interval, sleep, timeout};
|
||||
use tracing::info;
|
||||
|
||||
const ENABLE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E";
|
||||
const NAMESPACE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E_IN_NAMESPACE";
|
||||
const LOG_DIR_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_LOG_DIR";
|
||||
const TARGET_NODE: usize = 1;
|
||||
const TARGET_DRIVE: usize = 0;
|
||||
const MOUNT_SIZE: &str = "size=128m,mode=0700";
|
||||
const ABSENT_SCANNER_OBSERVATION_TIMEOUT_SECS: u64 = 180;
|
||||
const REPLACEMENT_RECOVERY_DIR: &str = ".rustfs.sys/buckets/ahm-replacement";
|
||||
const REPLACEMENT_INTENT_SUFFIX: &str = "_ahm_replacement_intent.json";
|
||||
const REPLACEMENT_COMPLETION_PROOF_SUFFIX: &str = "_ahm_replacement_completion_proof.json";
|
||||
const RESUME_CHECKPOINT_SUFFIX: &str = "_ahm_checkpoint.json";
|
||||
const FAULT_WINDOW_OBJECT_COUNT: usize = 24;
|
||||
const FAULT_WINDOW_OBJECT_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BaselineVersion {
|
||||
@@ -65,6 +68,12 @@ mod tests {
|
||||
CompletedWithIncomplete(BTreeSet<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ReplacementScenario {
|
||||
Baseline,
|
||||
MidRebuildIoFault,
|
||||
}
|
||||
|
||||
struct MountNamespaceGuard {
|
||||
mounts: Vec<PathBuf>,
|
||||
}
|
||||
@@ -102,6 +111,19 @@ mod tests {
|
||||
|
||||
impl FaultableBlockMount {
|
||||
fn mount(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
Self::mount_with_live_recovery(target, image_root, label, false)
|
||||
}
|
||||
|
||||
fn mount_live_recovery(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
Self::mount_with_live_recovery(target, image_root, label, true)
|
||||
}
|
||||
|
||||
fn mount_with_live_recovery(
|
||||
target: &Path,
|
||||
image_root: &Path,
|
||||
label: &str,
|
||||
live_recovery: bool,
|
||||
) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
fs::create_dir_all(image_root)?;
|
||||
let image = image_root.join(format!("{label}.img"));
|
||||
let file = fs::File::create(&image)?;
|
||||
@@ -114,7 +136,15 @@ mod tests {
|
||||
return Err("losetup --find --show returned an empty loop device".into());
|
||||
}
|
||||
|
||||
run_command("mkfs.ext4", &["-F", &loop_device])?;
|
||||
if live_recovery {
|
||||
// Keep the filesystem and RustFS' persistent root descriptor attached
|
||||
// across the transient all-block EIO. A journaling ext4 abort requires
|
||||
// an unmount to recover, which would test process/disk reattachment
|
||||
// instead of live I/O recovery.
|
||||
run_command("mkfs.ext4", &["-F", "-O", "^has_journal", &loop_device])?;
|
||||
} else {
|
||||
run_command("mkfs.ext4", &["-F", &loop_device])?;
|
||||
}
|
||||
let sectors = run_command_stdout("blockdev", &["--getsz", &loop_device])?;
|
||||
let dm_name = format!("rustfs_e2e_{label}_{}", std::process::id());
|
||||
let table = format!("0 {sectors} linear {loop_device} 0");
|
||||
@@ -122,7 +152,11 @@ mod tests {
|
||||
run_command("dmsetup", &["create", &dm_name, "--table", &table])?;
|
||||
|
||||
let target_arg = path_to_string(target, "faultable mount target")?;
|
||||
run_command("mount", &[&mapper, &target_arg])?;
|
||||
if live_recovery {
|
||||
run_command("mount", &["-o", "errors=continue", &mapper, &target_arg])?;
|
||||
} else {
|
||||
run_command("mount", &[&mapper, &target_arg])?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
target: target.to_path_buf(),
|
||||
@@ -142,18 +176,42 @@ mod tests {
|
||||
run_command("dmsetup", &["resume", &self.dm_name])
|
||||
}
|
||||
|
||||
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
fn verify_raw_io_is_unavailable(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mapper = format!("/dev/mapper/{}", self.dm_name);
|
||||
let output = Command::new("dd")
|
||||
.env("LC_ALL", "C")
|
||||
.arg(format!("if={mapper}"))
|
||||
.args(["of=/dev/null", "bs=4096", "count=1", "iflag=direct", "status=none"])
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
return Err(format!("dm-error target unexpectedly allowed a raw read from {mapper}").into());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.contains("Input/output error") {
|
||||
return Err(format!("raw read from dm-error target failed unexpectedly: {stderr}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_linear_table(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
|
||||
let linear_table = format!("0 {sectors} linear {} 0", self.loop_device);
|
||||
run_command("dmsetup", &["suspend", &self.dm_name])?;
|
||||
// An ext4 journal abort can leave the mounted filesystem internally
|
||||
// read-only. Avoid dmsetup's filesystem freeze/flush in that state;
|
||||
// all I/O sent to the error target has already completed with EIO.
|
||||
run_command("dmsetup", &["suspend", "--noflush", &self.dm_name])?;
|
||||
run_command("dmsetup", &["load", &self.dm_name, "--table", &linear_table])?;
|
||||
run_command("dmsetup", &["resume", &self.dm_name])
|
||||
}
|
||||
|
||||
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
self.restore_linear_table()
|
||||
}
|
||||
|
||||
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut first_error: Option<Box<dyn Error + Send + Sync>> = None;
|
||||
if self.dm_created {
|
||||
let _ = self.restore_available();
|
||||
let _ = self.restore_linear_table();
|
||||
}
|
||||
if self.mounted {
|
||||
if let Err(error) = detach_mount(&self.target) {
|
||||
@@ -194,6 +252,72 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct ZramBlockMount {
|
||||
target: PathBuf,
|
||||
device: String,
|
||||
mounted: bool,
|
||||
}
|
||||
|
||||
impl ZramBlockMount {
|
||||
fn reserve(target: &Path) -> Result<Self, Box<dyn Error + Send + Sync>> {
|
||||
if !Path::new("/dev/zram-control").exists() {
|
||||
run_command("modprobe", &["zram"])?;
|
||||
}
|
||||
let device = run_command_stdout("zramctl", &["--find", "--size", "256M"])?;
|
||||
if device.is_empty() {
|
||||
return Err("zramctl --find --size returned an empty device".into());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
target: target.to_path_buf(),
|
||||
device,
|
||||
mounted: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn mount_target(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let result = (|| {
|
||||
run_command("mkfs.ext4", &["-F", &self.device])?;
|
||||
let target_arg = path_to_string(&self.target, "zram replacement mount target")?;
|
||||
run_command("mount", &[&self.device, &target_arg])
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
let _ = self.cleanup();
|
||||
return Err(error);
|
||||
}
|
||||
self.mounted = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut first_error: Option<Box<dyn Error + Send + Sync>> = None;
|
||||
if self.mounted {
|
||||
if let Err(error) = detach_mount(&self.target) {
|
||||
first_error.get_or_insert(error);
|
||||
} else {
|
||||
self.mounted = false;
|
||||
}
|
||||
}
|
||||
if !self.device.is_empty() {
|
||||
if let Err(error) = run_command("zramctl", &["--reset", &self.device]) {
|
||||
first_error.get_or_insert(error);
|
||||
} else {
|
||||
self.device.clear();
|
||||
}
|
||||
}
|
||||
if let Some(error) = first_error {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ZramBlockMount {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_command_output(program: &str, args: &[&str]) -> Result<std::process::Output, Box<dyn Error + Send + Sync>> {
|
||||
let output = Command::new(program).args(args).output()?;
|
||||
if output.status.success() {
|
||||
@@ -298,6 +422,18 @@ mod tests {
|
||||
Err(format!("{ENABLE_ENV}=1 requires root or CAP_SYS_ADMIN; unshare exited with status {status}").into())
|
||||
}
|
||||
|
||||
fn replacement_node_log_path(
|
||||
cluster_temp_dir: &str,
|
||||
parity: usize,
|
||||
node_index: usize,
|
||||
) -> Result<PathBuf, Box<dyn Error + Send + Sync>> {
|
||||
let log_dir = std::env::var_os(LOG_DIR_ENV)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(cluster_temp_dir));
|
||||
fs::create_dir_all(&log_dir)?;
|
||||
Ok(log_dir.join(format!("replacement-ec{parity}-node{node_index}-{}.log", std::process::id())))
|
||||
}
|
||||
|
||||
fn payload(len: usize, seed: u8) -> Vec<u8> {
|
||||
let mut next = seed;
|
||||
(0..len)
|
||||
@@ -374,7 +510,11 @@ mod tests {
|
||||
Ok((completed.version_id().map(str::to_owned), digest))
|
||||
}
|
||||
|
||||
async fn seed_baseline(client: &Client, target_disk: &Path) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
|
||||
async fn seed_baseline(
|
||||
client: &Client,
|
||||
target_disk: &Path,
|
||||
extra_object_count: usize,
|
||||
) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
|
||||
let plain_bucket = "priv-replacement-plain";
|
||||
let versioned_bucket = "priv-replacement-versions";
|
||||
let null_bucket = "priv-replacement-null";
|
||||
@@ -437,7 +577,7 @@ mod tests {
|
||||
put_object_version(client, null_bucket, "null/current.bin", payload(512 * 1024, 8)).await?;
|
||||
versions.push((null_bucket, "null/current.bin", version_id, Some(body_sha256)));
|
||||
|
||||
let versions = versions
|
||||
let mut versions = versions
|
||||
.into_iter()
|
||||
.map(|(bucket, key, version_id, body_sha256)| {
|
||||
let expected = census_object_version_on_disk(target_disk, bucket, key, version_id.as_deref())?;
|
||||
@@ -453,6 +593,23 @@ mod tests {
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, Box<dyn Error + Send + Sync>>>()?;
|
||||
for index in 0..extra_object_count {
|
||||
let key = format!("fault-window/object-{index:04}.bin");
|
||||
let seed = u8::try_from(index + 32)?;
|
||||
let (version_id, body_sha256) =
|
||||
put_object_version(client, plain_bucket, &key, payload(FAULT_WINDOW_OBJECT_BYTES, seed)).await?;
|
||||
let expected = census_object_version_on_disk(target_disk, plain_bucket, &key, version_id.as_deref())?;
|
||||
if !expected.is_complete() {
|
||||
return Err(format!("fault-window baseline census is incomplete for {plain_bucket}/{key}: {expected:?}").into());
|
||||
}
|
||||
versions.push(BaselineVersion {
|
||||
bucket: plain_bucket.to_string(),
|
||||
key,
|
||||
version_id,
|
||||
body_sha256: Some(body_sha256),
|
||||
expected,
|
||||
});
|
||||
}
|
||||
let inline = versions
|
||||
.iter()
|
||||
.find(|version| version.key == "history/inline.bin")
|
||||
@@ -472,8 +629,20 @@ mod tests {
|
||||
if let Some(version_id) = &version.version_id {
|
||||
request = request.version_id(version_id);
|
||||
}
|
||||
let response = request.send().await?;
|
||||
let body = response.body.collect().await?.into_bytes();
|
||||
let response = request.send().await.map_err(|error| {
|
||||
format!("body GET failed for {}/{}@{:?}: {error}", version.bucket, version.key, version.version_id)
|
||||
})?;
|
||||
let body = response
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"body stream failed for {}/{}@{:?}: {error}",
|
||||
version.bucket, version.key, version.version_id
|
||||
)
|
||||
})?
|
||||
.into_bytes();
|
||||
assert_eq!(
|
||||
sha256_hex(&body),
|
||||
*expected_sha256,
|
||||
@@ -582,81 +751,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_tail(log: &str) -> String {
|
||||
let mut lines = log.lines().rev().take(80).collect::<Vec<_>>();
|
||||
lines.reverse();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn log_len(path: &Path) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
||||
match fs::metadata(path) {
|
||||
Ok(metadata) => Ok(metadata.len()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0),
|
||||
Err(error) => Err(format!("failed to stat target node log {path:?}: {error}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn log_from_offset(path: &Path, offset: u64) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let log = match fs::read(path) {
|
||||
Ok(log) => log,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(error) => return Err(format!("failed to read target node log {path:?}: {error}").into()),
|
||||
};
|
||||
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(log.len());
|
||||
Ok(String::from_utf8_lossy(&log[start..]).into_owned())
|
||||
}
|
||||
|
||||
fn live_disk_loss_scan_completed(log: &str, target_disk: &Path) -> bool {
|
||||
let target = target_disk.to_string_lossy();
|
||||
let mut saw_live_loss = false;
|
||||
for line in log.lines() {
|
||||
if line.contains("Heal auto-scan disk inspection failed")
|
||||
&& line.contains("check_failed")
|
||||
&& line.contains(target.as_ref())
|
||||
{
|
||||
saw_live_loss = true;
|
||||
continue;
|
||||
}
|
||||
if saw_live_loss && (line.contains("Heal auto disk scanner idle") || line.contains("Heal auto-scan cycle completed"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn live_disk_loss_scan_completed_from_path(
|
||||
log_path: &Path,
|
||||
start_offset: u64,
|
||||
target_disk: &Path,
|
||||
) -> Result<bool, Box<dyn Error + Send + Sync>> {
|
||||
Ok(live_disk_loss_scan_completed(&log_from_offset(log_path, start_offset)?, target_disk))
|
||||
}
|
||||
|
||||
async fn wait_for_live_disk_loss_observation(
|
||||
log_path: &Path,
|
||||
target_disk: &Path,
|
||||
start_offset: u64,
|
||||
timeout_secs: u64,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let mut tick = interval(Duration::from_secs(1));
|
||||
loop {
|
||||
if live_disk_loss_scan_completed_from_path(log_path, start_offset, target_disk)? {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let log = log_from_offset(log_path, start_offset)?;
|
||||
return Err(format!(
|
||||
"scanner did not finish a live target-loss scan for {target_disk:?} within {timeout_secs}s; log tail:\n{}",
|
||||
log_tail(&log)
|
||||
)
|
||||
.into());
|
||||
}
|
||||
tick.tick().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn cluster_status_is_definitive(status: &serde_json::Value) -> Result<bool, Box<dyn Error + Send + Sync>> {
|
||||
status["cluster"]["definitive"]
|
||||
.as_bool()
|
||||
@@ -707,6 +801,105 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn target_record_details(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
) -> Result<Vec<(String, String)>, Box<dyn Error + Send + Sync>> {
|
||||
let target = target_disk.to_string_lossy();
|
||||
let records = status["cluster"]["records"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("replacement recovery status omitted cluster.records: {status}"))?;
|
||||
records
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record["targetSlots"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.any(|slot| slot.contains(target.as_ref()))
|
||||
})
|
||||
.map(|record| {
|
||||
let task_id = record["taskId"]
|
||||
.as_str()
|
||||
.filter(|task_id| !task_id.is_empty())
|
||||
.ok_or_else(|| format!("replacement recovery record omitted taskId: {record}"))?;
|
||||
let state = record["state"]
|
||||
.as_str()
|
||||
.filter(|state| !state.is_empty())
|
||||
.ok_or_else(|| format!("replacement recovery record omitted state: {record}"))?;
|
||||
Ok((task_id.to_string(), state.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn running_target_generation(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
) -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
|
||||
if !cluster_status_is_definitive(status)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let records = target_record_details(status, target_disk)?;
|
||||
if records.len() == 1 && records[0].1 == "running" {
|
||||
return Ok(Some(records[0].0.clone()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn assert_target_generation_nonterminal(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
expected_task_id: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if !cluster_status_is_definitive(status)? {
|
||||
return Err(format!("replacement recovery became non-definitive during target EIO: {status}").into());
|
||||
}
|
||||
let records = target_record_details(status, target_disk)?;
|
||||
let matching = records
|
||||
.iter()
|
||||
.filter(|(task_id, _)| task_id == expected_task_id)
|
||||
.collect::<Vec<_>>();
|
||||
if matching.len() != 1 {
|
||||
return Err(format!(
|
||||
"replacement generation {expected_task_id} must remain uniquely observable during target EIO: {records:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
match matching[0].1.as_str() {
|
||||
"waiting_for_replacement" | "running" | "incomplete" => Ok(()),
|
||||
state => Err(format!(
|
||||
"replacement generation {expected_task_id} reached invalid state {state:?} during target EIO: {status}"
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_target_generation_completed(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
expected_task_id: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if !cluster_status_is_definitive(status)? {
|
||||
return Err(format!("completed replacement recovery status is non-definitive: {status}").into());
|
||||
}
|
||||
let records = target_record_details(status, target_disk)?;
|
||||
if records == [(expected_task_id.to_string(), "completed".to_string())] {
|
||||
return Ok(());
|
||||
}
|
||||
Err(
|
||||
format!("replacement generation {expected_task_id} did not retain its identity through EIO recovery: {records:?}")
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_transient_recovery_version_absence(error: &(dyn Error + 'static)) -> bool {
|
||||
matches!(
|
||||
error.downcast_ref::<rustfs_filemeta::Error>(),
|
||||
Some(rustfs_filemeta::Error::FileVersionNotFound)
|
||||
)
|
||||
}
|
||||
|
||||
fn incomplete_versions(
|
||||
target_disk: &Path,
|
||||
versions: &[BaselineVersion],
|
||||
@@ -714,7 +907,21 @@ mod tests {
|
||||
let mut missing = BTreeSet::new();
|
||||
for version in versions {
|
||||
let actual =
|
||||
census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref())?;
|
||||
match census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref()) {
|
||||
Ok(actual) => actual,
|
||||
// During replacement recovery, xl.meta may arrive before this
|
||||
// particular historical version. The generic census helper
|
||||
// correctly reports that as an error; this progress poll must
|
||||
// instead wait for the version to be restored.
|
||||
Err(error) if is_transient_recovery_version_absence(error.as_ref()) => {
|
||||
missing.insert(format!(
|
||||
"{}/{}@{:?}: version metadata not yet present on replacement",
|
||||
version.bucket, version.key, version.version_id
|
||||
));
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !actual.matches_manifest(&version.expected) {
|
||||
missing.insert(format!("{}/{}@{:?}: {actual:?}", version.bucket, version.key, version.version_id));
|
||||
}
|
||||
@@ -722,6 +929,165 @@ mod tests {
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
async fn wait_for_partial_replacement<'a>(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
target_disk: &Path,
|
||||
versions: &'a [BaselineVersion],
|
||||
timeout_secs: u64,
|
||||
) -> Result<(usize, &'a BaselineVersion, String), Box<dyn Error + Send + Sync>> {
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
let missing = incomplete_versions(target_disk, versions)?;
|
||||
let completed = versions.len().saturating_sub(missing.len());
|
||||
if completed > 0 && completed < versions.len() {
|
||||
let witness = versions.iter().find(|version| {
|
||||
census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref())
|
||||
.is_ok_and(|actual| actual.matches_manifest(&version.expected))
|
||||
});
|
||||
if let Some(witness) = witness {
|
||||
let status = replacement_status(cluster).await?;
|
||||
if let Some(task_id) = running_target_generation(&status, target_disk)? {
|
||||
return Ok((completed, witness, task_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if completed == versions.len() {
|
||||
return Err(format!(
|
||||
"replacement rebuilt all {} baseline versions before target EIO could be injected",
|
||||
versions.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let status = replacement_status(cluster).await?;
|
||||
return Err(format!(
|
||||
"replacement made no observable running partial progress within {timeout_secs}s: completed={completed}/{} status={status}",
|
||||
versions.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn cluster_process_ids(cluster: &RustFSTestClusterEnvironment) -> Result<Vec<u32>, Box<dyn Error + Send + Sync>> {
|
||||
cluster
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, node)| {
|
||||
node.process
|
||||
.as_ref()
|
||||
.map(std::process::Child::id)
|
||||
.ok_or_else(|| format!("cluster node {index} process is not running").into())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn assert_cluster_processes_and_listeners_unchanged(
|
||||
cluster: &mut RustFSTestClusterEnvironment,
|
||||
expected_pids: &[u32],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if cluster.nodes.len() != expected_pids.len() {
|
||||
return Err("cluster node count changed during target EIO".into());
|
||||
}
|
||||
for (index, (node, expected_pid)) in cluster.nodes.iter_mut().zip(expected_pids).enumerate() {
|
||||
let process = node
|
||||
.process
|
||||
.as_mut()
|
||||
.ok_or_else(|| format!("cluster node {index} process disappeared during target EIO"))?;
|
||||
if process.id() != *expected_pid {
|
||||
return Err(format!(
|
||||
"cluster node {index} PID changed during target EIO: expected {expected_pid}, got {}",
|
||||
process.id()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if let Some(status) = process.try_wait()? {
|
||||
return Err(format!("cluster node {index} exited during target EIO with {status}").into());
|
||||
}
|
||||
match timeout(Duration::from_secs(2), TcpStream::connect(&node.address)).await {
|
||||
Ok(Ok(stream)) => drop(stream),
|
||||
Ok(Err(error)) => {
|
||||
return Err(format!("cluster node {index} TCP listener failed during target EIO: {error}").into());
|
||||
}
|
||||
Err(_) => return Err(format!("cluster node {index} TCP listener timed out during target EIO").into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exercise_mid_rebuild_io_fault(
|
||||
cluster: &mut RustFSTestClusterEnvironment,
|
||||
replacement_mount: &FaultableBlockMount,
|
||||
target_disk: &Path,
|
||||
versions: &[BaselineVersion],
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let partial_timeout_secs = std::env::var("RUSTFS_HEAL_DISK_IO_PARTIAL_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(120);
|
||||
let (partial_count, witness, task_id) =
|
||||
wait_for_partial_replacement(cluster, target_disk, versions, partial_timeout_secs).await?;
|
||||
let expected_pids = cluster_process_ids(cluster)?;
|
||||
|
||||
replacement_mount
|
||||
.make_unavailable()
|
||||
.map_err(|error| format!("failed to install dm-error on the active replacement: {error}"))?;
|
||||
let fault_result = async {
|
||||
replacement_mount
|
||||
.verify_raw_io_is_unavailable()
|
||||
.map_err(|error| format!("active replacement dm-error was not proven by direct I/O: {error}"))?;
|
||||
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
|
||||
|
||||
let observation_deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
let status = timeout(Duration::from_secs(5), replacement_status(cluster))
|
||||
.await
|
||||
.map_err(|_| "replacement recovery status timed out during target EIO")??;
|
||||
assert_target_generation_nonterminal(&status, target_disk, &task_id)?;
|
||||
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
|
||||
if Instant::now() >= observation_deadline {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Ok::<(), Box<dyn Error + Send + Sync>>(())
|
||||
}
|
||||
.await;
|
||||
let restore_result = replacement_mount
|
||||
.restore_available()
|
||||
.map_err(|error| format!("failed to restore the active replacement after dm-error: {error}"));
|
||||
if let Err(error) = fault_result {
|
||||
if let Err(restore_error) = restore_result {
|
||||
info!(%restore_error, "replacement restore also failed while preserving target EIO failure");
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
restore_result?;
|
||||
|
||||
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
|
||||
let actual = census_object_version_on_disk(target_disk, &witness.bucket, &witness.key, witness.version_id.as_deref())?;
|
||||
if !actual.matches_manifest(&witness.expected) {
|
||||
return Err(format!(
|
||||
"witnessed replacement shard did not survive target EIO for {}/{}@{:?}: {actual:?}",
|
||||
witness.bucket, witness.key, witness.version_id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let completed_after_restore = versions
|
||||
.len()
|
||||
.saturating_sub(incomplete_versions(target_disk, versions)?.len());
|
||||
if completed_after_restore < partial_count {
|
||||
return Err(format!(
|
||||
"replacement progress regressed across target EIO: before={partial_count}, after={completed_after_restore}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
fn replacement_completion_state(
|
||||
status: &serde_json::Value,
|
||||
target_disk: &Path,
|
||||
@@ -810,7 +1176,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_replacement_e2e(parity: usize, test_name: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
async fn run_replacement_e2e(
|
||||
parity: usize,
|
||||
test_name: &str,
|
||||
scenario: ReplacementScenario,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
if !privileged_run_enabled()? {
|
||||
return Ok(());
|
||||
@@ -822,13 +1192,15 @@ mod tests {
|
||||
|
||||
let mut mount_ns = MountNamespaceGuard::new()?;
|
||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(3, 4)).await?;
|
||||
let target_log_path = PathBuf::from(&cluster.temp_dir).join(format!("replacement-node{TARGET_NODE}.log"));
|
||||
cluster.set_node_capture_log_path(TARGET_NODE, target_log_path.to_string_lossy())?;
|
||||
for node_index in 0..cluster.nodes.len() {
|
||||
let node_log_path = replacement_node_log_path(&cluster.temp_dir, parity, node_index)?;
|
||||
cluster.set_node_capture_log_path(node_index, node_log_path.to_string_lossy())?;
|
||||
}
|
||||
let target_disk = PathBuf::from(&cluster.nodes[TARGET_NODE].data_dirs[TARGET_DRIVE]);
|
||||
// Each drive below is an independent tmpfs mount, so this privileged
|
||||
// path must exercise the production distinct-device/readiness fences.
|
||||
// The blank target uses a temporary zram block device, so the
|
||||
// replacement readiness fence sees no root or sibling alias.
|
||||
cluster.extra_env.retain(|(key, _)| key != "RUSTFS_UNSAFE_BYPASS_DISK_CHECK");
|
||||
let image_root = PathBuf::from(&cluster.temp_dir).join("replacement-faultable-images");
|
||||
let image_root = PathBuf::from(&cluster.temp_dir).join("replacement-block-images");
|
||||
let mut target_mount = None;
|
||||
for (node_index, node) in cluster.nodes.iter().enumerate() {
|
||||
for (drive_index, drive) in node.data_dirs.iter().enumerate() {
|
||||
@@ -845,6 +1217,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut target_mount = target_mount.ok_or("target drive was not mounted with the faultable block fixture")?;
|
||||
let mut zram_replacement = match scenario {
|
||||
ReplacementScenario::Baseline => Some(ZramBlockMount::reserve(&target_disk)?),
|
||||
ReplacementScenario::MidRebuildIoFault => None,
|
||||
};
|
||||
|
||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
|
||||
cluster.set_env("RUSTFS_SCANNER_ENABLED", "true");
|
||||
@@ -852,28 +1228,55 @@ mod tests {
|
||||
cluster.set_env("RUSTFS_SCANNER_CYCLE", "1");
|
||||
cluster.set_env("RUSTFS_SCANNER_START_DELAY_SECS", "0");
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
|
||||
cluster.set_node_env(TARGET_NODE, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
|
||||
if scenario == ReplacementScenario::MidRebuildIoFault {
|
||||
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
|
||||
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
|
||||
}
|
||||
for node_index in 0..cluster.nodes.len() {
|
||||
cluster.set_node_env(node_index, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
|
||||
}
|
||||
cluster.start().await?;
|
||||
|
||||
let clients = cluster.create_all_clients()?;
|
||||
let versions = seed_baseline(&clients[0], &target_disk).await?;
|
||||
verify_bodies(&clients[0], &versions).await?;
|
||||
let extra_object_count = match scenario {
|
||||
ReplacementScenario::Baseline => 0,
|
||||
ReplacementScenario::MidRebuildIoFault => FAULT_WINDOW_OBJECT_COUNT,
|
||||
};
|
||||
let versions = seed_baseline(&clients[0], &target_disk, extra_object_count)
|
||||
.await
|
||||
.map_err(|error| format!("pre-fault baseline seeding failed: {error}"))?;
|
||||
verify_bodies(&clients[0], &versions)
|
||||
.await
|
||||
.map_err(|error| format!("pre-fault body verification failed: {error}"))?;
|
||||
|
||||
let live_loss_log_offset = log_len(&target_log_path)?;
|
||||
target_mount.make_unavailable()?;
|
||||
wait_for_live_disk_loss_observation(
|
||||
&target_log_path,
|
||||
&target_disk,
|
||||
live_loss_log_offset,
|
||||
ABSENT_SCANNER_OBSERVATION_TIMEOUT_SECS,
|
||||
)
|
||||
.await?;
|
||||
assert_no_replacement_status_records(&cluster, &target_disk).await?;
|
||||
assert_no_replacement_admission_artifacts(&cluster, &target_disk)?;
|
||||
target_mount
|
||||
.make_unavailable()
|
||||
.map_err(|error| format!("failed to install the dm-error target: {error}"))?;
|
||||
target_mount
|
||||
.verify_raw_io_is_unavailable()
|
||||
.map_err(|error| format!("dm-error target was not proven by a direct raw read: {error}"))?;
|
||||
assert_no_replacement_status_records(&cluster, &target_disk)
|
||||
.await
|
||||
.map_err(|error| format!("live-fault replacement status check failed: {error}"))?;
|
||||
assert_no_replacement_admission_artifacts(&cluster, &target_disk)
|
||||
.map_err(|error| format!("live-fault replacement artifact check failed: {error}"))?;
|
||||
|
||||
cluster.stop_node(TARGET_NODE)?;
|
||||
cluster.stop_node_gracefully(TARGET_NODE).await?;
|
||||
target_mount.cleanup()?;
|
||||
mount_ns.mount_tmpfs(&target_disk, &format!("rustfs-e2e-p{parity}-replacement"))?;
|
||||
let mut faultable_replacement = match scenario {
|
||||
ReplacementScenario::Baseline => {
|
||||
zram_replacement
|
||||
.as_mut()
|
||||
.ok_or("baseline replacement zram was not reserved")?
|
||||
.mount_target()?;
|
||||
None
|
||||
}
|
||||
ReplacementScenario::MidRebuildIoFault => Some(FaultableBlockMount::mount_live_recovery(
|
||||
&target_disk,
|
||||
&image_root,
|
||||
&format!("p{parity}_replacement_node{TARGET_NODE}_drive{TARGET_DRIVE}"),
|
||||
)?),
|
||||
};
|
||||
let missing_before_restart = incomplete_versions(&target_disk, &versions)?;
|
||||
assert_eq!(
|
||||
missing_before_restart.len(),
|
||||
@@ -882,46 +1285,42 @@ mod tests {
|
||||
);
|
||||
cluster.start_node(TARGET_NODE).await?;
|
||||
|
||||
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
|
||||
verify_bodies(&clients[0], &versions).await?;
|
||||
let recovery_result = async {
|
||||
let faulted_task_id = match faultable_replacement.as_ref() {
|
||||
Some(replacement) => {
|
||||
Some(exercise_mid_rebuild_io_fault(&mut cluster, replacement, &target_disk, &versions).await?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
|
||||
if let Some(task_id) = faulted_task_id {
|
||||
let status = replacement_status(&cluster).await?;
|
||||
assert_target_generation_completed(&status, &target_disk, &task_id)?;
|
||||
}
|
||||
verify_bodies(&clients[0], &versions).await
|
||||
}
|
||||
.await;
|
||||
let stop_result = cluster.stop_node_gracefully(TARGET_NODE).await;
|
||||
let replacement_cleanup_result = match faultable_replacement.as_mut() {
|
||||
Some(replacement) => replacement.cleanup(),
|
||||
None => zram_replacement
|
||||
.as_mut()
|
||||
.ok_or("baseline replacement zram disappeared before cleanup")?
|
||||
.cleanup(),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
if let Err(error) = recovery_result {
|
||||
if let Err(stop_error) = stop_result {
|
||||
info!(%stop_error, "replacement target stop failed while preserving recovery failure");
|
||||
}
|
||||
if let Err(cleanup_error) = replacement_cleanup_result {
|
||||
info!(%cleanup_error, "replacement zram cleanup failed while preserving recovery failure");
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
stop_result?;
|
||||
replacement_cleanup_result?;
|
||||
|
||||
#[test]
|
||||
fn live_loss_barrier_requires_scanner_failure_after_log_offset() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let target = Path::new("/mnt/target");
|
||||
assert!(live_disk_loss_scan_completed(
|
||||
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto-scan cycle completed",
|
||||
target
|
||||
));
|
||||
assert!(live_disk_loss_scan_completed(
|
||||
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
|
||||
target
|
||||
));
|
||||
assert!(!live_disk_loss_scan_completed(
|
||||
"Heal auto disk scanner idle\nHeal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed",
|
||||
target
|
||||
));
|
||||
assert!(!live_disk_loss_scan_completed(
|
||||
"event=disk_health_check_failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
|
||||
target
|
||||
));
|
||||
assert!(!live_disk_loss_scan_completed(
|
||||
"Heal auto-scan disk inspection failed endpoint=/mnt/other disk_state=check_failed\nHeal auto disk scanner idle",
|
||||
target
|
||||
));
|
||||
let path = std::env::temp_dir().join(format!("rustfs-replacement-scan-{}.log", std::process::id()));
|
||||
let stale =
|
||||
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
|
||||
fs::write(&path, stale)?;
|
||||
let offset = log_len(&path)?;
|
||||
assert!(!live_disk_loss_scan_completed_from_path(&path, offset, target)?);
|
||||
let fresh =
|
||||
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
|
||||
fs::write(&path, format!("{stale}{fresh}"))?;
|
||||
assert!(live_disk_loss_scan_completed_from_path(&path, offset, target)?);
|
||||
fs::remove_file(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -954,6 +1353,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_census_only_treats_missing_version_as_transient() {
|
||||
let missing_version: Box<dyn Error + Send + Sync> = Box::new(rustfs_filemeta::Error::FileVersionNotFound);
|
||||
let missing_file: Box<dyn Error + Send + Sync> = Box::new(rustfs_filemeta::Error::FileNotFound);
|
||||
|
||||
assert!(is_transient_recovery_version_absence(missing_version.as_ref()));
|
||||
assert!(!is_transient_recovery_version_absence(missing_file.as_ref()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_poll_samples_census_before_status() {
|
||||
let order = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
@@ -1046,6 +1454,65 @@ mod tests {
|
||||
assert_eq!(status_samples.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_eio_status_preserves_one_nonterminal_generation() {
|
||||
let target = Path::new("/mnt/target");
|
||||
for state in ["waiting_for_replacement", "running", "incomplete"] {
|
||||
let status = serde_json::json!({
|
||||
"cluster": {
|
||||
"definitive": true,
|
||||
"records": [{
|
||||
"taskId": "generation-a",
|
||||
"state": state,
|
||||
"targetSlots": ["http://127.0.0.1:9000/mnt/target"]
|
||||
}]
|
||||
}
|
||||
});
|
||||
assert!(assert_target_generation_nonterminal(&status, target, "generation-a").is_ok());
|
||||
}
|
||||
|
||||
let running = serde_json::json!({
|
||||
"cluster": {
|
||||
"definitive": true,
|
||||
"records": [{
|
||||
"taskId": "generation-a",
|
||||
"state": "running",
|
||||
"targetSlots": ["/mnt/target"]
|
||||
}]
|
||||
}
|
||||
});
|
||||
assert_eq!(running_target_generation(&running, target).unwrap().as_deref(), Some("generation-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_eio_status_rejects_false_or_replaced_completion() {
|
||||
let target = Path::new("/mnt/target");
|
||||
let completed = serde_json::json!({
|
||||
"cluster": {
|
||||
"definitive": true,
|
||||
"records": [{
|
||||
"taskId": "generation-a",
|
||||
"state": "completed",
|
||||
"targetSlots": ["/mnt/target"]
|
||||
}]
|
||||
}
|
||||
});
|
||||
assert!(assert_target_generation_nonterminal(&completed, target, "generation-a").is_err());
|
||||
assert!(assert_target_generation_completed(&completed, target, "generation-a").is_ok());
|
||||
assert!(assert_target_generation_completed(&completed, target, "generation-b").is_err());
|
||||
|
||||
let duplicate = serde_json::json!({
|
||||
"cluster": {
|
||||
"definitive": true,
|
||||
"records": [
|
||||
{"taskId": "generation-a", "state": "running", "targetSlots": ["/mnt/target"]},
|
||||
{"taskId": "generation-a", "state": "incomplete", "targetSlots": ["/mnt/target"]}
|
||||
]
|
||||
}
|
||||
});
|
||||
assert!(assert_target_generation_nonterminal(&duplicate, target, "generation-a").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_status_requires_definitive_empty_records() {
|
||||
let target = Path::new("/mnt/target");
|
||||
@@ -1066,6 +1533,7 @@ mod tests {
|
||||
run_replacement_e2e(
|
||||
4,
|
||||
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec8_plus_4_without_admin_heal",
|
||||
ReplacementScenario::Baseline,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1079,6 +1547,20 @@ mod tests {
|
||||
run_replacement_e2e(
|
||||
6,
|
||||
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec6_plus_6_without_admin_heal",
|
||||
ReplacementScenario::Baseline,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Linux mount namespaces are per-thread; keep mount setup and process
|
||||
/// spawning on one OS thread so child RustFS nodes inherit the test mounts.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[ignore = "requires Linux root/CAP_SYS_ADMIN and RUSTFS_PRIVILEGED_REPLACEMENT_E2E=1"]
|
||||
async fn test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
run_replacement_e2e(
|
||||
4,
|
||||
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio",
|
||||
ReplacementScenario::MidRebuildIoFault,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ use std::net::IpAddr;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||
use tokio::fs;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -86,7 +86,7 @@ type BacklogMetricPoints = Arc<Mutex<BTreeMap<String, BTreeMap<String, (u64, f64
|
||||
/// default. This suite opts its source servers into the loopback allowance explicitly
|
||||
/// so the shared harness (`RustFSTestEnvironment` / the cluster harness) stays
|
||||
/// fail-closed and every other e2e scenario keeps exercising the production SSRF policy.
|
||||
const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
|
||||
pub(crate) const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
|
||||
|
||||
/// Short data-scanner cycle for the failure-recovery tests (backlog#1147 repl-5).
|
||||
///
|
||||
@@ -402,14 +402,14 @@ fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String),
|
||||
Ok((access_key, secret_key, session_token))
|
||||
}
|
||||
|
||||
struct ReplicationTargetOptions<'a> {
|
||||
endpoint: &'a str,
|
||||
access_key: &'a str,
|
||||
secret_key: &'a str,
|
||||
target_bucket: &'a str,
|
||||
secure: bool,
|
||||
skip_tls_verify: bool,
|
||||
ca_cert_pem: Option<&'a str>,
|
||||
pub(crate) struct ReplicationTargetOptions<'a> {
|
||||
pub(crate) endpoint: &'a str,
|
||||
pub(crate) access_key: &'a str,
|
||||
pub(crate) secret_key: &'a str,
|
||||
pub(crate) target_bucket: &'a str,
|
||||
pub(crate) secure: bool,
|
||||
pub(crate) skip_tls_verify: bool,
|
||||
pub(crate) ca_cert_pem: Option<&'a str>,
|
||||
}
|
||||
|
||||
async fn set_replication_target(
|
||||
@@ -434,7 +434,7 @@ async fn set_replication_target(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_replication_target_with_options(
|
||||
pub(crate) async fn set_replication_target_with_options(
|
||||
source_env: &RustFSTestEnvironment,
|
||||
source_bucket: &str,
|
||||
options: ReplicationTargetOptions<'_>,
|
||||
@@ -504,7 +504,7 @@ async fn send_set_replication_target_request(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn put_bucket_replication(
|
||||
pub(crate) async fn put_bucket_replication(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
target_arn: &str,
|
||||
@@ -643,7 +643,10 @@ async fn get_bucket_replication(
|
||||
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
|
||||
}
|
||||
|
||||
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
pub(crate) async fn enable_bucket_versioning(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
set_bucket_versioning(env, bucket, BucketVersioningStatus::Enabled).await
|
||||
}
|
||||
|
||||
@@ -2005,6 +2008,87 @@ fn proxy_error_response(error: impl std::fmt::Display) -> Response<Full<bytes::B
|
||||
.expect("static proxy response must be valid")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ReplicationResponseHoldRuntime {
|
||||
armed: Arc<AtomicBool>,
|
||||
backend_committed: watch::Sender<bool>,
|
||||
release: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl ReplicationResponseHoldRuntime {
|
||||
fn try_claim(&self) -> bool {
|
||||
self.armed
|
||||
.compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
struct ReplicationResponseHold {
|
||||
armed: Arc<AtomicBool>,
|
||||
backend_committed_signal: watch::Sender<bool>,
|
||||
backend_committed: watch::Receiver<bool>,
|
||||
release: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl ReplicationResponseHold {
|
||||
fn arm(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if self.armed.load(Ordering::Acquire) {
|
||||
return Err("replication response hold was already armed".into());
|
||||
}
|
||||
self.backend_committed_signal
|
||||
.send(false)
|
||||
.map_err(|_| "replication response hold closed before rearming")?;
|
||||
self.release
|
||||
.send(false)
|
||||
.map_err(|_| "replication response hold closed before rearming")?;
|
||||
self.armed
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map_err(|_| "replication response hold was already armed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_backend_commit(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let wait = async {
|
||||
while !*self.backend_committed.borrow() {
|
||||
self.backend_committed
|
||||
.changed()
|
||||
.await
|
||||
.map_err(|_| "replication response hold closed before the backend committed")?;
|
||||
}
|
||||
Ok::<(), Box<dyn Error + Send + Sync>>(())
|
||||
};
|
||||
timeout(Duration::from_secs(60), wait)
|
||||
.await
|
||||
.map_err(|_| "timed out waiting for the replication backend to commit")?
|
||||
}
|
||||
|
||||
fn release(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
self.release
|
||||
.send(true)
|
||||
.map_err(|_| "replication response hold closed before release")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn replication_response_hold() -> (ReplicationResponseHoldRuntime, ReplicationResponseHold) {
|
||||
let armed = Arc::new(AtomicBool::new(false));
|
||||
let (backend_committed, backend_committed_rx) = watch::channel(false);
|
||||
let (release, release_rx) = watch::channel(false);
|
||||
(
|
||||
ReplicationResponseHoldRuntime {
|
||||
armed: armed.clone(),
|
||||
backend_committed: backend_committed.clone(),
|
||||
release: release_rx,
|
||||
},
|
||||
ReplicationResponseHold {
|
||||
armed,
|
||||
backend_committed_signal: backend_committed,
|
||||
backend_committed: backend_committed_rx,
|
||||
release,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn forward_replication_proxy_request(
|
||||
request: Request<Incoming>,
|
||||
backend_url: &str,
|
||||
@@ -2012,6 +2096,7 @@ async fn forward_replication_proxy_request(
|
||||
request_count: &AtomicU64,
|
||||
mut replication_enabled: watch::Receiver<bool>,
|
||||
mut held_tagging: watch::Receiver<Option<String>>,
|
||||
mut response_hold: ReplicationResponseHoldRuntime,
|
||||
) -> Response<Full<bytes::Bytes>> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let is_replication = parts
|
||||
@@ -2037,6 +2122,7 @@ async fn forward_replication_proxy_request(
|
||||
}
|
||||
}
|
||||
}
|
||||
let hold_response = is_replication && parts.method == http::Method::PUT && response_hold.try_claim();
|
||||
|
||||
let Some(path_and_query) = parts.uri.path_and_query() else {
|
||||
return proxy_error_response("request URI omitted path");
|
||||
@@ -2059,6 +2145,16 @@ async fn forward_replication_proxy_request(
|
||||
Ok(body) => body,
|
||||
Err(error) => return proxy_error_response(error),
|
||||
};
|
||||
if hold_response && status.is_success() {
|
||||
if response_hold.backend_committed.send(true).is_err() {
|
||||
return proxy_error_response("replication response hold closed after the backend committed");
|
||||
}
|
||||
while !*response_hold.release.borrow() {
|
||||
if response_hold.release.changed().await.is_err() {
|
||||
return proxy_error_response("replication response hold closed before release");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut proxied = Response::builder().status(status);
|
||||
for (name, value) in &headers {
|
||||
proxied = proxied.header(name, value);
|
||||
@@ -2070,7 +2166,7 @@ async fn start_replication_counting_proxy(
|
||||
backend_url: &str,
|
||||
tasks: &mut JoinSet<()>,
|
||||
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>), Box<dyn Error + Send + Sync>> {
|
||||
let (proxy_url, request_count, replication_enabled, _held_tagging) =
|
||||
let (proxy_url, request_count, replication_enabled, _held_tagging, _response_hold) =
|
||||
start_replication_counting_proxy_with_tag_hold(backend_url, tasks).await?;
|
||||
Ok((proxy_url, request_count, replication_enabled))
|
||||
}
|
||||
@@ -2082,7 +2178,16 @@ async fn start_replication_counting_proxy(
|
||||
async fn start_replication_counting_proxy_with_tag_hold(
|
||||
backend_url: &str,
|
||||
tasks: &mut JoinSet<()>,
|
||||
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>, watch::Sender<Option<String>>), Box<dyn Error + Send + Sync>> {
|
||||
) -> Result<
|
||||
(
|
||||
String,
|
||||
Arc<AtomicU64>,
|
||||
watch::Sender<bool>,
|
||||
watch::Sender<Option<String>>,
|
||||
ReplicationResponseHold,
|
||||
),
|
||||
Box<dyn Error + Send + Sync>,
|
||||
> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let proxy_url = format!("http://{}", listener.local_addr()?);
|
||||
let backend_url = backend_url.to_string();
|
||||
@@ -2090,6 +2195,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
let task_request_count = request_count.clone();
|
||||
let (replication_enabled, task_replication_enabled) = watch::channel(true);
|
||||
let (held_tagging, task_held_tagging) = watch::channel(None);
|
||||
let (response_hold_runtime, response_hold) = replication_response_hold();
|
||||
tasks.spawn(async move {
|
||||
let client = local_http_client();
|
||||
let mut connections = JoinSet::new();
|
||||
@@ -2102,6 +2208,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
let request_count = task_request_count.clone();
|
||||
let replication_enabled = task_replication_enabled.clone();
|
||||
let held_tagging = task_held_tagging.clone();
|
||||
let response_hold = response_hold_runtime.clone();
|
||||
connections.spawn(async move {
|
||||
let service = service_fn(move |request| {
|
||||
let backend_url = backend_url.clone();
|
||||
@@ -2109,6 +2216,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
let request_count = request_count.clone();
|
||||
let replication_enabled = replication_enabled.clone();
|
||||
let held_tagging = held_tagging.clone();
|
||||
let response_hold = response_hold.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(
|
||||
forward_replication_proxy_request(
|
||||
@@ -2118,6 +2226,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
&request_count,
|
||||
replication_enabled,
|
||||
held_tagging,
|
||||
response_hold,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -2130,7 +2239,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok((proxy_url, request_count, replication_enabled, held_tagging))
|
||||
Ok((proxy_url, request_count, replication_enabled, held_tagging, response_hold))
|
||||
}
|
||||
|
||||
async fn site_replication_remove(
|
||||
@@ -3821,6 +3930,12 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut source_env_vars = replication_fast_env();
|
||||
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
// This matrix verifies request-time rule/admission behavior. Keep the
|
||||
// background existing-object scanner outside the observation window: with
|
||||
// ExistingObjectReplication enabled it may legitimately discover an
|
||||
// object after its tags change, which is a separate data-replication path
|
||||
// that PR #5696 intentionally did not alter.
|
||||
source_env_vars.push(("RUSTFS_SCANNER_START_DELAY_SECS", "300"));
|
||||
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
|
||||
|
||||
let mut target_env_a = RustFSTestEnvironment::new().await?;
|
||||
@@ -4049,6 +4164,13 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
||||
.await?;
|
||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||
|
||||
// A metadata edit must not retroactively admit data that failed the tag
|
||||
// filter at PUT time. This is the PR #5696 safety boundary: the target ARN
|
||||
// has no persisted data-admission state for this version, so adding the
|
||||
// matching tag later remains metadata-only and fails closed.
|
||||
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
@@ -7152,6 +7274,27 @@ async fn put_single_tag(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn put_single_tag_current(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
tag_key: &str,
|
||||
tag_value: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
client
|
||||
.put_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.tagging(
|
||||
aws_sdk_s3::types::Tagging::builder()
|
||||
.tag_set(aws_sdk_s3::types::Tag::builder().key(tag_key).value(tag_value).build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_single_tag(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
@@ -7199,6 +7342,28 @@ async fn wait_for_single_tag(
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll one site until `tag_key` is absent from the selected version.
|
||||
async fn wait_for_tag_absent(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: &str,
|
||||
tag_key: &str,
|
||||
site: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let observed = get_single_tag(client, bucket, key, version_id, tag_key).await?;
|
||||
if observed.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("{site}: {bucket}/{key}?versionId={version_id} tag {tag_key} remained {observed:?}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tag key the dual-node LWW scenario edits on both sites.
|
||||
const LWW_TAG_KEY: &str = "owner";
|
||||
|
||||
@@ -7246,6 +7411,265 @@ async fn wait_for_proxy_replication_requests(
|
||||
}
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2099: metadata admission must not drop a tag edit made after
|
||||
/// the target has committed the initial object but before the source persists
|
||||
/// that replication as COMPLETED.
|
||||
#[tokio::test]
|
||||
async fn test_site_replication_tagging_during_initial_pending_window_converges() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
// `RustFSTestEnvironment::start_rustfs_server_with_env` resolves (and on a
|
||||
// cold checkout builds) this binary synchronously. Keep that setup outside
|
||||
// the scenario timeout so 180 seconds measures the runtime race rather
|
||||
// than compilation latency.
|
||||
let _rustfs_binary = rustfs_binary_path();
|
||||
|
||||
match timeout(Duration::from_secs(180), async {
|
||||
const PAYLOAD: &str = "tagging during pending replication";
|
||||
const TAG_KEY: &str = "window";
|
||||
const TAG_VALUE: &str = "pending";
|
||||
const DELETE_PAYLOAD: &str = "tag deletion during pending replication";
|
||||
const DELETE_TAG_KEY: &str = "remove";
|
||||
const DELETE_TAG_VALUE: &str = "while-pending";
|
||||
|
||||
let mut site_env = replication_fast_env();
|
||||
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
|
||||
let mut site_a_env = RustFSTestEnvironment::new().await?;
|
||||
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
|
||||
|
||||
let mut site_b_env = RustFSTestEnvironment::new().await?;
|
||||
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
|
||||
|
||||
let site_a_client = site_a_env.create_s3_client();
|
||||
let site_b_client = site_b_env.create_s3_client();
|
||||
let mut proxy_tasks = JoinSet::new();
|
||||
let (
|
||||
site_b_proxy,
|
||||
_site_b_replication_requests,
|
||||
_site_b_replication_enabled,
|
||||
_site_b_held_tagging,
|
||||
mut site_b_response_hold,
|
||||
) = start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
|
||||
|
||||
let add_status = site_replication_add(
|
||||
&site_a_env,
|
||||
&[
|
||||
PeerSite {
|
||||
name: "pending-site-a".to_string(),
|
||||
endpoint: site_a_env.url.clone(),
|
||||
access_key: site_a_env.access_key.clone(),
|
||||
secret_key: site_a_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
PeerSite {
|
||||
name: "pending-site-b".to_string(),
|
||||
endpoint: site_b_env.url.clone(),
|
||||
access_key: site_b_env.access_key.clone(),
|
||||
secret_key: site_b_env.secret_key.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
assert!(add_status.success, "unexpected site add result: {add_status:?}");
|
||||
|
||||
let site_info = wait_for_site_replication_enabled(&site_a_env, 2).await?;
|
||||
wait_for_site_replication_enabled(&site_b_env, 2).await?;
|
||||
let mut site_b_peer = site_info
|
||||
.sites
|
||||
.iter()
|
||||
.find(|peer| peer.endpoint == site_b_env.url.as_str())
|
||||
.ok_or("site B peer missing from replication info")?
|
||||
.clone();
|
||||
site_b_peer.endpoint = site_b_proxy.clone();
|
||||
site_b_peer.sync_state = SyncStatus::Enable;
|
||||
let edit = site_replication_edit(&site_a_env, "", &site_b_peer).await?;
|
||||
assert!(edit.success, "unexpected site B endpoint edit: {edit:?}");
|
||||
for env in [&site_a_env, &site_b_env] {
|
||||
wait_for_site_replication_info(env, |info| info.sites.iter().any(|peer| peer.endpoint == site_b_proxy)).await?;
|
||||
}
|
||||
|
||||
let bucket = "site-repl-tag-pending";
|
||||
let key = "pending-window.txt";
|
||||
site_a_client.create_bucket().bucket(bucket).send().await?;
|
||||
wait_for_bucket_on_target(&site_b_client, bucket).await?;
|
||||
|
||||
site_b_response_hold.arm()?;
|
||||
let put_task = {
|
||||
let client = site_a_client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = key.to_string();
|
||||
tokio::spawn(async move {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(PAYLOAD.as_bytes()))
|
||||
.send()
|
||||
.await
|
||||
})
|
||||
};
|
||||
|
||||
// The proxy reads B's complete successful response before parking it,
|
||||
// so B's GET proves the object is committed while A's worker is still
|
||||
// unable to persist COMPLETED.
|
||||
site_b_response_hold.wait_for_backend_commit().await?;
|
||||
wait_for_replicated_object(&site_b_client, bucket, key, PAYLOAD).await?;
|
||||
let source_head = site_a_client.head_object().bucket(bucket).key(key).send().await?;
|
||||
let version_id = source_head
|
||||
.version_id()
|
||||
.ok_or("source HEAD omitted the pending version ID")?
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
source_head.replication_status().map(|status| status.as_str()),
|
||||
Some("PENDING"),
|
||||
"source must still report PENDING while the initial replication response is held"
|
||||
);
|
||||
|
||||
let tag_task = {
|
||||
let client = site_a_client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = key.to_string();
|
||||
// The original real-machine failure used the current-version S3
|
||||
// API (no versionId). The delete phase below deliberately keeps an
|
||||
// explicit versionId so both request shapes stay covered.
|
||||
tokio::spawn(async move { put_single_tag_current(&client, &bucket, &key, TAG_KEY, TAG_VALUE).await })
|
||||
};
|
||||
wait_for_single_tag(&site_a_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site A").await?;
|
||||
assert_eq!(
|
||||
head_replication_status(&site_a_client, bucket, key, &version_id)
|
||||
.await?
|
||||
.as_deref(),
|
||||
Some("PENDING"),
|
||||
"tag update must be authored before the initial replication reaches COMPLETED"
|
||||
);
|
||||
|
||||
site_b_response_hold.release()?;
|
||||
let put_output = timeout(Duration::from_secs(60), put_task)
|
||||
.await
|
||||
.map_err(|_| "source PutObject remained blocked after releasing the replication response")???;
|
||||
timeout(Duration::from_secs(60), tag_task)
|
||||
.await
|
||||
.map_err(|_| "PutObjectTagging remained blocked after releasing the replication response")???;
|
||||
assert_eq!(put_output.version_id(), Some(version_id.as_str()));
|
||||
|
||||
wait_for_single_tag(&site_b_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site B").await?;
|
||||
wait_for_version_replication_status(&site_a_client, bucket, key, &version_id, &["COMPLETED"], "site A").await?;
|
||||
wait_for_version_replication_status(&site_b_client, bucket, key, &version_id, &["REPLICA"], "site B").await?;
|
||||
|
||||
let source_state = list_replication_state(&site_a_client, bucket).await?;
|
||||
let target_state = list_replication_state(&site_b_client, bucket).await?;
|
||||
assert_eq!(source_state, target_state, "tag replication must not fork the object version");
|
||||
assert_eq!(source_state.len(), 1, "tag replication must leave exactly one object version");
|
||||
assert_eq!(source_state[0].key, key);
|
||||
assert_eq!(source_state[0].version_id, version_id);
|
||||
|
||||
// Re-arm the same response barrier for an initially tagged object.
|
||||
// Deleting its tag while A is still PENDING proves the same admission
|
||||
// rule covers DeleteObjectTagging rather than only the original PUT
|
||||
// symptom.
|
||||
let delete_key = "pending-delete-window.txt";
|
||||
site_b_response_hold.arm()?;
|
||||
let delete_put_task = {
|
||||
let client = site_a_client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = delete_key.to_string();
|
||||
tokio::spawn(async move {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.tagging(format!("{DELETE_TAG_KEY}={DELETE_TAG_VALUE}"))
|
||||
.body(ByteStream::from_static(DELETE_PAYLOAD.as_bytes()))
|
||||
.send()
|
||||
.await
|
||||
})
|
||||
};
|
||||
|
||||
site_b_response_hold.wait_for_backend_commit().await?;
|
||||
wait_for_replicated_object(&site_b_client, bucket, delete_key, DELETE_PAYLOAD).await?;
|
||||
let delete_source_head = site_a_client.head_object().bucket(bucket).key(delete_key).send().await?;
|
||||
let delete_version_id = delete_source_head
|
||||
.version_id()
|
||||
.ok_or("source HEAD omitted the pending tagged version ID")?
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
delete_source_head.replication_status().map(|status| status.as_str()),
|
||||
Some("PENDING"),
|
||||
"source tagged object must remain PENDING while its initial response is held"
|
||||
);
|
||||
wait_for_single_tag(
|
||||
&site_b_client,
|
||||
bucket,
|
||||
delete_key,
|
||||
&delete_version_id,
|
||||
DELETE_TAG_KEY,
|
||||
DELETE_TAG_VALUE,
|
||||
"site B",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let delete_tag_task = {
|
||||
let client = site_a_client.clone();
|
||||
let bucket = bucket.to_string();
|
||||
let key = delete_key.to_string();
|
||||
let version_id = delete_version_id.clone();
|
||||
tokio::spawn(async move {
|
||||
client
|
||||
.delete_object_tagging()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await
|
||||
})
|
||||
};
|
||||
wait_for_tag_absent(&site_a_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site A").await?;
|
||||
assert_eq!(
|
||||
head_replication_status(&site_a_client, bucket, delete_key, &delete_version_id)
|
||||
.await?
|
||||
.as_deref(),
|
||||
Some("PENDING"),
|
||||
"tag deletion must be authored before the initial replication reaches COMPLETED"
|
||||
);
|
||||
|
||||
site_b_response_hold.release()?;
|
||||
let delete_put_output = timeout(Duration::from_secs(60), delete_put_task)
|
||||
.await
|
||||
.map_err(|_| "source tagged PutObject remained blocked after releasing the replication response")???;
|
||||
timeout(Duration::from_secs(60), delete_tag_task)
|
||||
.await
|
||||
.map_err(|_| "DeleteObjectTagging remained blocked after releasing the replication response")???;
|
||||
assert_eq!(delete_put_output.version_id(), Some(delete_version_id.as_str()));
|
||||
|
||||
wait_for_tag_absent(&site_b_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site B").await?;
|
||||
wait_for_version_replication_status(&site_a_client, bucket, delete_key, &delete_version_id, &["COMPLETED"], "site A")
|
||||
.await?;
|
||||
wait_for_version_replication_status(&site_b_client, bucket, delete_key, &delete_version_id, &["REPLICA"], "site B")
|
||||
.await?;
|
||||
|
||||
let source_state = list_replication_state(&site_a_client, bucket).await?;
|
||||
let target_state = list_replication_state(&site_b_client, bucket).await?;
|
||||
assert_eq!(source_state, target_state, "tag deletion must not fork the object version");
|
||||
assert_eq!(source_state.len(), 2, "pending-window scenarios must leave exactly two object versions");
|
||||
assert!(
|
||||
source_state
|
||||
.iter()
|
||||
.any(|entry| entry.key == delete_key && entry.version_id == delete_version_id),
|
||||
"tag deletion must preserve the original version identity"
|
||||
);
|
||||
|
||||
proxy_tasks.abort_all();
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("pending-window site-replication tagging test timed out".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// rustfs/backlog#1953 (audit A4/P1-6): receiver-side LWW for replicated
|
||||
/// metadata categories, exercised end to end over the real dual-node
|
||||
/// active-active site-replication control plane — sender, worker, status
|
||||
@@ -7282,9 +7706,9 @@ async fn test_site_replication_tagging_lww_converges_active_active_real_dual_nod
|
||||
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
|
||||
|
||||
let mut proxy_tasks = JoinSet::new();
|
||||
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging) =
|
||||
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging, _site_a_response_hold) =
|
||||
start_replication_counting_proxy_with_tag_hold(&site_a_env.url, &mut proxy_tasks).await?;
|
||||
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging) =
|
||||
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging, _site_b_response_hold) =
|
||||
start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
|
||||
|
||||
let site_a_client = site_a_env.create_s3_client();
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Outbound target matrix: every object shape RustFS replicates, against
|
||||
//! every remote-target failure mode the fake target models.
|
||||
//!
|
||||
//! The matrix exists because a fix for one target class shipped a regression
|
||||
//! for another (rustfs#6895 fixed rustfs#6853 and caused rustfs#7082; see
|
||||
//! `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`).
|
||||
//! Each row is one target mode with its own RustFS source and fake target;
|
||||
//! each cell is one object shape. [`expectation`] is the single place that
|
||||
//! says what a cell must do today:
|
||||
//!
|
||||
//! - `Completed` cells must replicate and the target must hold the source
|
||||
//! bytes; the journal must also show the wire shape the cell relies on.
|
||||
//! - `KnownFailing` cells pin an open issue. They must fail for the recorded
|
||||
//! reason, and the moment they start passing the test fails with an XPASS
|
||||
//! message so the expectation is flipped in the same PR as the fix.
|
||||
//!
|
||||
//! Adding a target behavior the fleet has shown: add the mode to the fake
|
||||
//! target, add a row here, and record any cell that is red before the fix.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, replication_fast_env};
|
||||
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
|
||||
use crate::fake_s3_target::{FakeS3Target, Operation as FakeTargetOperation, RequestRecord};
|
||||
use crate::on_demand_migration::common::fake_source_client;
|
||||
use crate::replication_extension_test::{
|
||||
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, put_bucket_replication,
|
||||
set_replication_target_with_options,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTime};
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockMode};
|
||||
use bytes::Bytes;
|
||||
use std::error::Error;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
/// A remote-target behavior the fleet has shown, as the fake target models it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum TargetMode {
|
||||
/// RustFS / MinIO-like target: adopts source version ids, decodes any
|
||||
/// framing, enforces no checksum rule.
|
||||
Baseline,
|
||||
/// SeaweedFS 3.97 (rustfs#6853): refuses `aws-chunked` bodies. A sender
|
||||
/// that frames its uploads gets a hard failure here instead of a
|
||||
/// silently corrupted replica.
|
||||
RejectAwsChunked,
|
||||
/// AWS S3 / MinIO / Impossible Cloud (rustfs#7082): a PutObject with
|
||||
/// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`.
|
||||
RequireChecksumWithObjectLock,
|
||||
/// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids
|
||||
/// (rustfs/backlog#2085). Data must still land.
|
||||
MintOwnVersionIds,
|
||||
}
|
||||
|
||||
impl TargetMode {
|
||||
const ALL: [TargetMode; 4] = [
|
||||
TargetMode::Baseline,
|
||||
TargetMode::RejectAwsChunked,
|
||||
TargetMode::RequireChecksumWithObjectLock,
|
||||
TargetMode::MintOwnVersionIds,
|
||||
];
|
||||
|
||||
fn apply(self, target: &FakeS3Target) {
|
||||
match self {
|
||||
TargetMode::Baseline => {}
|
||||
TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true),
|
||||
TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true),
|
||||
TargetMode::MintOwnVersionIds => target.assign_own_version_ids(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn slug(self) -> &'static str {
|
||||
match self {
|
||||
TargetMode::Baseline => "baseline",
|
||||
TargetMode::RejectAwsChunked => "reject-aws-chunked",
|
||||
TargetMode::RequireChecksumWithObjectLock => "require-checksum-object-lock",
|
||||
TargetMode::MintOwnVersionIds => "mint-own-version-ids",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An object shape the replication transport treats differently.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ObjectShape {
|
||||
/// The exact rustfs#7082 reproduction: a zero-byte object.
|
||||
Empty,
|
||||
/// Small single-part object with no Object Lock parameters.
|
||||
Plain,
|
||||
/// Single-part object with a GOVERNANCE retention period.
|
||||
Retention,
|
||||
/// Single-part object with legal hold ON.
|
||||
LegalHold,
|
||||
/// Two-part multipart upload, no Object Lock parameters.
|
||||
Multipart,
|
||||
/// Two-part multipart upload with a GOVERNANCE retention period; the
|
||||
/// lock headers travel on CreateMultipartUpload, which has no body.
|
||||
LockedMultipart,
|
||||
}
|
||||
|
||||
impl ObjectShape {
|
||||
const ALL: [ObjectShape; 6] = [
|
||||
ObjectShape::Empty,
|
||||
ObjectShape::Plain,
|
||||
ObjectShape::Retention,
|
||||
ObjectShape::LegalHold,
|
||||
ObjectShape::Multipart,
|
||||
ObjectShape::LockedMultipart,
|
||||
];
|
||||
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
ObjectShape::Empty => "matrix/empty.bin",
|
||||
ObjectShape::Plain => "matrix/plain.bin",
|
||||
ObjectShape::Retention => "matrix/retention.bin",
|
||||
ObjectShape::LegalHold => "matrix/legal-hold.bin",
|
||||
ObjectShape::Multipart => "matrix/multipart.bin",
|
||||
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
|
||||
}
|
||||
}
|
||||
|
||||
fn carries_object_lock_params(self) -> bool {
|
||||
matches!(self, ObjectShape::Retention | ObjectShape::LegalHold | ObjectShape::LockedMultipart)
|
||||
}
|
||||
|
||||
/// Upload the shape to the source and return the bytes the target must
|
||||
/// end up holding.
|
||||
async fn put(self, client: &Client, bucket: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
|
||||
let key = self.key();
|
||||
match self {
|
||||
ObjectShape::Empty => {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b""))
|
||||
.send()
|
||||
.await?;
|
||||
Ok(Bytes::new())
|
||||
}
|
||||
ObjectShape::Plain => {
|
||||
let body = payload(64 * 1024, 0x11);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
Ok(body)
|
||||
}
|
||||
ObjectShape::Retention => {
|
||||
let body = payload(48 * 1024, 0x22);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.object_lock_mode(ObjectLockMode::Governance)
|
||||
.object_lock_retain_until_date(retain_until())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(body)
|
||||
}
|
||||
ObjectShape::LegalHold => {
|
||||
let body = payload(32 * 1024, 0x33);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(body)
|
||||
}
|
||||
ObjectShape::Multipart => multipart_put(client, bucket, key, 0x44, false).await,
|
||||
ObjectShape::LockedMultipart => multipart_put(client, bucket, key, 0x55, true).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Expectation {
|
||||
/// Replicates COMPLETED and the target holds the source bytes.
|
||||
Completed,
|
||||
/// Replicates FAILED today for a recorded reason; pinned to an open issue.
|
||||
KnownFailing(&'static str),
|
||||
}
|
||||
|
||||
/// The cells that are red today, each pinned to the open issue that owns it.
|
||||
/// This is the single source of truth: a fix that turns a cell green must
|
||||
/// remove its entry in the same PR, and [`check_known_failing_cell`] refuses
|
||||
/// an unexpected pass so the table cannot go stale silently. rustfs#7082
|
||||
/// (Retention and LegalHold against the checksum-requiring target) lived
|
||||
/// here until the replication PUT started carrying a Content-MD5 derived
|
||||
/// from the source ETag.
|
||||
const KNOWN_FAILING_CELLS: &[(TargetMode, ObjectShape, &str)] = &[];
|
||||
|
||||
fn expectation(mode: TargetMode, shape: ObjectShape) -> Expectation {
|
||||
KNOWN_FAILING_CELLS
|
||||
.iter()
|
||||
.find(|(known_mode, known_shape, _)| *known_mode == mode && *known_shape == shape)
|
||||
.map(|(_, _, issue)| Expectation::KnownFailing(issue))
|
||||
.unwrap_or(Expectation::Completed)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matrix_baseline_target() -> TestResult {
|
||||
run_row(TargetMode::Baseline).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matrix_reject_aws_chunked_target() -> TestResult {
|
||||
run_row(TargetMode::RejectAwsChunked).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matrix_require_checksum_with_object_lock_target() -> TestResult {
|
||||
run_row(TargetMode::RequireChecksumWithObjectLock).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matrix_mint_own_version_ids_target() -> TestResult {
|
||||
run_row(TargetMode::MintOwnVersionIds).await
|
||||
}
|
||||
|
||||
/// Every known-red entry must name a real cell and an issue, and the lookup
|
||||
/// must round-trip, so a stale or mistyped entry cannot silently pin nothing.
|
||||
#[test]
|
||||
fn known_failing_table_names_real_cells() {
|
||||
for (mode, shape, issue) in KNOWN_FAILING_CELLS {
|
||||
assert!(
|
||||
TargetMode::ALL.contains(mode) && ObjectShape::ALL.contains(shape),
|
||||
"{mode:?}/{shape:?} is not a matrix cell"
|
||||
);
|
||||
assert!(
|
||||
issue.starts_with("rustfs#") || issue.starts_with("rustfs/backlog#"),
|
||||
"{issue} must name an open issue"
|
||||
);
|
||||
assert_eq!(expectation(*mode, *shape), Expectation::KnownFailing(issue));
|
||||
}
|
||||
let red_cells = TargetMode::ALL
|
||||
.iter()
|
||||
.flat_map(|mode| ObjectShape::ALL.iter().map(move |shape| (*mode, *shape)))
|
||||
.filter(|(mode, shape)| matches!(expectation(*mode, *shape), Expectation::KnownFailing(_)))
|
||||
.count();
|
||||
assert_eq!(red_cells, KNOWN_FAILING_CELLS.len());
|
||||
}
|
||||
|
||||
async fn run_row(mode: TargetMode) -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let target = FakeS3Target::start().await?;
|
||||
let target_bucket = format!("matrix-{}-dst", mode.slug());
|
||||
target.create_bucket_with_object_lock(target_bucket.clone());
|
||||
mode.apply(&target);
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut env_vars = replication_fast_env();
|
||||
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
|
||||
source_env.start_rustfs_server_with_env(vec![], &env_vars).await?;
|
||||
|
||||
let source_bucket = format!("matrix-{}-src", mode.slug());
|
||||
let source_client = source_env.create_s3_client();
|
||||
source_client
|
||||
.create_bucket()
|
||||
.bucket(&source_bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
enable_bucket_versioning(&source_env, &source_bucket).await?;
|
||||
let target_arn = set_replication_target_with_options(
|
||||
&source_env,
|
||||
&source_bucket,
|
||||
ReplicationTargetOptions {
|
||||
endpoint: &target.address(),
|
||||
access_key: FAKE_ACCESS_KEY,
|
||||
secret_key: FAKE_SECRET_KEY,
|
||||
target_bucket: &target_bucket,
|
||||
secure: false,
|
||||
skip_tls_verify: false,
|
||||
ca_cert_pem: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication(&source_env, &source_bucket, &target_arn).await?;
|
||||
|
||||
let target_client = fake_source_client(&target);
|
||||
let mut failures = Vec::new();
|
||||
for shape in ObjectShape::ALL {
|
||||
let cell = format!("{}/{:?}", mode.slug(), shape);
|
||||
let expected_body = shape.put(&source_client, &source_bucket).await?;
|
||||
let status = wait_for_terminal_replication_status(&source_client, &source_bucket, shape.key()).await?;
|
||||
let journal = target.requests();
|
||||
let outcome = match expectation(mode, shape) {
|
||||
Expectation::Completed => {
|
||||
check_completed_cell(&cell, &status, &target_client, &target_bucket, shape, &expected_body, &journal).await
|
||||
}
|
||||
Expectation::KnownFailing(issue) => check_known_failing_cell(&cell, issue, &status, shape, &journal),
|
||||
};
|
||||
if let Err(err) = outcome {
|
||||
failures.push(format!("{cell}: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
target.shutdown().await;
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} matrix cell(s) violated their expectation:\n {}",
|
||||
failures.len(),
|
||||
failures.join("\n ")
|
||||
)
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_completed_cell(
|
||||
cell: &str,
|
||||
status: &str,
|
||||
target_client: &Client,
|
||||
target_bucket: &str,
|
||||
shape: ObjectShape,
|
||||
expected_body: &Bytes,
|
||||
journal: &[RequestRecord],
|
||||
) -> TestResult {
|
||||
if status != "COMPLETED" {
|
||||
return Err(format!("expected COMPLETED, source reports {status}").into());
|
||||
}
|
||||
let stored = target_client
|
||||
.get_object()
|
||||
.bucket(target_bucket)
|
||||
.key(shape.key())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("target GET failed after COMPLETED: {err}"))?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
if stored != *expected_body {
|
||||
return Err(format!(
|
||||
"target holds {} bytes that differ from the {} source bytes (COMPLETED over a corrupted replica)",
|
||||
stored.len(),
|
||||
expected_body.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// The wire shape the cell relies on: plain signed payloads (rustfs#6853)
|
||||
// for every upload of this key, and the lock headers present exactly when
|
||||
// the shape carries them.
|
||||
let uploads: Vec<&RequestRecord> = journal
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record.key.as_deref() == Some(shape.key())
|
||||
&& matches!(
|
||||
record.operation,
|
||||
FakeTargetOperation::PutObject | FakeTargetOperation::UploadPart | FakeTargetOperation::CreateMultipartUpload
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if uploads.is_empty() {
|
||||
return Err("no upload reached the target although the source reports COMPLETED".into());
|
||||
}
|
||||
if let Some(framed) = uploads.iter().find(|record| record.transport.aws_chunked) {
|
||||
return Err(format!("{cell}: an upload went out aws-chunked (rustfs#6853 framing): {framed:?}").into());
|
||||
}
|
||||
let lock_headers_seen = uploads.iter().any(|record| record.transport.object_lock_params);
|
||||
if lock_headers_seen != shape.carries_object_lock_params() {
|
||||
return Err(format!(
|
||||
"object lock headers on the wire: {lock_headers_seen}, shape carries them: {}",
|
||||
shape.carries_object_lock_params()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// rustfs#7082 contract: every PutObject that carries Object Lock
|
||||
// parameters also carries Content-MD5 or an x-amz-checksum-* header,
|
||||
// whatever the target's own policy is.
|
||||
if let Some(bare) = uploads.iter().find(|record| {
|
||||
record.operation == FakeTargetOperation::PutObject
|
||||
&& record.transport.object_lock_params
|
||||
&& record.transport.content_md5.is_none()
|
||||
&& record.transport.checksum_headers.is_empty()
|
||||
}) {
|
||||
return Err(format!("a locked PutObject went out without any integrity header (rustfs#7082): {bare:?}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_known_failing_cell(cell: &str, issue: &str, status: &str, shape: ObjectShape, journal: &[RequestRecord]) -> TestResult {
|
||||
if status == "COMPLETED" {
|
||||
return Err(format!(
|
||||
"XPASS: {cell} reached COMPLETED but the expectation table pins it to {issue}; \
|
||||
the fix landed, so flip this cell to Expectation::Completed in the same PR"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if status != "FAILED" {
|
||||
return Err(format!("expected FAILED ({issue}), source reports {status}").into());
|
||||
}
|
||||
// Fail for the recorded reason, not by accident: the PUT carried the lock
|
||||
// headers and no integrity header at all.
|
||||
let rejected = journal.iter().any(|record| {
|
||||
record.operation == FakeTargetOperation::PutObject
|
||||
&& record.key.as_deref() == Some(shape.key())
|
||||
&& record.transport.object_lock_params
|
||||
&& record.transport.content_md5.is_none()
|
||||
&& record.transport.checksum_headers.is_empty()
|
||||
});
|
||||
if !rejected {
|
||||
return Err(format!(
|
||||
"FAILED, but not for the {issue} reason (a locked PUT without Content-MD5 / x-amz-checksum-*); journal: {journal:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// First terminal replication status (`COMPLETED` or `FAILED`) the source
|
||||
/// reports for the key.
|
||||
async fn wait_for_terminal_replication_status(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let wait = async {
|
||||
loop {
|
||||
let head = client.head_object().bucket(bucket).key(key).send().await?;
|
||||
match head.replication_status().map(|status| status.as_str().to_string()) {
|
||||
Some(status) if status == "COMPLETED" || status == "FAILED" => return Ok(status),
|
||||
_ => sleep(Duration::from_millis(200)).await,
|
||||
}
|
||||
}
|
||||
};
|
||||
match timeout(Duration::from_secs(90), wait).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("{key} reached no terminal replication status within 90 seconds").into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn multipart_put(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
fill: u8,
|
||||
locked: bool,
|
||||
) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
|
||||
let part_one = payload(5 * 1024 * 1024, fill);
|
||||
let part_two = payload(256 * 1024, fill.wrapping_add(1));
|
||||
let mut create = client.create_multipart_upload().bucket(bucket).key(key);
|
||||
if locked {
|
||||
create = create
|
||||
.object_lock_mode(ObjectLockMode::Governance)
|
||||
.object_lock_retain_until_date(retain_until());
|
||||
}
|
||||
let upload_id = create
|
||||
.send()
|
||||
.await?
|
||||
.upload_id()
|
||||
.ok_or("CreateMultipartUpload returned no upload id")?
|
||||
.to_string();
|
||||
let mut completed = Vec::new();
|
||||
for (number, part) in [(1, &part_one), (2, &part_two)] {
|
||||
let etag = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(number)
|
||||
.body(ByteStream::from(part.clone()))
|
||||
.send()
|
||||
.await?
|
||||
.e_tag()
|
||||
.ok_or("UploadPart returned no ETag")?
|
||||
.to_string();
|
||||
completed.push(CompletedPart::builder().part_number(number).e_tag(etag).build());
|
||||
}
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
|
||||
.send()
|
||||
.await?;
|
||||
let mut body = Vec::with_capacity(part_one.len() + part_two.len());
|
||||
body.extend_from_slice(&part_one);
|
||||
body.extend_from_slice(&part_two);
|
||||
Ok(Bytes::from(body))
|
||||
}
|
||||
|
||||
fn payload(len: usize, fill: u8) -> Bytes {
|
||||
Bytes::from((0..len).map(|i| fill.wrapping_add((i % 251) as u8)).collect::<Vec<u8>>())
|
||||
}
|
||||
|
||||
fn retain_until() -> DateTime {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock after epoch")
|
||||
.as_secs();
|
||||
DateTime::from_secs(now as i64 + 86_400)
|
||||
}
|
||||
@@ -17,8 +17,56 @@ mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use flate2::{Compression, write::GzEncoder};
|
||||
use std::error::Error;
|
||||
use std::io::Cursor;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn pax_record(key: &str, value: &str) -> Vec<u8> {
|
||||
let payload = format!("{key}={value}\n");
|
||||
let mut len = payload.len() + 3;
|
||||
loop {
|
||||
let record = format!("{len} {payload}");
|
||||
if record.len() == len {
|
||||
return record.into_bytes();
|
||||
}
|
||||
len = record.len();
|
||||
}
|
||||
}
|
||||
|
||||
async fn append_pax_header(
|
||||
builder: &mut tokio_tar::Builder<Cursor<Vec<u8>>>,
|
||||
entry_type: tokio_tar::EntryType,
|
||||
records: &[(&str, &str)],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut payload = Vec::new();
|
||||
for (key, value) in records {
|
||||
payload.extend(pax_record(key, value));
|
||||
}
|
||||
let mut header = tokio_tar::Header::new_ustar();
|
||||
header.set_entry_type(entry_type);
|
||||
header.set_size(u64::try_from(payload.len()).expect("PAX payload length should fit in u64"));
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, "PaxHeaders.X/snowball", Cursor::new(payload))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn append_typed_entry(
|
||||
builder: &mut tokio_tar::Builder<Cursor<Vec<u8>>>,
|
||||
path: &str,
|
||||
entry_type: tokio_tar::EntryType,
|
||||
body: &[u8],
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut header = tokio_tar::Header::new_gnu();
|
||||
header.set_entry_type(entry_type);
|
||||
header.set_size(u64::try_from(body.len()).expect("TAR member length should fit in u64"));
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, path, Cursor::new(body)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn build_test_archive() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
@@ -69,12 +117,50 @@ mod tests {
|
||||
Ok(builder.into_inner().await?.into_inner())
|
||||
}
|
||||
|
||||
fn build_archive_with_parent_dir_entry(victim_bucket: &str) -> Vec<u8> {
|
||||
let path = format!("../{victim_bucket}/evil-injected.txt");
|
||||
let data = b"injected-body";
|
||||
async fn build_archive_with_invalid_checksum() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let mut archive = build_test_archive().await?;
|
||||
archive[0] ^= 1;
|
||||
Ok(archive)
|
||||
}
|
||||
|
||||
async fn build_archive_with_negative_gnu_mtime() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
let mut header = tokio_tar::Header::new_gnu();
|
||||
header.set_size(b"negative-mtime-body".len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.as_old_mut().mtime.fill(0xff);
|
||||
builder
|
||||
.append_data(&mut header, "negative-mtime.txt", Cursor::new(b"negative-mtime-body".as_slice()))
|
||||
.await?;
|
||||
Ok(builder.into_inner().await?.into_inner())
|
||||
}
|
||||
|
||||
fn gzip_member(payload: &[u8]) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(payload)?;
|
||||
Ok(encoder.finish()?)
|
||||
}
|
||||
|
||||
async fn build_concatenated_gzip_archive() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let archive = build_test_archive().await?;
|
||||
let split_at = archive.len() / 2;
|
||||
let mut encoded = gzip_member(&archive[..split_at])?;
|
||||
encoded.extend(gzip_member(&archive[split_at..])?);
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
async fn build_gzip_archive_with_invalid_crc() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let mut encoded = gzip_member(&build_test_archive().await?)?;
|
||||
let crc_offset = encoded.len().checked_sub(8).expect("gzip fixture must contain a trailer");
|
||||
encoded[crc_offset] ^= 1;
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
fn append_raw_tar_entry_with_type(archive: &mut Vec<u8>, path: &[u8], data: &[u8], entry_type: u8) {
|
||||
assert!(path.len() <= 100, "raw TAR fixture path must fit in the name field");
|
||||
let mut header = [0u8; 512];
|
||||
|
||||
header[..path.len()].copy_from_slice(path.as_bytes());
|
||||
header[..path.len()].copy_from_slice(path);
|
||||
header[100..108].copy_from_slice(b"0000644\0");
|
||||
header[108..116].copy_from_slice(b"0000000\0");
|
||||
header[116..124].copy_from_slice(b"0000000\0");
|
||||
@@ -82,7 +168,7 @@ mod tests {
|
||||
header[124..136].copy_from_slice(size.as_bytes());
|
||||
header[136..148].copy_from_slice(b"00000000000\0");
|
||||
header[148..156].fill(b' ');
|
||||
header[156] = b'0';
|
||||
header[156] = entry_type;
|
||||
header[257..263].copy_from_slice(b"ustar\0");
|
||||
header[263..265].copy_from_slice(b"00");
|
||||
|
||||
@@ -90,11 +176,87 @@ mod tests {
|
||||
let checksum = format!("{:06o}\0 ", checksum);
|
||||
header[148..156].copy_from_slice(checksum.as_bytes());
|
||||
|
||||
let mut archive = Vec::new();
|
||||
archive.extend_from_slice(&header);
|
||||
archive.extend_from_slice(data);
|
||||
let padding = (512 - (data.len() % 512)) % 512;
|
||||
archive.extend(std::iter::repeat_n(0, padding));
|
||||
}
|
||||
|
||||
fn append_raw_tar_entry(archive: &mut Vec<u8>, path: &[u8], data: &[u8]) {
|
||||
append_raw_tar_entry_with_type(archive, path, data, b'0');
|
||||
}
|
||||
|
||||
fn build_archive_with_parent_dir_entry(victim_bucket: &str) -> Vec<u8> {
|
||||
let path = format!("../{victim_bucket}/evil-injected.txt");
|
||||
let mut archive = Vec::new();
|
||||
append_raw_tar_entry(&mut archive, path.as_bytes(), b"injected-body");
|
||||
archive.extend_from_slice(&[0u8; 1024]);
|
||||
archive
|
||||
}
|
||||
|
||||
async fn build_member_semantics_archive() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
append_pax_header(
|
||||
&mut builder,
|
||||
tokio_tar::EntryType::XGlobalHeader,
|
||||
&[
|
||||
("minio.metadata.x-amz-meta-owner", "global"),
|
||||
("minio.metadata.x-amz-meta-snowball-auto-extract", "true"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
append_pax_header(
|
||||
&mut builder,
|
||||
tokio_tar::EntryType::XHeader,
|
||||
&[("minio.metadata.x-amz-meta-owner", "local")],
|
||||
)
|
||||
.await?;
|
||||
append_typed_entry(&mut builder, "regular.txt", tokio_tar::EntryType::Regular, b"regular-body").await?;
|
||||
for (path, entry_type) in [
|
||||
("char", tokio_tar::EntryType::Char),
|
||||
("block", tokio_tar::EntryType::Block),
|
||||
("fifo", tokio_tar::EntryType::Fifo),
|
||||
] {
|
||||
append_typed_entry(&mut builder, path, entry_type, b"").await?;
|
||||
}
|
||||
let mut directory = tokio_tar::Header::new_gnu();
|
||||
directory.set_entry_type(tokio_tar::EntryType::Directory);
|
||||
directory.set_size(0);
|
||||
directory.set_mode(0o755);
|
||||
directory.set_cksum();
|
||||
builder
|
||||
.append_data(&mut directory, "directory/", Cursor::new(Vec::new()))
|
||||
.await?;
|
||||
for (path, entry_type) in [
|
||||
("hard-link", tokio_tar::EntryType::Link),
|
||||
("symlink", tokio_tar::EntryType::Symlink),
|
||||
("continuous", tokio_tar::EntryType::Continuous),
|
||||
("unknown", tokio_tar::EntryType::Other(b'9')),
|
||||
] {
|
||||
append_typed_entry(&mut builder, path, entry_type, b"").await?;
|
||||
}
|
||||
Ok(builder.into_inner().await?.into_inner())
|
||||
}
|
||||
|
||||
async fn build_versioned_member_archive(path: &str, version_id: &str) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
||||
append_pax_header(&mut builder, tokio_tar::EntryType::XHeader, &[("minio.versionId", version_id)]).await?;
|
||||
append_typed_entry(&mut builder, path, tokio_tar::EntryType::Regular, b"versioned-body").await?;
|
||||
Ok(builder.into_inner().await?.into_inner())
|
||||
}
|
||||
|
||||
fn build_archive_with_invalid_utf8_entry() -> Vec<u8> {
|
||||
let mut archive = Vec::new();
|
||||
append_raw_tar_entry(&mut archive, b"invalid-\xff.txt", b"ignored-body");
|
||||
append_raw_tar_entry(&mut archive, b"valid.txt", b"valid-body");
|
||||
archive.extend_from_slice(&[0u8; 1024]);
|
||||
archive
|
||||
}
|
||||
|
||||
fn build_archive_with_invalid_utf8_symlink() -> Vec<u8> {
|
||||
let mut archive = Vec::new();
|
||||
append_raw_tar_entry_with_type(&mut archive, b"invalid-\xff-link", b"", b'2');
|
||||
append_raw_tar_entry(&mut archive, b"valid.txt", b"valid-body");
|
||||
archive.extend_from_slice(&[0u8; 1024]);
|
||||
archive
|
||||
}
|
||||
@@ -135,6 +297,147 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_applies_member_semantics_and_metadata_precedence() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-member-semantics";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.metadata("Minio-Snowball-Prefix", "members")
|
||||
.metadata("owner", "outer")
|
||||
.body(ByteStream::from(build_member_semantics_archive().await?))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let regular = client.head_object().bucket(bucket).key("members/regular.txt").send().await?;
|
||||
let regular_metadata = regular.metadata().expect("regular member should expose metadata");
|
||||
assert_eq!(regular_metadata.get("owner").map(String::as_str), Some("local"));
|
||||
assert!(!regular_metadata.contains_key("snowball-auto-extract"));
|
||||
assert!(!regular_metadata.contains_key("minio-snowball-prefix"));
|
||||
|
||||
for key in ["char", "block", "fifo"] {
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("members/{key}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(head.content_length(), Some(0), "{key} should be materialized as an empty object");
|
||||
assert_eq!(
|
||||
head.metadata().and_then(|metadata| metadata.get("owner")).map(String::as_str),
|
||||
Some("outer"),
|
||||
"{key} should not inherit global PAX metadata"
|
||||
);
|
||||
}
|
||||
let directory = client.head_object().bucket(bucket).key("members/directory/").send().await?;
|
||||
assert_eq!(directory.content_length(), Some(0));
|
||||
|
||||
for key in ["hard-link", "symlink", "continuous", "unknown"] {
|
||||
let error = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(format!("members/{key}"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("unsupported TAR entry type must be skipped");
|
||||
assert_eq!(error.into_service_error().code(), Some("NotFound"), "{key}");
|
||||
}
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_validates_pax_version_id_against_bucket_state() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-version-semantics";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("null.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.body(ByteStream::from(build_versioned_member_archive("null.txt", "null").await?))
|
||||
.send()
|
||||
.await?;
|
||||
let null_member = client.get_object().bucket(bucket).key("null.txt").send().await?;
|
||||
assert_eq!(null_member.body.collect().await?.into_bytes().as_ref(), b"versioned-body");
|
||||
|
||||
for (archive_key, member_key, version_id) in [
|
||||
("uuid.tar", "uuid.txt", uuid::Uuid::new_v4().to_string()),
|
||||
("uppercase-null.tar", "uppercase-null.txt", "NULL".to_string()),
|
||||
] {
|
||||
let error = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(archive_key)
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.body(ByteStream::from(build_versioned_member_archive(member_key, &version_id).await?))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("invalid or unversioned UUID import must be rejected");
|
||||
assert_eq!(error.into_service_error().code(), Some("InvalidArgument"), "{archive_key}");
|
||||
let missing = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(member_key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("rejected version import must not create an object");
|
||||
assert_eq!(missing.into_service_error().code(), Some("NotFound"), "{member_key}");
|
||||
}
|
||||
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
aws_sdk_s3::types::VersioningConfiguration::builder()
|
||||
.status(aws_sdk_s3::types::BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let imported_version_id = uuid::Uuid::new_v4().to_string();
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("versioned-uuid.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.body(ByteStream::from(
|
||||
build_versioned_member_archive("versioned-uuid.txt", &imported_version_id).await?,
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
let imported = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("versioned-uuid.txt")
|
||||
.version_id(&imported_version_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(imported.version_id(), Some(imported_version_id.as_str()));
|
||||
assert_eq!(imported.body.collect().await?.into_bytes().as_ref(), b"versioned-body");
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_supports_standard_headers_with_combined_extract_options()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
@@ -263,6 +566,113 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_accepts_negative_gnu_mtime() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-negative-mtime";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.body(ByteStream::from(build_archive_with_negative_gnu_mtime().await?))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let object = client.get_object().bucket(bucket).key("negative-mtime.txt").send().await?;
|
||||
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), b"negative-mtime-body");
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_consumes_concatenated_gzip_members() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-concatenated-gzip";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar.gz")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.body(ByteStream::from(build_concatenated_gzip_archive().await?))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let object = client.get_object().bucket(bucket).key("root.txt").send().await?;
|
||||
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), b"root payload\n");
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_rejects_gzip_crc_error_when_ignore_errors_enabled() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-gzip-crc-ignore-errors";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
let err = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar.gz")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.metadata("Minio-Snowball-Ignore-Errors", "true")
|
||||
.body(ByteStream::from(build_gzip_archive_with_invalid_crc().await?))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("gzip integrity failures must remain fatal under ignore-errors");
|
||||
|
||||
assert_eq!(err.into_service_error().code(), Some("InvalidArgument"));
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_rejects_mismatched_content_md5() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-content-md5";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
let err = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.content_md5("AAAAAAAAAAAAAAAAAAAAAA==")
|
||||
.body(ByteStream::from(build_test_archive().await?))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("mismatched Content-MD5 must fail after the raw body reaches EOF");
|
||||
|
||||
assert_eq!(err.into_service_error().code(), Some("BadDigest"));
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_ignores_invalid_entries_when_requested() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
@@ -299,7 +709,100 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_rejects_parent_dir_entry_without_cross_bucket_write()
|
||||
async fn snowball_auto_extract_skips_non_utf8_symlink_without_ignore_errors() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-invalid-utf8-link";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.body(ByteStream::from(build_archive_with_invalid_utf8_symlink()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let valid = client.get_object().bucket(bucket).key("valid.txt").send().await?;
|
||||
assert_eq!(valid.body.collect().await?.into_bytes().as_ref(), b"valid-body");
|
||||
let listed = client.list_objects_v2().bucket(bucket).send().await?;
|
||||
let keys: Vec<_> = listed.contents().iter().filter_map(|entry| entry.key()).collect();
|
||||
assert_eq!(keys, vec!["valid.txt"]);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_skips_non_utf8_member_without_lossy_key_collision() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-invalid-utf8";
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.metadata("Minio-Snowball-Ignore-Errors", "true")
|
||||
.body(ByteStream::from(build_archive_with_invalid_utf8_entry()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let valid = client.get_object().bucket(bucket).key("valid.txt").send().await?;
|
||||
assert_eq!(valid.body.collect().await?.into_bytes().as_ref(), b"valid-body");
|
||||
let listed = client.list_objects_v2().bucket(bucket).send().await?;
|
||||
let keys: Vec<_> = listed.contents().iter().filter_map(|entry| entry.key()).collect();
|
||||
assert_eq!(keys, vec!["valid.txt"]);
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_rejects_corrupt_tar_when_ignore_errors_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "snowball-corrupt-ignore-errors";
|
||||
let archive = build_archive_with_invalid_checksum().await?;
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
|
||||
let err = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.metadata("Minio-Snowball-Ignore-Errors", "true")
|
||||
.body(ByteStream::from(archive))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("corrupt TAR structure must remain fatal under ignore-errors");
|
||||
assert_eq!(err.into_service_error().code(), Some("InvalidArgument"));
|
||||
|
||||
let listed = client.list_objects_v2().bucket(bucket).send().await?;
|
||||
assert!(listed.contents().is_empty(), "corrupt archive must not produce objects");
|
||||
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snowball_auto_extract_rejects_parent_dir_entry_even_when_ignore_errors_enabled()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -319,6 +822,7 @@ mod tests {
|
||||
.bucket(attacker_bucket)
|
||||
.key("fixture.tar")
|
||||
.metadata("Snowball-Auto-Extract", "true")
|
||||
.metadata("Minio-Snowball-Ignore-Errors", "true")
|
||||
.body(ByteStream::from(archive))
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
|
||||
pub(crate) use rustfs_ecstore::api::disk::{RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Two-node gate for the tier stats wire contract (rustfs/backlog#2207).
|
||||
//!
|
||||
//! Before this contract, `GET /v3/tier-stats` answered from the process that
|
||||
//! happened to receive the request, so the same query returned different
|
||||
//! numbers depending on which node a client reached, with nothing in the body
|
||||
//! saying so. These tests pin the two properties that fix costs: the answer is
|
||||
//! node-independent, and it states how much of the cluster it covers.
|
||||
|
||||
use crate::common::{RustFSTestClusterEnvironment, admin_request, init_logging};
|
||||
use http::Method;
|
||||
use http::StatusCode;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
const TIER_STATS_PATH: &str = "/rustfs/admin/v3/tier-stats";
|
||||
const PEER_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
async fn tier_stats(cluster: &RustFSTestClusterEnvironment, node: usize, query: &str) -> TestResult<(StatusCode, String)> {
|
||||
admin_request(
|
||||
&cluster.nodes[node].url,
|
||||
Method::GET,
|
||||
&format!("{TIER_STATS_PATH}{query}"),
|
||||
None,
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn tier_stats_json(cluster: &RustFSTestClusterEnvironment, node: usize) -> TestResult<Value> {
|
||||
let (status, body) = tier_stats(cluster, node, "").await?;
|
||||
assert_eq!(status, StatusCode::OK, "node {node} must answer tier-stats: {body}");
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
}
|
||||
|
||||
/// Wait until every node reports the whole cluster.
|
||||
///
|
||||
/// Peer clients are established during startup, so a query issued in the first
|
||||
/// moments can legitimately see a peer as unavailable. Polling separates that
|
||||
/// startup window from the failure this test is about: an answer that stays
|
||||
/// partial because the peer never reports at all.
|
||||
async fn wait_for_complete_activity(cluster: &RustFSTestClusterEnvironment) -> TestResult<Vec<Value>> {
|
||||
let deadline = Instant::now() + PEER_CONVERGENCE_TIMEOUT;
|
||||
loop {
|
||||
let mut bodies = Vec::with_capacity(cluster.nodes.len());
|
||||
for node in 0..cluster.nodes.len() {
|
||||
bodies.push(tier_stats_json(cluster, node).await?);
|
||||
}
|
||||
|
||||
if bodies.iter().all(|body| body["activity"]["status"] == "complete") {
|
||||
return Ok(bodies);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("tier-stats activity never became complete on every node: {bodies:?}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_stats_answers_the_same_cluster_result_from_either_node() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
cluster.start().await?;
|
||||
|
||||
let bodies = wait_for_complete_activity(&cluster).await?;
|
||||
let (first, second) = (&bodies[0], &bodies[1]);
|
||||
|
||||
for (node, body) in bodies.iter().enumerate() {
|
||||
assert_eq!(body["contractVersion"], 2, "node {node} must name the current contract version");
|
||||
assert_eq!(
|
||||
body["activity"]["nodesExpected"], 2,
|
||||
"node {node} must expect both cluster members, not only itself"
|
||||
);
|
||||
assert_eq!(
|
||||
body["activity"]["nodesReporting"], 2,
|
||||
"node {node} must include its peer's rolling window in the sum"
|
||||
);
|
||||
assert_eq!(
|
||||
body["activity"]["unavailableNodes"],
|
||||
serde_json::json!([]),
|
||||
"node {node} reported a complete result while naming an unavailable member"
|
||||
);
|
||||
}
|
||||
|
||||
// A fixture cluster has no remote tier, so this pair is equal over an
|
||||
// empty list; `nodesReporting` above is what proves the peer answered.
|
||||
// The per-tier summing itself is pinned by the aggregator unit tests.
|
||||
assert_eq!(
|
||||
first["tiers"], second["tiers"],
|
||||
"the same query must not depend on which node received it"
|
||||
);
|
||||
assert_eq!(
|
||||
first["inventory"]["status"], second["inventory"]["status"],
|
||||
"both nodes read the same persisted usage snapshot"
|
||||
);
|
||||
|
||||
cluster.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_stats_keeps_the_legacy_body_reachable_and_rejects_unknown_formats() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
|
||||
cluster.start().await?;
|
||||
|
||||
let (status, body) = tier_stats(&cluster, 0, "?format=legacy").await?;
|
||||
assert_eq!(status, StatusCode::OK, "the pinned legacy body must stay reachable: {body}");
|
||||
let legacy: Value = serde_json::from_str(&body)?;
|
||||
assert!(
|
||||
legacy.is_object() && legacy.get("contractVersion").is_none(),
|
||||
"the legacy body is the bare tier map, not the current envelope: {legacy}"
|
||||
);
|
||||
|
||||
let (status, body) = tier_stats(&cluster, 0, "?format=v3").await?;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"an unknown format must be rejected rather than answered in another shape: {body}"
|
||||
);
|
||||
|
||||
cluster.stop();
|
||||
Ok(())
|
||||
}
|
||||
@@ -12,14 +12,17 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ServerSideEncryption, VersioningConfiguration,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::{Instant, sleep};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
@@ -28,6 +31,14 @@ const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
||||
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
|
||||
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
|
||||
const MIXED_NODE_COUNT: usize = 4;
|
||||
const MULTIPART_WORKERS: usize = 16;
|
||||
const MULTIPART_UPLOADS_PER_WORKER: usize = 16;
|
||||
// Peers keep a restarted node's drive in Suspect/Returning for roughly
|
||||
// probe_interval (2s) x success_threshold (3) after it comes back; 30s
|
||||
// comfortably covers that window plus CI scheduling jitter.
|
||||
const LISTING_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
||||
@@ -103,6 +114,132 @@ async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_cluster_logs(cluster: &mut RustFSTestClusterEnvironment) -> 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.nodes.len() {
|
||||
let path = Path::new(&log_dir).join(format!("mixed-upgrade-node-{node_idx}.log"));
|
||||
cluster.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_multipart_load(clients: &[Client], phase: &str) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut tasks = JoinSet::new();
|
||||
for worker in 0..MULTIPART_WORKERS {
|
||||
let client = clients[worker % clients.len()].clone();
|
||||
let phase = phase.to_string();
|
||||
tasks.spawn(async move {
|
||||
let mut keys = Vec::with_capacity(MULTIPART_UPLOADS_PER_WORKER);
|
||||
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
|
||||
let key = format!("{phase}/multipart/{worker:02}/{upload:02}");
|
||||
let part = vec![u8::try_from(worker)?; 64 * 1024];
|
||||
write_multipart(&client, MIXED_BUCKET, &key, &[part]).await?;
|
||||
keys.push(key);
|
||||
}
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(keys)
|
||||
});
|
||||
}
|
||||
|
||||
let mut keys = Vec::with_capacity(MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
keys.extend(result??);
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Assert that `client` eventually lists exactly `expected` objects under
|
||||
/// `{phase}/`, polling until [`LISTING_CONVERGENCE_TIMEOUT`].
|
||||
///
|
||||
/// A single-snapshot assertion here is racy by construction: each phase both
|
||||
/// writes and lists within seconds of a node restart. While a peer still holds
|
||||
/// the restarted node's drive in Suspect/Returning, strict-quorum listing
|
||||
/// consults only the remaining three drives and drops any object that was
|
||||
/// itself legally written at write quorum (3/4 drives) during an earlier
|
||||
/// node's identical post-restart window — its xl.meta is then visible on only
|
||||
/// two of the three consulted drives, below the required object quorum of
|
||||
/// three. GET still succeeds for such objects; only the listing under-counts
|
||||
/// until drive health converges. A genuine upgrade data-loss regression still
|
||||
/// fails after the deadline.
|
||||
async fn wait_for_phase_listing(client: &Client, phase: &str, expected: usize, context: &str) -> TestResult {
|
||||
let deadline = Instant::now() + LISTING_CONVERGENCE_TIMEOUT;
|
||||
loop {
|
||||
let listed = client
|
||||
.list_objects_v2()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.prefix(format!("{phase}/"))
|
||||
.send()
|
||||
.await?;
|
||||
let count = listed.contents().len();
|
||||
if count == expected {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"{context}: listing under {phase}/ returned {count} of {expected} objects even after {}s of post-restart convergence",
|
||||
LISTING_CONVERGENCE_TIMEOUT.as_secs()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn exercise_mixed_cluster(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
phase: &str,
|
||||
current_node: usize,
|
||||
previous_node: usize,
|
||||
) -> TestResult {
|
||||
let clients = cluster.create_all_clients()?;
|
||||
let current_client = &clients[current_node];
|
||||
let previous_client = &clients[previous_node];
|
||||
|
||||
let current_key = format!("{phase}/written-by-current");
|
||||
let current_body = format!("{phase}: current RustFS build").into_bytes();
|
||||
current_client
|
||||
.put_object()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.key(¤t_key)
|
||||
.body(ByteStream::from(current_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(read_object(previous_client, MIXED_BUCKET, ¤t_key, None).await?.1, current_body);
|
||||
|
||||
let previous_key = format!("{phase}/written-by-previous");
|
||||
let previous_body = format!("{phase}: previous RustFS release").into_bytes();
|
||||
previous_client
|
||||
.put_object()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.key(&previous_key)
|
||||
.body(ByteStream::from(previous_body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
|
||||
|
||||
let multipart_keys = write_multipart_load(&clients, phase).await?;
|
||||
let expected_count = multipart_keys.len() + 2;
|
||||
for (label, client) in [("current", current_client), ("previous", previous_client)] {
|
||||
wait_for_phase_listing(
|
||||
client,
|
||||
phase,
|
||||
expected_count,
|
||||
&format!("the {label} RustFS version must stream the complete mixed-version listing"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let last_multipart_key = format!("{phase}/multipart/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
|
||||
assert_eq!(
|
||||
read_object(previous_client, MIXED_BUCKET, &last_multipart_key, None).await?.1,
|
||||
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
||||
@@ -252,3 +389,43 @@ async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a pinned previous RustFS release binary"]
|
||||
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
|
||||
init_logging();
|
||||
let previous_binary = source_binary()?;
|
||||
let current_binary = rustfs_binary_path();
|
||||
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
|
||||
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
|
||||
configure_cluster_logs(&mut cluster)?;
|
||||
cluster.start_with_binary(&previous_binary).await?;
|
||||
cluster.create_test_bucket(MIXED_BUCKET).await?;
|
||||
|
||||
cluster.stop_node(0)?;
|
||||
cluster.start_node_from_binary(0, ¤t_binary).await?;
|
||||
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
|
||||
|
||||
for node_idx in [1, 2] {
|
||||
cluster.stop_node(node_idx)?;
|
||||
cluster.start_node_from_binary(node_idx, ¤t_binary).await?;
|
||||
}
|
||||
exercise_mixed_cluster(&cluster, "one-previous-node", 0, 3).await?;
|
||||
|
||||
cluster.stop_node(3)?;
|
||||
cluster.start_node_from_binary(3, ¤t_binary).await?;
|
||||
|
||||
for (node_idx, client) in cluster.create_all_clients()?.iter().enumerate() {
|
||||
for phase in ["one-current-node", "one-previous-node"] {
|
||||
wait_for_phase_listing(
|
||||
client,
|
||||
phase,
|
||||
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2,
|
||||
&format!("node {node_idx}: the homogeneous current cluster must preserve every object"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ rustfs-concurrency.workspace = true
|
||||
rustfs-credentials = { workspace = true }
|
||||
rustfs-common.workspace = true
|
||||
rustfs-heal-contracts.workspace = true
|
||||
rustfs-scanner-contracts.workspace = true
|
||||
rustfs-scanner-metrics.workspace = true
|
||||
rustfs-policy.workspace = true
|
||||
rustfs-protos.workspace = true
|
||||
rustfs-replication.workspace = true
|
||||
@@ -163,10 +163,10 @@ http-body = { workspace = true }
|
||||
http-body-util.workspace = true
|
||||
url.workspace = true
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "serde", "macro-diagnostics"] }
|
||||
reed-solomon-erasure = { workspace = true, features = ["simd-accel"] }
|
||||
rustfs-erasure-codec = { workspace = true, features = ["simd-accel"] }
|
||||
reed-solomon-simd = { workspace = true }
|
||||
lazy_static.workspace = true
|
||||
moka = { workspace = true, features = ["future"] }
|
||||
moka = { workspace = true, features = ["future", "sync"] }
|
||||
rustfs-lock.workspace = true
|
||||
rustfs-io-metrics.workspace = true
|
||||
regex = { workspace = true }
|
||||
@@ -185,7 +185,7 @@ hyper-rustls = { workspace = true, default-features = false, features = ["native
|
||||
hostname.workspace = true
|
||||
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
||||
rustls-pki-types.workspace = true
|
||||
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread"] }
|
||||
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread", "time"] }
|
||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||
xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] }
|
||||
tower = { workspace = true, features = ["timeout"] }
|
||||
|
||||
@@ -122,10 +122,10 @@ fn bench_encode_performance(c: &mut Criterion) {
|
||||
});
|
||||
group.finish();
|
||||
|
||||
// Test direct reed-solomon-erasure implementation for large shards (>= 512 bytes)
|
||||
// Test direct rustfs-erasure-codec implementation for large shards (>= 512 bytes)
|
||||
let shard_size = calc_shard_size(config.data_size, config.data_shards);
|
||||
if shard_size >= 512 && config.parity_shards > 0 {
|
||||
use reed_solomon_erasure::galois_8::ReedSolomon;
|
||||
use rustfs_erasure_codec::galois_8::ReedSolomon;
|
||||
|
||||
let mut rse_group = c.benchmark_group("encode_rse_direct");
|
||||
rse_group.throughput(Throughput::Bytes(config.data_size as u64));
|
||||
@@ -204,10 +204,10 @@ fn bench_decode_performance(c: &mut Criterion) {
|
||||
);
|
||||
group.finish();
|
||||
|
||||
// Test direct reed-solomon-erasure decoding for large shards
|
||||
// Test direct rustfs-erasure-codec decoding for large shards
|
||||
let shard_size = calc_shard_size(config.data_size, config.data_shards);
|
||||
if shard_size >= 512 && config.parity_shards > 0 {
|
||||
use reed_solomon_erasure::galois_8::ReedSolomon;
|
||||
use rustfs_erasure_codec::galois_8::ReedSolomon;
|
||||
|
||||
if let Ok(rs) = ReedSolomon::new(config.data_shards, config.parity_shards) {
|
||||
let mut rse_group = c.benchmark_group("decode_rse_direct");
|
||||
|
||||
@@ -49,7 +49,7 @@ pub mod bucket {
|
||||
apply_transition_rule, enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
|
||||
enqueue_transition_for_existing_objects_scoped, enqueue_transition_for_existing_objects_scoped_with_cancel,
|
||||
enqueue_transition_immediate, expire_transitioned_object, get_global_expiry_state, get_global_transition_state,
|
||||
init_background_expiry, manual_transition_queue_snapshot, post_restore_opts,
|
||||
init_background_expiry, lifecycle_version_delete_target, manual_transition_queue_snapshot, post_restore_opts,
|
||||
run_stale_multipart_upload_cleanup_once, validate_transition_tier,
|
||||
};
|
||||
}
|
||||
@@ -75,6 +75,11 @@ pub mod bucket {
|
||||
delete_transition_candidate_for_operator, finalize_missing_transition_transaction_for_operator,
|
||||
inspect_transition_transaction_for_operator,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::lifecycle::transition_transaction::{
|
||||
TransitionTransactionRecoveryStats, recover_transition_transaction_records,
|
||||
recover_transition_transaction_records_at,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod evaluator {
|
||||
@@ -99,10 +104,21 @@ pub mod bucket {
|
||||
pub use crate::bucket::lifecycle::tier_delete_journal::{
|
||||
persist_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
|
||||
};
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
/// Model a single-node, all-v6 fleet after its capability probe has completed.
|
||||
///
|
||||
/// Call this only once while constructing an isolated test store, before any
|
||||
/// tier-delete journal permit or background worker can be active.
|
||||
pub fn install_all_v6_fleet_capability_proof() {
|
||||
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod tier_last_day_stats {
|
||||
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
|
||||
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats, TierDailyStatsWire};
|
||||
}
|
||||
|
||||
pub mod tier_sweeper {
|
||||
@@ -113,7 +129,6 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod metadata {
|
||||
pub use crate::bucket::metadata::BUCKET_DURABILITY_CONFIG;
|
||||
pub use crate::bucket::metadata::{
|
||||
BUCKET_ACCELERATE_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_LOGGING_CONFIG,
|
||||
BUCKET_NOTIFICATION_CONFIG, BUCKET_POLICY_CONFIG, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_QUOTA_CONFIG_FILE,
|
||||
@@ -122,6 +137,7 @@ pub mod bucket {
|
||||
BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, BUCKET_WEBSITE_CONFIG, BucketMetadata, OBJECT_LOCK_CONFIG,
|
||||
load_bucket_metadata, table_catalog_path_hash,
|
||||
};
|
||||
pub use crate::bucket::metadata::{BUCKET_DURABILITY_CONFIG, BUCKET_ON_DEMAND_MIGRATION_CONFIG};
|
||||
}
|
||||
|
||||
pub mod durability {
|
||||
@@ -130,6 +146,51 @@ pub mod bucket {
|
||||
};
|
||||
}
|
||||
|
||||
pub mod on_demand_migration {
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
ApplyOutcome, BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION,
|
||||
Breaker, BreakerState, BreakerTransition, BreakerVerdict, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, GaugeGuard,
|
||||
LastSourceError, LatencyBucketSnapshot, NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache, OdmBucketSnapshot, OdmLookup,
|
||||
OdmOp, OdmOutcome, OdmStateError, OdmStats, OdmStatsSnapshot, OnDemandMigrationSys, PullError, PullFailureReason,
|
||||
PullFollower, PullLeader, PullOutcome, PullPath, PullResult, PullSlot, SOURCE_LATENCY_BUCKET_BOUNDS_MS,
|
||||
SourceLatencySnapshot, source_client_spec,
|
||||
};
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
|
||||
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy,
|
||||
SourceConfig, SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
|
||||
};
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
|
||||
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody,
|
||||
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
|
||||
idle_guarded_body,
|
||||
};
|
||||
pub use crate::bucket::on_demand_migration::{
|
||||
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
|
||||
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
|
||||
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
|
||||
};
|
||||
pub mod backfill {
|
||||
pub use crate::bucket::on_demand_migration::backfill::{
|
||||
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
|
||||
BACKFILL_LEASE_LOCK_PREFIX, BACKFILL_LIST_PAGE_SIZE, BACKFILL_RECOVERY_INTERVAL, BACKFILL_SAVE_EVERY_KEYS,
|
||||
BACKFILL_SAVE_INTERVAL, BackfillCheckpoint, BackfillContext, BackfillContextFactory, BackfillError,
|
||||
BackfillLastError, BackfillOwner, BackfillRecoveryStats, BackfillRequest, BackfillRunner, BackfillState,
|
||||
BucketBackfillContext, LocalBackfillObject, PriorityPullPermits, PullPermit, PullPriority, SkipExisting,
|
||||
StoredCheckpoint, SysBackfillContexts, global_backfill_runner, install_global_backfill_runner, key_hash,
|
||||
read_checkpoint, run_backfill_recovery_loop, spawn_backfill_recovery_loop,
|
||||
};
|
||||
}
|
||||
pub mod source_client {
|
||||
pub use crate::bucket::on_demand_migration::source_client::{
|
||||
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
|
||||
SourceProbe, SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
|
||||
resolve_path_style,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub mod metadata_sys {
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
|
||||
@@ -139,11 +200,11 @@ pub mod bucket {
|
||||
delete_if_incarnation, delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy,
|
||||
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
||||
get_object_lock_config, get_object_lock_config_state, get_public_access_block_config, get_quota_config,
|
||||
get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config,
|
||||
get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
|
||||
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
|
||||
update_quota_if_incarnation, update_under_transaction_lock,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +245,20 @@ pub mod bucket {
|
||||
}
|
||||
}
|
||||
|
||||
pub mod remote_s3_client {
|
||||
pub use crate::bucket::remote_s3_client::{
|
||||
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
|
||||
validate_remote_endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod sealed_credentials {
|
||||
pub use crate::bucket::sealed_credentials::{
|
||||
CredentialSealer, SEALED_CREDENTIAL_VERSION, SealScope, SealedCredential, SealedCredentialError,
|
||||
SealedCredentialStore, credential_sealer, install_credential_sealer, seal_secret, unseal_secret,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod replication {
|
||||
pub use crate::bucket::replication::replication_pool::{
|
||||
DurableMrfBacklogSummary, DurableMrfBucketBacklog, DurableMrfTargetBacklog, MrfBacklogObservabilitySummary,
|
||||
@@ -404,9 +479,9 @@ pub mod notification {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
|
||||
pub use crate::services::notification_sys::{
|
||||
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||
new_global_notification_sys, start_remote_version_state_fleet_probe,
|
||||
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -425,6 +500,11 @@ pub mod object {
|
||||
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
|
||||
SnapshotConsistencyError,
|
||||
};
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub mod test_util {
|
||||
pub use crate::store::DeleteAfterObjectLockSnapshotBarrier;
|
||||
}
|
||||
}
|
||||
|
||||
pub mod rebalance {
|
||||
@@ -452,8 +532,8 @@ pub mod rpc {
|
||||
pub use crate::cluster::rpc::{
|
||||
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
|
||||
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
|
||||
ScannerBucketListing, ScannerPeerActivity, ScannerPublicationLease, TONIC_RPC_PREFIX, TonicInterceptor,
|
||||
build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
|
||||
ScannerBucketListing, ScannerPeerActivity, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, TONIC_RPC_PREFIX,
|
||||
TonicInterceptor, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
|
||||
encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
||||
gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth,
|
||||
normalize_tonic_rpc_audience, set_tonic_canonical_body_digest, sign_ns_scanner_capability,
|
||||
@@ -461,8 +541,8 @@ pub mod rpc {
|
||||
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
|
||||
verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer,
|
||||
verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
||||
verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
|
||||
verify_tonic_rpc_signature_with_bootstrap,
|
||||
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
|
||||
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -489,9 +569,9 @@ pub mod storage {
|
||||
pub use crate::core::pools::HealLifecycleExpiryContext;
|
||||
pub use crate::store::HealWalkVersion;
|
||||
pub use crate::store::{
|
||||
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, all_local_disk, all_local_disk_path, find_local_disk_by_ref, init_local_disks,
|
||||
init_local_disks_with_instance_ctx, init_lock_clients, prewarm_local_disk_id_map,
|
||||
prewarm_local_disk_id_map_with_instance_ctx,
|
||||
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk, all_local_disk_path,
|
||||
find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
|
||||
prewarm_local_disk_id_map, prewarm_local_disk_id_map_with_instance_ctx,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,9 @@ use super::runtime_boundary as runtime_sources;
|
||||
use crate::bucket::lifecycle::lifecycle;
|
||||
use crate::object_api::ObjectInfo;
|
||||
use crate::services::event_notification::{EventArgs, send_event};
|
||||
use crate::storage_api_contracts::object::{DeletedObject, ObjectToDelete};
|
||||
use rustfs_s3_types::EventName;
|
||||
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||
use rustfs_scanner_metrics::metrics::IlmAction;
|
||||
|
||||
const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]";
|
||||
const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]";
|
||||
@@ -76,6 +77,60 @@ pub(crate) fn emit_non_transitioned_expiration_event(action: IlmAction, source:
|
||||
emit_lifecycle_event(event_name, deleted, LIFECYCLE_EXPIRY_USER_AGENT);
|
||||
}
|
||||
|
||||
/// Emit the lifecycle expiration event for one version removed by the batch
|
||||
/// `NewerNoncurrentVersions` expiry path.
|
||||
///
|
||||
/// That path never sent events, so a successful noncurrent-version expiry was
|
||||
/// invisible to notification subscribers even though the equivalent
|
||||
/// current-version path emits one (backlog#2202).
|
||||
pub(crate) fn emit_noncurrent_expiration_event(bucket: &str, target: &ObjectToDelete, deleted: &DeletedObject, failed: bool) {
|
||||
if let Some((event_name, object)) = noncurrent_expiration_event(bucket, target, deleted, failed) {
|
||||
emit_lifecycle_event(event_name, object, LIFECYCLE_EXPIRY_USER_AGENT);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which event a single batch entry earned, if any.
|
||||
///
|
||||
/// Only an entry that mutated something may be announced. "No error" is not
|
||||
/// enough, and neither is `found`: the disk layer skips an absent version and
|
||||
/// reports success (`delete_versions_internal` in `disk/local.rs` continues
|
||||
/// past `FileVersionNotFound`), so a batch entry for a version that was
|
||||
/// already gone comes back indistinguishable from a committed delete. The
|
||||
/// delete plan's own source lookup is the signal that survives that, and the
|
||||
/// lifecycle batch path always performs it because every target carries an
|
||||
/// exact version identity.
|
||||
fn noncurrent_expiration_event(
|
||||
bucket: &str,
|
||||
target: &ObjectToDelete,
|
||||
deleted: &DeletedObject,
|
||||
failed: bool,
|
||||
) -> Option<(EventName, ObjectInfo)> {
|
||||
if failed || !deleted.found || deleted.source_missing {
|
||||
return None;
|
||||
}
|
||||
// A version removed by explicit version id is a plain versioned delete
|
||||
// even when that version is itself a delete marker; only a request that
|
||||
// carried no version id can publish a new delete marker. This is the rule
|
||||
// the S3 DeleteObjects path applies (issue #6745). `delete_object_versions`
|
||||
// now refuses targets without an exact version identity, so the
|
||||
// marker-creation shape is unreachable from that caller; the mapping stays
|
||||
// here so a future caller cannot silently announce the wrong mutation.
|
||||
let created_delete_marker = deleted.delete_marker && target.version_id.is_none();
|
||||
let (event_name, version_id) = if created_delete_marker {
|
||||
(EventName::LifecycleExpirationDeleteMarkerCreated, deleted.delete_marker_version_id)
|
||||
} else {
|
||||
(EventName::LifecycleExpirationDelete, deleted.version_id.or(target.version_id))
|
||||
};
|
||||
let object = ObjectInfo {
|
||||
bucket: bucket.to_string(),
|
||||
name: target.object_name.clone(),
|
||||
version_id,
|
||||
delete_marker: deleted.delete_marker,
|
||||
..Default::default()
|
||||
};
|
||||
Some((event_name, object))
|
||||
}
|
||||
|
||||
fn emit_lifecycle_event(event_name: EventName, object: ObjectInfo, user_agent: &str) {
|
||||
send_event(EventArgs {
|
||||
event_name: event_name.to_string(),
|
||||
@@ -113,6 +168,7 @@ fn non_transitioned_expiration_event_name(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn transitioned_expiration_event_marks_delete_marker_creation() {
|
||||
@@ -129,4 +185,87 @@ mod tests {
|
||||
EventName::LifecycleExpirationDelete
|
||||
);
|
||||
}
|
||||
|
||||
fn deleted_version(version_id: Uuid) -> DeletedObject {
|
||||
DeletedObject {
|
||||
object_name: "object".to_string(),
|
||||
version_id: Some(version_id),
|
||||
found: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn target_version(version_id: Option<Uuid>) -> ObjectToDelete {
|
||||
ObjectToDelete {
|
||||
object_name: "object".to_string(),
|
||||
version_id,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncurrent_expiration_emits_versioned_delete_with_exact_identity() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let (event_name, object) =
|
||||
noncurrent_expiration_event("bucket", &target_version(Some(version_id)), &deleted_version(version_id), false)
|
||||
.expect("a committed delete must emit");
|
||||
assert_eq!(event_name, EventName::LifecycleExpirationDelete);
|
||||
assert_eq!(object.bucket, "bucket");
|
||||
assert_eq!(object.name, "object");
|
||||
assert_eq!(object.version_id, Some(version_id));
|
||||
}
|
||||
|
||||
/// Removing a noncurrent version that happens to be a delete marker is a
|
||||
/// plain versioned delete, not a delete-marker creation.
|
||||
#[test]
|
||||
fn noncurrent_expiration_of_a_delete_marker_version_is_a_plain_delete() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let deleted = DeletedObject {
|
||||
delete_marker: true,
|
||||
..deleted_version(version_id)
|
||||
};
|
||||
let (event_name, object) = noncurrent_expiration_event("bucket", &target_version(Some(version_id)), &deleted, false)
|
||||
.expect("a committed delete must emit");
|
||||
assert_eq!(event_name, EventName::LifecycleExpirationDelete);
|
||||
assert_eq!(object.version_id, Some(version_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncurrent_expiration_reports_a_created_delete_marker() {
|
||||
let marker_version_id = Uuid::new_v4();
|
||||
let deleted = DeletedObject {
|
||||
object_name: "object".to_string(),
|
||||
delete_marker: true,
|
||||
delete_marker_version_id: Some(marker_version_id),
|
||||
found: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (event_name, object) =
|
||||
noncurrent_expiration_event("bucket", &target_version(None), &deleted, false).expect("a committed delete must emit");
|
||||
assert_eq!(event_name, EventName::LifecycleExpirationDeleteMarkerCreated);
|
||||
assert_eq!(object.version_id, Some(marker_version_id));
|
||||
}
|
||||
|
||||
/// A batch mixes successes with failures and versions that were already
|
||||
/// gone; only a real mutation may produce an event. A version that was
|
||||
/// already gone comes back with no error and `found` set, so
|
||||
/// `source_missing` is the signal that keeps it silent.
|
||||
#[test]
|
||||
fn noncurrent_expiration_skips_failed_and_missing_versions() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let target = target_version(Some(version_id));
|
||||
assert!(noncurrent_expiration_event("bucket", &target, &deleted_version(version_id), true).is_none());
|
||||
|
||||
let absent = DeletedObject {
|
||||
source_missing: true,
|
||||
..deleted_version(version_id)
|
||||
};
|
||||
assert!(noncurrent_expiration_event("bucket", &target, &absent, false).is_none());
|
||||
|
||||
let not_found = DeletedObject {
|
||||
found: false,
|
||||
..deleted_version(version_id)
|
||||
};
|
||||
assert!(noncurrent_expiration_event("bucket", &target, ¬_found, false).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ use crate::object_api::ObjectInfo;
|
||||
pub use rustfs_lifecycle::{
|
||||
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate, TRANSITION_COMPLETE,
|
||||
TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due, expected_expiry_time,
|
||||
expiration_action_has_valid_target,
|
||||
};
|
||||
|
||||
pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts {
|
||||
|
||||
@@ -33,6 +33,7 @@ const MANUAL_TRANSITION_CURSOR_MARKER_PROOF_MAX_SIZE: usize = 1024;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DurableIlmRecordKind {
|
||||
TierDeleteJournal,
|
||||
TierDeleteDispatchManifest,
|
||||
TransitionTransaction,
|
||||
ManualTransitionJob,
|
||||
ManualTransitionScope,
|
||||
@@ -54,6 +55,18 @@ pub(crate) const TIER_DELETE_JOURNAL_NAMESPACE: DurableIlmNamespace = DurableIlm
|
||||
max_record_size: 64 * 1024,
|
||||
kind: DurableIlmRecordKind::TierDeleteJournal,
|
||||
};
|
||||
pub(crate) const TIER_DELETE_JOURNAL_V6_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
|
||||
name: "tier-delete-journal-v6",
|
||||
prefix: "ilm/tier-delete-journal-v6/",
|
||||
max_record_size: 64 * 1024,
|
||||
kind: DurableIlmRecordKind::TierDeleteJournal,
|
||||
};
|
||||
pub(crate) const TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
|
||||
name: "tier-delete-dispatch-manifest",
|
||||
prefix: tier_delete_journal::TIER_DELETE_DISPATCH_MANIFEST_PREFIX,
|
||||
max_record_size: tier_delete_journal::MAX_TIER_DELETE_DISPATCH_MANIFEST_SIZE,
|
||||
kind: DurableIlmRecordKind::TierDeleteDispatchManifest,
|
||||
};
|
||||
pub(crate) const TRANSITION_TRANSACTION_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
|
||||
name: "transition-transaction",
|
||||
prefix: "ilm/transition-transactions/records",
|
||||
@@ -85,8 +98,10 @@ pub(crate) const MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE: DurableIlmNamespace
|
||||
kind: DurableIlmRecordKind::ManualTransitionWorkerResult,
|
||||
};
|
||||
|
||||
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 6] = [
|
||||
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 8] = [
|
||||
TIER_DELETE_JOURNAL_NAMESPACE,
|
||||
TIER_DELETE_JOURNAL_V6_NAMESPACE,
|
||||
TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE,
|
||||
TRANSITION_TRANSACTION_NAMESPACE,
|
||||
MANUAL_TRANSITION_JOB_NAMESPACE,
|
||||
MANUAL_TRANSITION_SCOPE_NAMESPACE,
|
||||
@@ -157,6 +172,25 @@ pub(crate) enum DurableIlmRecordCheckpoint {
|
||||
content_sha256: String,
|
||||
identity_sha256: String,
|
||||
committed: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
dispatch_identity_sha256: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
state: Option<super::tier_sweeper::TierDeleteJournalState>,
|
||||
},
|
||||
TierDeleteDispatchManifest {
|
||||
content_sha256: String,
|
||||
identity_sha256: String,
|
||||
state: tier_delete_journal::TierDeleteDispatchManifestState,
|
||||
},
|
||||
TierDeleteDispatchParent {
|
||||
content_sha256: String,
|
||||
identity_sha256: String,
|
||||
revision: u64,
|
||||
next_chunk_sequence: u64,
|
||||
completed_journal_count: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
active_chunk_identity_sha256: Option<String>,
|
||||
completed: bool,
|
||||
},
|
||||
TransitionTransaction {
|
||||
content_sha256: String,
|
||||
@@ -195,6 +229,8 @@ impl DurableIlmRecordCheckpoint {
|
||||
pub(crate) fn content_sha256(&self) -> &str {
|
||||
match self {
|
||||
Self::TierDeleteJournal { content_sha256, .. }
|
||||
| Self::TierDeleteDispatchManifest { content_sha256, .. }
|
||||
| Self::TierDeleteDispatchParent { content_sha256, .. }
|
||||
| Self::TransitionTransaction { content_sha256, .. }
|
||||
| Self::ManualTransitionJob { content_sha256, .. }
|
||||
| Self::ManualTransitionScope { content_sha256, .. }
|
||||
@@ -228,6 +264,19 @@ impl DurableIlmRecordCheckpoint {
|
||||
}
|
||||
|
||||
pub(crate) fn validate_successor(&self, next: &Self) -> Result<()> {
|
||||
for checkpoint in [self, next] {
|
||||
if let Self::TierDeleteJournal {
|
||||
committed,
|
||||
dispatch_identity_sha256,
|
||||
state,
|
||||
..
|
||||
} = checkpoint
|
||||
&& (state.is_some() != dispatch_identity_sha256.is_some()
|
||||
|| state.is_some_and(|state| *committed != (state == super::tier_sweeper::TierDeleteJournalState::Committed)))
|
||||
{
|
||||
return Err(Error::other("durable ILM tier delete journal checkpoint is invalid"));
|
||||
}
|
||||
}
|
||||
if self == next {
|
||||
if let Self::ManualTransitionJob {
|
||||
progress,
|
||||
@@ -244,18 +293,110 @@ impl DurableIlmRecordCheckpoint {
|
||||
let valid = match (self, next) {
|
||||
(
|
||||
Self::TierDeleteJournal {
|
||||
content_sha256: previous_content,
|
||||
identity_sha256: previous_identity,
|
||||
committed: previous_committed,
|
||||
dispatch_identity_sha256: previous_dispatch_identity,
|
||||
state: previous_state,
|
||||
..
|
||||
},
|
||||
Self::TierDeleteJournal {
|
||||
content_sha256: next_content,
|
||||
identity_sha256: next_identity,
|
||||
committed: next_committed,
|
||||
dispatch_identity_sha256: next_dispatch_identity,
|
||||
state: next_state,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
use super::tier_sweeper::TierDeleteJournalState::{Committed, Dispatched, Prepared};
|
||||
|
||||
let dispatch_identity_is_monotonic = match (previous_dispatch_identity, next_dispatch_identity) {
|
||||
(Some(previous), Some(next)) => previous == next,
|
||||
(None, None) => true,
|
||||
// Old receipts did not record the v6 dispatch binding. A
|
||||
// byte-identical observation may adopt the stronger proof,
|
||||
// but an in-flight mutation must fail closed instead of
|
||||
// guessing which operation owned the journal.
|
||||
(None, Some(_)) => previous_content == next_content,
|
||||
(Some(_), None) => false,
|
||||
};
|
||||
let state_is_monotonic = match (previous_state, next_state) {
|
||||
(Some(previous), Some(next)) => {
|
||||
previous == next || matches!((previous, next), (Prepared, Dispatched) | (Dispatched, Committed))
|
||||
}
|
||||
(None, None) => previous_committed == next_committed || (!previous_committed && *next_committed),
|
||||
(None, Some(_)) => previous_content == next_content,
|
||||
(Some(_), None) => false,
|
||||
};
|
||||
previous_identity == next_identity && dispatch_identity_is_monotonic && state_is_monotonic
|
||||
}
|
||||
(
|
||||
Self::TierDeleteDispatchManifest {
|
||||
identity_sha256: previous_identity,
|
||||
state: previous_state,
|
||||
..
|
||||
},
|
||||
Self::TierDeleteDispatchManifest {
|
||||
identity_sha256: next_identity,
|
||||
state: next_state,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
use tier_delete_journal::TierDeleteDispatchManifestState::{
|
||||
Aborted, Aborting, Completed, DispatchAuthorized, Preparing,
|
||||
};
|
||||
previous_identity == next_identity
|
||||
&& (previous_committed == next_committed || (!previous_committed && *next_committed))
|
||||
&& matches!(
|
||||
(previous_state, next_state),
|
||||
(Preparing, DispatchAuthorized | Aborting) | (Aborting, Aborted) | (DispatchAuthorized, Completed)
|
||||
)
|
||||
}
|
||||
(
|
||||
Self::TierDeleteDispatchParent {
|
||||
identity_sha256: previous_identity,
|
||||
revision: previous_revision,
|
||||
next_chunk_sequence: previous_sequence,
|
||||
completed_journal_count: previous_completed_journals,
|
||||
active_chunk_identity_sha256: previous_active,
|
||||
completed: previous_completed,
|
||||
..
|
||||
},
|
||||
Self::TierDeleteDispatchParent {
|
||||
identity_sha256: next_identity,
|
||||
revision: next_revision,
|
||||
next_chunk_sequence: next_sequence,
|
||||
completed_journal_count: next_completed_journals,
|
||||
active_chunk_identity_sha256: next_active,
|
||||
completed: next_completed,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
let Some((sequence_delta, completed_journal_delta)) = tier_delete_dispatch_parent_progress_delta(
|
||||
*previous_sequence,
|
||||
*previous_completed_journals,
|
||||
*next_sequence,
|
||||
*next_completed_journals,
|
||||
) else {
|
||||
return Err(Error::other("durable ILM record generation is not a monotonic successor"));
|
||||
};
|
||||
let same_position_transition = sequence_delta == 0
|
||||
&& completed_journal_delta == 0
|
||||
&& matches!(
|
||||
(previous_active.as_ref(), next_active.as_ref(), previous_completed, next_completed),
|
||||
(None, Some(_), false, false) | (Some(_), None, false, false) | (None, None, false, true)
|
||||
);
|
||||
let progress_transition = sequence_delta > 0
|
||||
&& completed_journal_delta > 0
|
||||
&& !matches!(
|
||||
(previous_active.as_ref(), next_active.as_ref()),
|
||||
(Some(previous), Some(next)) if previous == next
|
||||
);
|
||||
previous_identity == next_identity
|
||||
&& !previous_completed
|
||||
&& next_revision > previous_revision
|
||||
&& (!next_completed || next_active.is_none())
|
||||
&& (same_position_transition || progress_transition)
|
||||
}
|
||||
(
|
||||
Self::TransitionTransaction {
|
||||
@@ -351,6 +492,96 @@ impl DurableIlmRecordCheckpoint {
|
||||
Err(Error::other("durable ILM record generation is not a monotonic successor"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `self` is an older generation of the same immutable record
|
||||
/// that can reach `terminal` through one or more valid state transitions.
|
||||
/// This is deliberately broader than `validate_successor`, which remains
|
||||
/// adjacent-only for receipt advancement. Terminal cleanup uses this only
|
||||
/// after the exact terminal ETag and terminal receipt were committed, to
|
||||
/// purge older object versions exposed by that deletion.
|
||||
pub(crate) fn is_predecessor_of_terminal(&self, terminal: &Self) -> bool {
|
||||
if self == terminal || self.validate_successor(terminal).is_ok() {
|
||||
return true;
|
||||
}
|
||||
match (self, terminal) {
|
||||
(
|
||||
Self::TierDeleteJournal {
|
||||
identity_sha256: previous_identity,
|
||||
dispatch_identity_sha256: previous_dispatch,
|
||||
state: Some(super::tier_sweeper::TierDeleteJournalState::Prepared),
|
||||
..
|
||||
},
|
||||
Self::TierDeleteJournal {
|
||||
identity_sha256: terminal_identity,
|
||||
dispatch_identity_sha256: terminal_dispatch,
|
||||
state: Some(super::tier_sweeper::TierDeleteJournalState::Committed),
|
||||
..
|
||||
},
|
||||
) => previous_identity == terminal_identity && previous_dispatch == terminal_dispatch,
|
||||
(
|
||||
Self::TierDeleteDispatchManifest {
|
||||
identity_sha256: previous_identity,
|
||||
state: tier_delete_journal::TierDeleteDispatchManifestState::Preparing,
|
||||
..
|
||||
},
|
||||
Self::TierDeleteDispatchManifest {
|
||||
identity_sha256: terminal_identity,
|
||||
state:
|
||||
tier_delete_journal::TierDeleteDispatchManifestState::Aborted
|
||||
| tier_delete_journal::TierDeleteDispatchManifestState::Completed,
|
||||
..
|
||||
},
|
||||
) => previous_identity == terminal_identity,
|
||||
(
|
||||
Self::TierDeleteDispatchParent {
|
||||
identity_sha256: previous_identity,
|
||||
revision: previous_revision,
|
||||
next_chunk_sequence: previous_sequence,
|
||||
completed_journal_count: previous_completed_journals,
|
||||
active_chunk_identity_sha256: previous_active,
|
||||
completed: false,
|
||||
..
|
||||
},
|
||||
Self::TierDeleteDispatchParent {
|
||||
identity_sha256: terminal_identity,
|
||||
revision: terminal_revision,
|
||||
next_chunk_sequence: terminal_sequence,
|
||||
completed_journal_count: terminal_completed_journals,
|
||||
active_chunk_identity_sha256: None,
|
||||
completed: true,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
previous_identity == terminal_identity
|
||||
&& terminal_revision > previous_revision
|
||||
&& tier_delete_dispatch_parent_progress_delta(
|
||||
*previous_sequence,
|
||||
*previous_completed_journals,
|
||||
*terminal_sequence,
|
||||
*terminal_completed_journals,
|
||||
)
|
||||
.is_some_and(|(sequence_delta, completed_journal_delta)| {
|
||||
if sequence_delta == 0 && completed_journal_delta == 0 {
|
||||
previous_active.is_none()
|
||||
} else {
|
||||
sequence_delta > 0 && completed_journal_delta > 0
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_delete_dispatch_parent_progress_delta(
|
||||
previous_sequence: u64,
|
||||
previous_completed_journals: u64,
|
||||
next_sequence: u64,
|
||||
next_completed_journals: u64,
|
||||
) -> Option<(u64, u64)> {
|
||||
let sequence_delta = next_sequence.checked_sub(previous_sequence)?;
|
||||
let completed_journal_delta = next_completed_journals.checked_sub(previous_completed_journals)?;
|
||||
(sequence_delta <= completed_journal_delta).then_some((sequence_delta, completed_journal_delta))
|
||||
}
|
||||
|
||||
fn transition_state_distance(
|
||||
@@ -750,10 +981,19 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
|
||||
if tier_delete_journal::tier_delete_journal_object_name(&entry) != path {
|
||||
return Err(Error::other("tier delete journal content does not match its path"));
|
||||
}
|
||||
let operation_id = path
|
||||
let legacy_operation_id = path
|
||||
.strip_prefix(namespace.prefix)
|
||||
.and_then(|suffix| suffix.strip_suffix(".json"))
|
||||
.ok_or_else(|| Error::other("tier delete journal path is invalid"))?;
|
||||
// Legacy v1-v5 paths already expose a 64-hex operation id and
|
||||
// must remain receipt-compatible. V6 uses an operation-scoped
|
||||
// nested path, so derive a fixed, path-unique receipt id instead
|
||||
// of embedding slashes in the receipt locator.
|
||||
let operation_id = if entry.persisted_version == 6 {
|
||||
hex_sha256(path.as_bytes(), ToOwned::to_owned)
|
||||
} else {
|
||||
legacy_operation_id.to_string()
|
||||
};
|
||||
let identity_sha256 = checkpoint_hash(&(
|
||||
&entry.obj_name,
|
||||
&entry.version_id,
|
||||
@@ -763,16 +1003,57 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
|
||||
entry.version_state,
|
||||
&entry.source,
|
||||
))?;
|
||||
let dispatch_identity_sha256 = entry.dispatch.as_ref().map(checkpoint_hash).transpose()?;
|
||||
(
|
||||
"operation_id",
|
||||
operation_id.to_string(),
|
||||
operation_id,
|
||||
DurableIlmRecordCheckpoint::TierDeleteJournal {
|
||||
content_sha256,
|
||||
identity_sha256,
|
||||
committed: entry.state == super::tier_sweeper::TierDeleteJournalState::Committed,
|
||||
dispatch_identity_sha256,
|
||||
state: (entry.persisted_version == 6).then_some(entry.state),
|
||||
},
|
||||
)
|
||||
}
|
||||
DurableIlmRecordKind::TierDeleteDispatchManifest => {
|
||||
match tier_delete_journal::validate_tier_delete_dispatch_manifest_record(path, data)? {
|
||||
tier_delete_journal::TierDeleteDispatchDurableRecord::Manifest {
|
||||
operation_id,
|
||||
identity_sha256,
|
||||
state,
|
||||
} => (
|
||||
"operation_id",
|
||||
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
|
||||
DurableIlmRecordCheckpoint::TierDeleteDispatchManifest {
|
||||
content_sha256,
|
||||
identity_sha256,
|
||||
state,
|
||||
},
|
||||
),
|
||||
tier_delete_journal::TierDeleteDispatchDurableRecord::Parent {
|
||||
operation_id,
|
||||
identity_sha256,
|
||||
revision,
|
||||
next_chunk_sequence,
|
||||
completed_journal_count,
|
||||
active_chunk_identity_sha256,
|
||||
completed,
|
||||
} => (
|
||||
"operation_id",
|
||||
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
|
||||
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
|
||||
content_sha256,
|
||||
identity_sha256,
|
||||
revision,
|
||||
next_chunk_sequence,
|
||||
completed_journal_count,
|
||||
active_chunk_identity_sha256,
|
||||
completed,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
DurableIlmRecordKind::TransitionTransaction => {
|
||||
let transaction = transition_transaction::decode_transition_transaction_record(path, data)
|
||||
.map_err(|err| Error::other(err.to_string()))?;
|
||||
@@ -956,6 +1237,166 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_dispatch_manifest_namespace_validates_monotonic_branches() {
|
||||
use tier_delete_journal::TierDeleteDispatchManifestState::{Aborted, Aborting, Completed, DispatchAuthorized, Preparing};
|
||||
|
||||
let operation_id = Uuid::new_v4();
|
||||
let checkpoint = |state| {
|
||||
let (path, data) = tier_delete_journal::test_tier_delete_dispatch_manifest_record(operation_id, state);
|
||||
let namespace = classify_durable_ilm_record(&path)
|
||||
.expect("dispatch manifest namespace should classify")
|
||||
.expect("dispatch manifest should be durable");
|
||||
assert_eq!(namespace, &TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE);
|
||||
validate_durable_ilm_record(&path, &data)
|
||||
.expect("dispatch manifest should validate")
|
||||
.checkpoint
|
||||
};
|
||||
|
||||
let preparing = checkpoint(Preparing);
|
||||
let authorized = checkpoint(DispatchAuthorized);
|
||||
let completed = checkpoint(Completed);
|
||||
let aborting = checkpoint(Aborting);
|
||||
let aborted = checkpoint(Aborted);
|
||||
|
||||
preparing
|
||||
.validate_successor(&authorized)
|
||||
.expect("Preparing may become DispatchAuthorized");
|
||||
authorized
|
||||
.validate_successor(&completed)
|
||||
.expect("DispatchAuthorized may become Completed");
|
||||
preparing.validate_successor(&aborting).expect("Preparing may enter rollback");
|
||||
aborting.validate_successor(&aborted).expect("Aborting may become Aborted");
|
||||
assert!(authorized.validate_successor(&aborting).is_err());
|
||||
assert!(completed.validate_successor(&authorized).is_err());
|
||||
assert!(aborted.validate_successor(&preparing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_dispatch_parent_checkpoint_is_monotonic_across_chunks() {
|
||||
let identity = "a".repeat(64);
|
||||
let checkpoint = |revision, sequence, completed_journals, active: Option<&str>, completed| {
|
||||
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
|
||||
content_sha256: format!("{revision:064x}"),
|
||||
identity_sha256: identity.clone(),
|
||||
revision,
|
||||
next_chunk_sequence: sequence,
|
||||
completed_journal_count: completed_journals,
|
||||
active_chunk_identity_sha256: active.map(ToOwned::to_owned),
|
||||
completed,
|
||||
}
|
||||
};
|
||||
let idle = checkpoint(0, 0, 0, None, false);
|
||||
let first_child = "b".repeat(64);
|
||||
let second_child = "c".repeat(64);
|
||||
let bound = checkpoint(1, 0, 0, Some(&first_child), false);
|
||||
let advanced = checkpoint(2, 1, 2, None, false);
|
||||
let next_bound = checkpoint(3, 1, 2, Some(&second_child), false);
|
||||
let completed = checkpoint(4, 2, 3, None, true);
|
||||
let terminal_after_more_chunks = checkpoint(6, 4, 7, None, true);
|
||||
|
||||
idle.validate_successor(&bound).expect("an idle parent may bind one child");
|
||||
bound
|
||||
.validate_successor(&advanced)
|
||||
.expect("a completed child may advance the parent sequence");
|
||||
advanced
|
||||
.validate_successor(&next_bound)
|
||||
.expect("the next sequence may bind a new immutable child");
|
||||
next_bound
|
||||
.validate_successor(&completed)
|
||||
.expect("receipt progress may skip directly to a later terminal checkpoint");
|
||||
assert!(
|
||||
bound.is_predecessor_of_terminal(&terminal_after_more_chunks),
|
||||
"terminal cleanup may still recognize a valid multi-chunk predecessor"
|
||||
);
|
||||
assert!(
|
||||
advanced.is_predecessor_of_terminal(&terminal_after_more_chunks),
|
||||
"terminal cleanup may still skip over later valid parent generations"
|
||||
);
|
||||
assert!(
|
||||
idle.validate_successor(&checkpoint(1, 0, 1, Some(&first_child), false))
|
||||
.is_err()
|
||||
);
|
||||
assert!(bound.validate_successor(&checkpoint(2, 1, 0, None, false)).is_err());
|
||||
assert!(
|
||||
bound.validate_successor(&checkpoint(2, 2, 1, None, false)).is_err(),
|
||||
"sequence cannot advance beyond completed journal evidence"
|
||||
);
|
||||
assert!(
|
||||
advanced.validate_successor(&checkpoint(3, 1, 3, None, false)).is_err(),
|
||||
"completed journal count cannot grow without a completed child sequence"
|
||||
);
|
||||
assert!(
|
||||
bound.validate_successor(&checkpoint(2, 0, 0, None, true)).is_err(),
|
||||
"an active child cannot be marked completed without completion evidence"
|
||||
);
|
||||
assert!(
|
||||
bound
|
||||
.validate_successor(&checkpoint(2, 0, 0, Some(&second_child), false))
|
||||
.is_err(),
|
||||
"an active child cannot be replaced at the same parent sequence"
|
||||
);
|
||||
assert!(
|
||||
bound
|
||||
.validate_successor(&checkpoint(2, 1, 1, Some(&first_child), false))
|
||||
.is_err(),
|
||||
"sequence growth cannot retain the same active child identity"
|
||||
);
|
||||
assert!(
|
||||
!bound.is_predecessor_of_terminal(&checkpoint(2, 0, 0, None, true)),
|
||||
"terminal cleanup must not treat an active child as completed without count evidence"
|
||||
);
|
||||
assert!(completed.validate_successor(&checkpoint(5, 3, 4, None, true)).is_err());
|
||||
assert!(completed.validate_successor(&advanced).is_err());
|
||||
assert!(advanced.validate_successor(&idle).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_checkpoint_binds_dispatch_and_full_state_monotonically() {
|
||||
use crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::{Committed, Dispatched, Prepared};
|
||||
|
||||
let checkpoint = |content: &str, dispatch: Option<&str>, state| DurableIlmRecordCheckpoint::TierDeleteJournal {
|
||||
content_sha256: content.repeat(64),
|
||||
identity_sha256: "i".repeat(64),
|
||||
committed: state == Some(Committed),
|
||||
dispatch_identity_sha256: dispatch.map(|value| value.repeat(64)),
|
||||
state,
|
||||
};
|
||||
let prepared = checkpoint("a", Some("d"), Some(Prepared));
|
||||
let dispatched = checkpoint("b", Some("d"), Some(Dispatched));
|
||||
let committed = checkpoint("c", Some("d"), Some(Committed));
|
||||
prepared
|
||||
.validate_successor(&dispatched)
|
||||
.expect("Prepared may advance to Dispatched");
|
||||
dispatched
|
||||
.validate_successor(&committed)
|
||||
.expect("Dispatched may advance to Committed");
|
||||
assert!(prepared.validate_successor(&committed).is_err());
|
||||
assert!(dispatched.validate_successor(&prepared).is_err());
|
||||
|
||||
let rebound = checkpoint("b", Some("e"), Some(Dispatched));
|
||||
assert!(dispatched.validate_successor(&rebound).is_err());
|
||||
|
||||
let legacy: DurableIlmRecordCheckpoint = serde_json::from_value(serde_json::json!({
|
||||
"kind": "tier_delete_journal",
|
||||
"content_sha256": "a".repeat(64),
|
||||
"identity_sha256": "i".repeat(64),
|
||||
"committed": false
|
||||
}))
|
||||
.expect("legacy tier-delete checkpoint should remain decodable");
|
||||
legacy
|
||||
.validate_successor(&prepared)
|
||||
.expect("byte-identical legacy receipt may adopt the stronger v6 proof");
|
||||
let changed_legacy = DurableIlmRecordCheckpoint::TierDeleteJournal {
|
||||
content_sha256: "z".repeat(64),
|
||||
identity_sha256: "i".repeat(64),
|
||||
committed: false,
|
||||
dispatch_identity_sha256: None,
|
||||
state: None,
|
||||
};
|
||||
assert!(changed_legacy.validate_successor(&prepared).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_transition_job_checkpoint_compacts_legacy_progress_compatibly() {
|
||||
let options = super::super::bucket_lifecycle_ops::ManualTransitionRunOptions::default();
|
||||
|
||||
@@ -34,6 +34,6 @@ pub mod tier_sweeper;
|
||||
pub mod transition_transaction;
|
||||
|
||||
pub(crate) use durable_namespace::{
|
||||
DurableIlmRecordCheckpoint, ILM_META_PREFIX, ValidatedDurableIlmRecord, classify_durable_ilm_record,
|
||||
validate_durable_ilm_record,
|
||||
DurableIlmRecordCheckpoint, ILM_META_PREFIX, TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE, ValidatedDurableIlmRecord,
|
||||
classify_durable_ilm_record, validate_durable_ilm_record,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
|
||||
const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped";
|
||||
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
|
||||
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_audit::emit_noncurrent_expiration_event;
|
||||
use crate::bucket::lifecycle::lifecycle;
|
||||
use crate::bucket::lifecycle::replication_sink::{self, ReplicationObjectBridge};
|
||||
use crate::object_api::ObjectOptions;
|
||||
@@ -34,7 +35,23 @@ pub async fn delete_object_versions(
|
||||
to_del: &[ObjectToDelete],
|
||||
_lc_event: lifecycle::Event,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) {
|
||||
) -> usize {
|
||||
if to_del.iter().any(|target| {
|
||||
target.version_id.is_none()
|
||||
|| (target.version_id.is_some_and(|version_id| version_id.is_nil()) && target.expected_identity.is_none())
|
||||
}) {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_CLEANUP_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket,
|
||||
target_count = to_del.len(),
|
||||
reason = "incomplete_version_identity",
|
||||
"Skipped lifecycle noncurrent version cleanup"
|
||||
);
|
||||
return to_del.len();
|
||||
}
|
||||
|
||||
let delete_config_snapshot = match ReplicationObjectBridge::delete_request_config(api, bucket).await {
|
||||
Ok(snapshot) => Arc::new(snapshot),
|
||||
Err(err) => {
|
||||
@@ -47,10 +64,11 @@ pub async fn delete_object_versions(
|
||||
reason = "delete_config_snapshot_unavailable",
|
||||
"Skipped lifecycle noncurrent version cleanup"
|
||||
);
|
||||
return;
|
||||
return to_del.len();
|
||||
}
|
||||
};
|
||||
let mut remaining = to_del;
|
||||
let mut failed = 0;
|
||||
loop {
|
||||
let mut to_del = remaining;
|
||||
if to_del.len() > MAX_DELETE_LIST {
|
||||
@@ -71,6 +89,7 @@ pub async fn delete_object_versions(
|
||||
},
|
||||
)
|
||||
.await;
|
||||
failed += errors.iter().filter(|err| err.is_some()).count();
|
||||
|
||||
for (i, deleted_obj) in deleted_objs.iter_mut().enumerate() {
|
||||
if errors.get(i).and_then(|err| err.as_ref()).is_some() {
|
||||
@@ -80,6 +99,12 @@ pub async fn delete_object_versions(
|
||||
// version so it does not sit resident until TTL (ODC-26).
|
||||
if let Some(target) = to_del.get(i) {
|
||||
crate::object_api::notify_object_mutation(bucket, &target.object_name).await;
|
||||
// Announce the version this batch actually removed. Cache
|
||||
// eviction and replication scheduling keep their existing
|
||||
// order and admission; the event is derived from the committed
|
||||
// result, and a send failure never rolls back a delete that
|
||||
// already happened (backlog#2202).
|
||||
emit_noncurrent_expiration_event(bucket, target, deleted_obj, false);
|
||||
}
|
||||
if deleted_obj.replication_state.is_none() {
|
||||
continue;
|
||||
@@ -111,4 +136,5 @@ pub async fn delete_object_versions(
|
||||
break;
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||
use rustfs_scanner_metrics::metrics::IlmAction;
|
||||
|
||||
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
|
||||
use crate::bucket::replication::ReplicationLifecycleBridge;
|
||||
@@ -77,7 +77,7 @@ mod tests {
|
||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||
use crate::object_api::{ObjectInfo, ObjectOptions};
|
||||
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||
use rustfs_scanner_contracts::metrics::IlmAction;
|
||||
use rustfs_scanner_metrics::metrics::IlmAction;
|
||||
use s3s::dto::{
|
||||
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
|
||||
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,10 @@ use crate::store::ECStore;
|
||||
use rustfs_filemeta::FileInfo;
|
||||
|
||||
pub const DEFAULT_FREE_VERSION_RECOVERY_LIMIT: usize = 1_000;
|
||||
// These are page-wide repair budgets. Applying them per bucket would still let
|
||||
// one recovery pass walk an unbounded namespace before the scheduler can cool down.
|
||||
const DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT: usize = 10_000;
|
||||
const DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT: usize = 100;
|
||||
#[cfg(not(test))]
|
||||
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[cfg(test)]
|
||||
@@ -41,6 +44,12 @@ const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, crate::error::Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct RecoveryWorkBudget {
|
||||
pub(super) max_objects: usize,
|
||||
pub(super) max_buckets: usize,
|
||||
}
|
||||
|
||||
fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
|
||||
WalkOptions {
|
||||
include_free_versions: true,
|
||||
@@ -58,6 +67,7 @@ fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) enum RecoveryWalkTestAction {
|
||||
SendItems(Vec<ObjectInfo>),
|
||||
SendItemsThenError(Vec<ObjectInfo>, crate::error::Error),
|
||||
SendItemsThenHang(Vec<ObjectInfo>, Arc<tokio::sync::Notify>),
|
||||
SendItemsUntilReceiverCloses(Arc<tokio::sync::Notify>),
|
||||
@@ -172,7 +182,8 @@ pub(super) async fn recover_tier_free_versions_with_cancel(
|
||||
return Err(std::io::Error::other("free-version recovery limit must be greater than zero").into());
|
||||
}
|
||||
|
||||
let page = list_tier_free_versions(api, limit, bucket_marker.clone(), object_marker.clone(), cancel_token.clone()).await?;
|
||||
let page =
|
||||
list_tier_free_versions(api.clone(), limit, bucket_marker.clone(), object_marker.clone(), cancel_token.clone()).await?;
|
||||
let mut stats = FreeVersionRecoveryStats {
|
||||
scanned: 0,
|
||||
enqueued: 0,
|
||||
@@ -190,7 +201,7 @@ pub(super) async fn recover_tier_free_versions_with_cancel(
|
||||
return Err(tier_free_version_recovery_cancelled());
|
||||
}
|
||||
retry_cursor.visit(&oi);
|
||||
if !record_recovered_free_version_enqueue(&mut stats, enqueue_recovered_free_version(oi).await) {
|
||||
if !record_recovered_free_version_enqueue(&mut stats, enqueue_recovered_free_version(&api, oi).await) {
|
||||
let (bucket_marker, object_marker) = retry_cursor.retry_markers();
|
||||
stats.truncated = true;
|
||||
stats.next_bucket_marker = bucket_marker;
|
||||
@@ -276,6 +287,17 @@ pub(super) async fn list_tier_free_versions(
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<FreeVersionRecoveryPage> {
|
||||
list_tier_free_versions_with_budget(api, limit, bucket_marker, object_marker, cancel_token, recovery_work_budget(limit)).await
|
||||
}
|
||||
|
||||
pub(super) async fn list_tier_free_versions_with_budget(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
work_budget: RecoveryWorkBudget,
|
||||
) -> Result<FreeVersionRecoveryPage> {
|
||||
let mut page = FreeVersionRecoveryPage {
|
||||
items: Vec::new(),
|
||||
@@ -289,6 +311,9 @@ pub(super) async fn list_tier_free_versions(
|
||||
if limit == 0 {
|
||||
return Ok(page);
|
||||
}
|
||||
if work_budget.max_objects == 0 || work_budget.max_buckets == 0 {
|
||||
return Err(std::io::Error::other("free-version recovery work budget must be greater than zero").into());
|
||||
}
|
||||
|
||||
let bucket_options = BucketOptions::default();
|
||||
let list_buckets = async {
|
||||
@@ -312,7 +337,7 @@ pub(super) async fn list_tier_free_versions(
|
||||
};
|
||||
let mut bucket_seen = bucket_marker.is_none();
|
||||
let mut truncated_after: Option<RecoveryCursor> = None;
|
||||
let walk_scan_limit = recovery_walk_scan_limit(limit);
|
||||
let mut remaining_scan_objects = work_budget.max_objects;
|
||||
|
||||
for bucket in buckets {
|
||||
if cancel_token.is_cancelled() {
|
||||
@@ -328,12 +353,20 @@ pub(super) async fn list_tier_free_versions(
|
||||
bucket_seen = true;
|
||||
}
|
||||
|
||||
if page.buckets_scanned >= work_budget.max_buckets {
|
||||
page.truncated = true;
|
||||
page.next_bucket_marker = Some(bucket.name);
|
||||
page.next_object_marker = None;
|
||||
break;
|
||||
}
|
||||
|
||||
page.buckets_scanned += 1;
|
||||
let bucket_object_marker = if bucket_marker.as_deref() == Some(bucket.name.as_str()) {
|
||||
object_marker.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let bucket_walk_limit = remaining_scan_objects;
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<ObjectInfoOrErr>(100);
|
||||
let cancel = cancel_token.child_token();
|
||||
@@ -357,6 +390,21 @@ pub(super) async fn list_tier_free_versions(
|
||||
#[cfg(test)]
|
||||
if let Some(action) = test_action {
|
||||
match action {
|
||||
RecoveryWalkTestAction::SendItems(items) => {
|
||||
for item in items {
|
||||
if tx
|
||||
.send(ObjectInfoOrErr {
|
||||
item: Some(item),
|
||||
err: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
RecoveryWalkTestAction::SendItemsThenError(items, err) => {
|
||||
for item in items {
|
||||
if tx
|
||||
@@ -424,7 +472,7 @@ pub(super) async fn list_tier_free_versions(
|
||||
}
|
||||
}
|
||||
|
||||
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(walk_scan_limit, object_marker))
|
||||
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(bucket_walk_limit, object_marker))
|
||||
.await
|
||||
}
|
||||
});
|
||||
@@ -465,6 +513,18 @@ pub(super) async fn list_tier_free_versions(
|
||||
let Some(oi) = item.item else {
|
||||
continue;
|
||||
};
|
||||
if last_seen_object.as_deref() != Some(oi.name.as_str()) && scanned_objects >= bucket_walk_limit {
|
||||
// The disk listing limit follows S3-visible counting rules and
|
||||
// does not charge metadata containing only hidden/free versions.
|
||||
// Enforce the repair budget again at the decoded-object boundary.
|
||||
page.truncated = true;
|
||||
page.next_bucket_marker = Some(bucket.name.clone());
|
||||
page.next_object_marker = last_seen_object.clone();
|
||||
cancel.cancel();
|
||||
draining_after_truncation = true;
|
||||
drain_deadline = Some(tokio::time::Instant::now() + BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
|
||||
continue;
|
||||
}
|
||||
record_scanned_object(&mut last_seen_object, &mut scanned_objects, &oi.name);
|
||||
if let Some(cursor) = &truncated_after
|
||||
&& (cursor.bucket.as_str() != bucket.name.as_str() || cursor.object.as_str() != oi.name.as_str())
|
||||
@@ -508,7 +568,8 @@ pub(super) async fn list_tier_free_versions(
|
||||
return Err(err);
|
||||
}
|
||||
walk_result?;
|
||||
mark_scan_truncated_if_needed(&mut page, scanned_objects, walk_scan_limit, &bucket.name, last_seen_object.as_deref());
|
||||
remaining_scan_objects = remaining_scan_objects.saturating_sub(scanned_objects);
|
||||
mark_scan_truncated_if_needed(&mut page, scanned_objects, bucket_walk_limit, &bucket.name, last_seen_object.as_deref());
|
||||
|
||||
if page.truncated {
|
||||
break;
|
||||
@@ -527,6 +588,13 @@ fn recovery_walk_scan_limit(limit: usize) -> usize {
|
||||
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT.max(limit.saturating_add(1))
|
||||
}
|
||||
|
||||
fn recovery_work_budget(limit: usize) -> RecoveryWorkBudget {
|
||||
RecoveryWorkBudget {
|
||||
max_objects: recovery_walk_scan_limit(limit),
|
||||
max_buckets: DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_scanned_object(last_seen_object: &mut Option<String>, scanned_objects: &mut usize, object: &str) {
|
||||
if last_seen_object.as_deref() == Some(object) {
|
||||
return;
|
||||
@@ -699,6 +767,13 @@ mod tests {
|
||||
recovery_walk_scan_limit(DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT),
|
||||
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT + 1
|
||||
);
|
||||
assert_eq!(
|
||||
recovery_work_budget(DEFAULT_FREE_VERSION_RECOVERY_LIMIT),
|
||||
RecoveryWorkBudget {
|
||||
max_objects: DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT,
|
||||
max_buckets: DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#![allow(clippy::all)]
|
||||
|
||||
use rustfs_data_usage::TierStats;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Sub;
|
||||
@@ -27,7 +28,24 @@ use tracing::{error, warn};
|
||||
|
||||
pub type DailyAllTierStats = HashMap<String, LastDayTierStats>;
|
||||
|
||||
#[derive(Clone)]
|
||||
/// One bin per hour of the rolling day. The bin index is the UTC hour, so the
|
||||
/// array is a ring the writer ages forward rather than a queue.
|
||||
pub const TIER_DAILY_STATS_BINS: usize = 24;
|
||||
|
||||
/// Interchange form of [`LastDayTierStats`] for the internode tier-stats RPC.
|
||||
///
|
||||
/// The in-memory type keeps its bins private because the ring is only
|
||||
/// meaningful together with `updated_at`; this type carries both across the
|
||||
/// wire and is validated back into the ring by [`LastDayTierStats::from_wire`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TierDailyStatsWire {
|
||||
pub bins: Vec<TierStats>,
|
||||
/// Seconds since the Unix epoch. Bins are hour-resolution, so a coarser
|
||||
/// timestamp than the in-memory `OffsetDateTime` loses nothing.
|
||||
pub updated_at_unix_secs: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LastDayTierStats {
|
||||
bins: [TierStats; 24],
|
||||
updated_at: OffsetDateTime,
|
||||
@@ -80,11 +98,57 @@ impl LastDayTierStats {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "asserted by this file's tests; the lib target cannot see test-only consumers (backlog#1823)"
|
||||
)]
|
||||
fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
|
||||
/// The rolling ring as observed, without aging it forward.
|
||||
///
|
||||
/// Only meaningful together with [`LastDayTierStats::updated_at`]: a bin
|
||||
/// belongs to the hour of its index within the day that ends at
|
||||
/// `updated_at`.
|
||||
pub fn bins(&self) -> &[TierStats; TIER_DAILY_STATS_BINS] {
|
||||
&self.bins
|
||||
}
|
||||
|
||||
pub fn updated_at(&self) -> OffsetDateTime {
|
||||
self.updated_at
|
||||
}
|
||||
|
||||
pub fn to_wire(&self) -> TierDailyStatsWire {
|
||||
TierDailyStatsWire {
|
||||
bins: self.bins.to_vec(),
|
||||
updated_at_unix_secs: self.updated_at.unix_timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the ring from a peer's response.
|
||||
///
|
||||
/// A ring of the wrong width or an unrepresentable timestamp is corrupt
|
||||
/// peer input, not a zero sample: it returns an error so the caller can
|
||||
/// report the node as non-reporting instead of merging a plausible but
|
||||
/// wrong day into a cluster total.
|
||||
pub fn from_wire(wire: TierDailyStatsWire) -> Result<Self, std::io::Error> {
|
||||
let bins: [TierStats; TIER_DAILY_STATS_BINS] = wire.bins.try_into().map_err(|bins: Vec<TierStats>| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("tier daily stats must carry {TIER_DAILY_STATS_BINS} bins, got {}", bins.len()),
|
||||
)
|
||||
})?;
|
||||
let updated_at = OffsetDateTime::from_unix_timestamp(wire.updated_at_unix_secs).map_err(|err| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("tier daily stats carry an unrepresentable timestamp: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self { bins, updated_at })
|
||||
}
|
||||
|
||||
/// Combine two independently observed rings.
|
||||
///
|
||||
/// Each node counts only the transitions it completed itself, so summing
|
||||
/// bins across nodes is a cluster total rather than a double count. The
|
||||
/// older ring is aged forward to the newer one's clock first, so a node
|
||||
/// that stopped transitioning hours ago contributes its still-current
|
||||
/// bins and not its expired ones.
|
||||
pub fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
|
||||
let mut cl = self.clone();
|
||||
let mut cm = m;
|
||||
let mut merged = LastDayTierStats::default();
|
||||
@@ -108,6 +172,7 @@ impl LastDayTierStats {
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use time::Duration;
|
||||
|
||||
#[test]
|
||||
fn total_sums_all_recorded_stats() {
|
||||
@@ -132,4 +197,83 @@ mod test {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn sample(total_size: u64) -> TierStats {
|
||||
TierStats {
|
||||
total_size,
|
||||
num_versions: 1,
|
||||
num_objects: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_round_trip_preserves_the_ring_and_its_clock() {
|
||||
let mut stats = LastDayTierStats::default();
|
||||
stats.add_stats(sample(10));
|
||||
|
||||
let restored = LastDayTierStats::from_wire(stats.to_wire()).expect("a ring this node produced must decode");
|
||||
|
||||
assert_eq!(restored.bins(), stats.bins(), "every bin must survive the wire");
|
||||
assert_eq!(
|
||||
restored.updated_at().unix_timestamp(),
|
||||
stats.updated_at().unix_timestamp(),
|
||||
"the ring's clock must survive the wire"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ring_of_the_wrong_width_is_rejected() {
|
||||
let mut wire = LastDayTierStats::default().to_wire();
|
||||
wire.bins.pop();
|
||||
|
||||
let err = LastDayTierStats::from_wire(wire).expect_err("a short ring must not decode as a zero day");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrepresentable_clock_is_rejected() {
|
||||
let mut wire = LastDayTierStats::default().to_wire();
|
||||
wire.updated_at_unix_secs = i64::MIN;
|
||||
|
||||
let err = LastDayTierStats::from_wire(wire).expect_err("an unrepresentable clock must not decode");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_sums_two_nodes_that_transitioned_in_the_same_hour() {
|
||||
let mut left = LastDayTierStats::default();
|
||||
left.add_stats(sample(10));
|
||||
let mut right = LastDayTierStats::default();
|
||||
right.add_stats(sample(20));
|
||||
|
||||
assert_eq!(
|
||||
left.merge(right).total(),
|
||||
TierStats {
|
||||
total_size: 30,
|
||||
num_versions: 2,
|
||||
num_objects: 2,
|
||||
},
|
||||
"each node counts only its own completions, so a merge is a cluster total"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_ages_out_a_peer_ring_older_than_a_day() {
|
||||
let mut stale = LastDayTierStats::default();
|
||||
stale.add_stats(sample(10));
|
||||
stale.updated_at -= Duration::days(2);
|
||||
|
||||
let mut fresh = LastDayTierStats::default();
|
||||
fresh.add_stats(sample(20));
|
||||
|
||||
assert_eq!(
|
||||
fresh.merge(stale).total(),
|
||||
TierStats {
|
||||
total_size: 20,
|
||||
num_versions: 1,
|
||||
num_objects: 1,
|
||||
},
|
||||
"a node that stopped transitioning more than a day ago must not keep contributing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ impl ObjSweeper {
|
||||
}
|
||||
if del_tier {
|
||||
return Some(Jentry {
|
||||
persisted_version: 0,
|
||||
obj_name: self.remote_object.clone(),
|
||||
version_id: self.transition_version_id.clone(),
|
||||
tier_name: self.transition_tier.clone(),
|
||||
@@ -266,6 +267,7 @@ impl ObjSweeper {
|
||||
version_state: self.transition_version_state,
|
||||
state: TierDeleteJournalState::Committed,
|
||||
source: None,
|
||||
dispatch: None,
|
||||
});
|
||||
}
|
||||
None
|
||||
@@ -298,9 +300,19 @@ impl ObjSweeper {
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum TierDeleteJournalState {
|
||||
Prepared,
|
||||
Dispatched,
|
||||
Committed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct TierDeleteDispatchBinding {
|
||||
pub(crate) operation_id: Uuid,
|
||||
pub(crate) manifest_object: String,
|
||||
pub(crate) journal_set_sha256: String,
|
||||
pub(crate) topology_generation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct TierDeleteSourceIdentity {
|
||||
@@ -342,6 +354,10 @@ impl TierDeleteSourceIdentity {
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(unused_assignments)]
|
||||
pub struct Jentry {
|
||||
/// On-disk format version when decoded. Newly constructed entries use 0;
|
||||
/// the encoder chooses their format from the durable ownership fields.
|
||||
/// Recovery uses this value to quarantine v1-v5 without rewriting them.
|
||||
pub(crate) persisted_version: u8,
|
||||
pub(crate) obj_name: String,
|
||||
pub(crate) version_id: String,
|
||||
pub(crate) tier_name: String,
|
||||
@@ -350,6 +366,23 @@ pub struct Jentry {
|
||||
pub(crate) version_state: rustfs_filemeta::TransitionVersionState,
|
||||
pub(crate) state: TierDeleteJournalState,
|
||||
pub(crate) source: Option<TierDeleteSourceIdentity>,
|
||||
pub(crate) dispatch: Option<TierDeleteDispatchBinding>,
|
||||
}
|
||||
|
||||
impl Jentry {
|
||||
/// Whether this prepared transaction is eligible to become the sole
|
||||
/// cleanup owner for its transitioned source. The caller may use this to
|
||||
/// decide whether to persist it, but must not set `skip_free_version`
|
||||
/// until persistence succeeds.
|
||||
pub(crate) fn can_replace_tier_free_version(&self) -> bool {
|
||||
self.state == TierDeleteJournalState::Prepared
|
||||
&& self.backend_identity.is_some()
|
||||
&& self.version_state != rustfs_filemeta::TransitionVersionState::Unknown
|
||||
&& self
|
||||
.source
|
||||
.as_ref()
|
||||
.is_some_and(TierDeleteSourceIdentity::has_stable_identity)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExpiryOp for Jentry {
|
||||
@@ -542,6 +575,7 @@ pub(crate) async fn delete_confirmed_transition_candidate_exact_with_lease_idemp
|
||||
#[cfg(test)]
|
||||
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
obj_name: &str,
|
||||
rv_id: &str,
|
||||
@@ -617,6 +651,7 @@ pub fn transitioned_force_delete_journal_entry(
|
||||
}
|
||||
|
||||
Some(Jentry {
|
||||
persisted_version: 0,
|
||||
obj_name: transitioned.name.clone(),
|
||||
version_id: transitioned.version_id.clone(),
|
||||
tier_name: transitioned.tier.clone(),
|
||||
@@ -628,6 +663,7 @@ pub fn transitioned_force_delete_journal_entry(
|
||||
version_state: transition_version_state,
|
||||
state: TierDeleteJournalState::Committed,
|
||||
source: None,
|
||||
dispatch: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -673,17 +709,73 @@ mod test {
|
||||
use rustfs_s3_client::signer_error::invalid_utf8_header_error;
|
||||
|
||||
use super::{
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED,
|
||||
RemoteDeleteBreaker, RemoteTierDeleteOutcome, delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||
delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||
is_remote_tier_not_found_error, is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook,
|
||||
should_record_remote_delete_failure, transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
|
||||
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, Jentry,
|
||||
RemoteDeleteBreaker, RemoteTierDeleteOutcome, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity, delete_object_from_remote_tier_idempotent,
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity, is_remote_tier_not_found_error,
|
||||
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 rustfs_filemeta::TransitionVersionState;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn stable_prepared_journal() -> Jentry {
|
||||
Jentry {
|
||||
persisted_version: 0,
|
||||
obj_name: "remote/object".to_string(),
|
||||
version_id: "remote-version".to_string(),
|
||||
tier_name: "WARM".to_string(),
|
||||
backend_identity: Some([7; 32]),
|
||||
version_id_exact: true,
|
||||
version_state: TransitionVersionState::Exact,
|
||||
state: TierDeleteJournalState::Prepared,
|
||||
source: Some(TierDeleteSourceIdentity {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
version_id: Some(uuid::Uuid::new_v4().to_string()),
|
||||
versioned: true,
|
||||
version_suspended: false,
|
||||
data_dir: None,
|
||||
etag: None,
|
||||
mod_time: None,
|
||||
}),
|
||||
dispatch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_stable_prepared_journal_can_replace_tier_free_version() {
|
||||
let stable = stable_prepared_journal();
|
||||
assert!(stable.can_replace_tier_free_version());
|
||||
|
||||
let mut committed = stable.clone();
|
||||
committed.state = TierDeleteJournalState::Committed;
|
||||
assert!(!committed.can_replace_tier_free_version());
|
||||
|
||||
let mut unbound = stable.clone();
|
||||
unbound.backend_identity = None;
|
||||
assert!(!unbound.can_replace_tier_free_version());
|
||||
|
||||
let mut unknown = stable.clone();
|
||||
unknown.version_state = TransitionVersionState::Unknown;
|
||||
assert!(!unknown.can_replace_tier_free_version());
|
||||
|
||||
let mut unstable = stable;
|
||||
unstable.source = Some(TierDeleteSourceIdentity {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
version_id: None,
|
||||
versioned: false,
|
||||
version_suspended: false,
|
||||
data_dir: None,
|
||||
etag: Some("etag-only".to_string()),
|
||||
mod_time: None,
|
||||
});
|
||||
assert!(!unstable.can_replace_tier_free_version());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signer_header_error_detection_matches_utf8_failures() {
|
||||
let err = Error::new(
|
||||
|
||||
@@ -25,14 +25,17 @@ use crate::bucket::lifecycle::durable_namespace::TRANSITION_TRANSACTION_NAMESPAC
|
||||
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
||||
use crate::bucket::lifecycle::tier_sweeper::{
|
||||
delete_confirmed_transition_candidate_exact_with_lease_idempotent,
|
||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||
};
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result as EcstoreResult};
|
||||
use crate::object_api::ObjectOptions;
|
||||
use crate::services::tier::{tier::TierConfigMgr, warm_backend::TransitionCandidateProbe};
|
||||
use crate::storage_api_contracts::{list::ListOperations as _, object::ObjectOperations as _};
|
||||
use crate::storage_api_contracts::{
|
||||
list::ListOperations as _,
|
||||
namespace::NamespaceLocking as _,
|
||||
object::{HTTPPreconditions, ObjectOperations as _},
|
||||
};
|
||||
use crate::store::ECStore;
|
||||
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
@@ -110,7 +113,7 @@ pub struct TransitionRemoteVersion {
|
||||
impl TransitionRemoteVersion {
|
||||
pub fn known_from_put_response(version_id: impl Into<String>) -> Self {
|
||||
let version_id = version_id.into();
|
||||
if version_id.is_empty() || Uuid::parse_str(&version_id).is_ok_and(|parsed| parsed.is_nil()) {
|
||||
if version_id.is_empty() {
|
||||
Self::unversioned()
|
||||
} else {
|
||||
Self::versioned(version_id)
|
||||
@@ -307,10 +310,16 @@ impl TransitionTransaction {
|
||||
if self.write_id.is_nil() {
|
||||
return Err(TransitionTransactionError::Corrupt("write_id is nil"));
|
||||
}
|
||||
if self.not_after_unix_nanos <= 0 {
|
||||
return Err(TransitionTransactionError::Corrupt("ownership deadline is not positive"));
|
||||
}
|
||||
self.source.validate()?;
|
||||
if self.tier_name.is_empty() {
|
||||
return Err(TransitionTransactionError::Corrupt("tier name is empty"));
|
||||
}
|
||||
if self.backend_fingerprint == [0; 32] {
|
||||
return Err(TransitionTransactionError::Corrupt("backend fingerprint is empty"));
|
||||
}
|
||||
if self.remote_object
|
||||
!= canonical_transition_remote_object(self.deployment_id, &self.source.bucket, self.transaction_id, self.write_id)?
|
||||
{
|
||||
@@ -476,6 +485,18 @@ impl TransitionTransaction {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_same_immutable_identity(&self, other: &Self) -> bool {
|
||||
self.deployment_id == other.deployment_id
|
||||
&& self.transaction_id == other.transaction_id
|
||||
&& self.owner_epoch == other.owner_epoch
|
||||
&& self.write_id == other.write_id
|
||||
&& self.source == other.source
|
||||
&& self.tier_name == other.tier_name
|
||||
&& self.backend_fingerprint == other.backend_fingerprint
|
||||
&& self.remote_object == other.remote_object
|
||||
&& self.not_after_unix_nanos == other.not_after_unix_nanos
|
||||
}
|
||||
|
||||
fn validate_cleanup_proof(&self, proof: &TransitionCleanupProof) -> Result<()> {
|
||||
if proof.transaction_id != self.transaction_id
|
||||
|| proof.write_id != self.write_id
|
||||
@@ -585,20 +606,91 @@ pub(crate) async fn save_transition_transaction_record(
|
||||
let object =
|
||||
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
|
||||
let data = transaction.encode().map_err(transition_transaction_store_error)?;
|
||||
config_boundary::save_config(api.clone(), &object, data.clone()).await?;
|
||||
config_boundary::save_config_with_opts(
|
||||
api.clone(),
|
||||
&object,
|
||||
data.clone(),
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// Box::pin: the durable-receipt state machine is large and sits on the
|
||||
// already-deep transition worker poll chain; keeping it inline overflows
|
||||
// the default 2 MiB tokio worker stack in debug builds.
|
||||
Box::pin(api.record_durable_ilm_decommission_progress(&object, &data)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_transition_transaction_record_if_current(
|
||||
api: Arc<ECStore>,
|
||||
expected: &TransitionTransaction,
|
||||
next: &TransitionTransaction,
|
||||
) -> EcstoreResult<()> {
|
||||
let object = transition_transaction_record_object_name(next.transaction_id).map_err(transition_transaction_store_error)?;
|
||||
let revision_is_next = expected.revision.checked_add(1) == Some(next.revision);
|
||||
let state_is_next = state_change_allowed(expected.state, next.state)
|
||||
|| matches!(
|
||||
(expected.state, next.state),
|
||||
(
|
||||
TransitionTransactionState::Uploaded
|
||||
| TransitionTransactionState::UploadOutcomeUnknown
|
||||
| TransitionTransactionState::LocalCommitStarted,
|
||||
TransitionTransactionState::CleanupPending
|
||||
)
|
||||
);
|
||||
let remote_version_is_monotonic = expected.remote_version.is_unknown() || expected.remote_version == next.remote_version;
|
||||
if !expected.has_same_immutable_identity(next) || !revision_is_next || !state_is_next || !remote_version_is_monotonic {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let (current, etag) = load_transition_transaction_record_with_etag(api.clone(), expected.transaction_id).await?;
|
||||
if ¤t != expected {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let data = next.encode().map_err(transition_transaction_store_error)?;
|
||||
config_boundary::save_config_with_opts(
|
||||
api.clone(),
|
||||
&object,
|
||||
data.clone(),
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(etag),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// Box::pin: see save_transition_transaction_record.
|
||||
Box::pin(api.record_durable_ilm_decommission_progress(&object, &data)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn load_transition_transaction_record(
|
||||
api: Arc<ECStore>,
|
||||
transaction_id: Uuid,
|
||||
) -> EcstoreResult<TransitionTransaction> {
|
||||
load_transition_transaction_record_with_etag(api, transaction_id)
|
||||
.await
|
||||
.map(|(transaction, _)| transaction)
|
||||
}
|
||||
|
||||
async fn load_transition_transaction_record_with_etag(
|
||||
api: Arc<ECStore>,
|
||||
transaction_id: Uuid,
|
||||
) -> EcstoreResult<(TransitionTransaction, String)> {
|
||||
let object = transition_transaction_record_object_name(transaction_id).map_err(transition_transaction_store_error)?;
|
||||
let data = config_boundary::read_config(api, &object).await?;
|
||||
TransitionTransaction::decode(transaction_id, &data).map_err(transition_transaction_store_error)
|
||||
let (data, object_info) = config_boundary::read_config_with_metadata(api, &object, &ObjectOptions::default()).await?;
|
||||
let etag = object_info
|
||||
.etag
|
||||
.filter(|etag| !etag.trim().is_empty())
|
||||
.ok_or_else(|| Error::other("transition transaction record is missing an ETag"))?;
|
||||
let transaction = TransitionTransaction::decode(transaction_id, &data).map_err(transition_transaction_store_error)?;
|
||||
Ok((transaction, etag))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_transition_transaction_record(
|
||||
@@ -607,10 +699,18 @@ pub(crate) async fn delete_transition_transaction_record(
|
||||
) -> EcstoreResult<()> {
|
||||
let object =
|
||||
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
|
||||
let data = transaction.encode().map_err(transition_transaction_store_error)?;
|
||||
let (current, etag) = match load_transition_transaction_record_with_etag(api.clone(), transaction.transaction_id).await {
|
||||
Ok(record) => record,
|
||||
Err(Error::ConfigNotFound) => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if ¤t != transaction {
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let data = current.encode().map_err(transition_transaction_store_error)?;
|
||||
// Box::pin: see save_transition_transaction_record.
|
||||
Box::pin(api.record_durable_ilm_decommission_terminal(&object, &data)).await?;
|
||||
match config_boundary::delete_config(api, &object).await {
|
||||
match config_boundary::delete_config_if_match(api, &object, &etag).await {
|
||||
Ok(()) | Err(Error::ConfigNotFound) => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
@@ -637,6 +737,84 @@ pub enum TransitionTransactionRecoveryOutcome {
|
||||
Retained,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct TransitionRecoveryClaimBarrierState {
|
||||
transaction_id: Uuid,
|
||||
arrived: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct TransitionRecoveryClaimBarrier {
|
||||
state: Arc<TransitionRecoveryClaimBarrierState>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static TRANSITION_RECOVERY_CLAIM_BARRIER: std::sync::OnceLock<
|
||||
std::sync::Mutex<Option<Arc<TransitionRecoveryClaimBarrierState>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
#[cfg(test)]
|
||||
impl TransitionRecoveryClaimBarrier {
|
||||
pub(crate) fn install(transaction_id: Uuid) -> Self {
|
||||
let state = Arc::new(TransitionRecoveryClaimBarrierState {
|
||||
transaction_id,
|
||||
..Default::default()
|
||||
});
|
||||
let mut slot = TRANSITION_RECOVERY_CLAIM_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("transition recovery claim barrier mutex should not poison");
|
||||
assert!(
|
||||
slot.is_none(),
|
||||
"transition recovery claim barrier must be installed by one test at a time"
|
||||
);
|
||||
*slot = Some(Arc::clone(&state));
|
||||
drop(slot);
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_until_paused(&self) {
|
||||
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
|
||||
.await
|
||||
.expect("transition recovery should reach the cleanup claim CAS");
|
||||
}
|
||||
|
||||
pub(crate) fn release(&self) {
|
||||
self.state.release.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TransitionRecoveryClaimBarrier {
|
||||
fn drop(&mut self) {
|
||||
self.state.release.notify_one();
|
||||
let mut slot = TRANSITION_RECOVERY_CLAIM_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("transition recovery claim barrier mutex should not poison");
|
||||
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn pause_before_transition_recovery_claim(transaction_id: Uuid) {
|
||||
let barrier = TRANSITION_RECOVERY_CLAIM_BARRIER
|
||||
.get_or_init(|| std::sync::Mutex::new(None))
|
||||
.lock()
|
||||
.expect("transition recovery claim barrier mutex should not poison")
|
||||
.as_ref()
|
||||
.filter(|barrier| barrier.transaction_id == transaction_id)
|
||||
.cloned();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.arrived.notify_one();
|
||||
barrier.release.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransitionOperatorProbe {
|
||||
@@ -858,49 +1036,129 @@ pub async fn process_transition_transaction_record(
|
||||
transaction: &TransitionTransaction,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
transaction.validate().map_err(transition_transaction_store_error)?;
|
||||
match transaction.state {
|
||||
// Box the expanded recovery state machine so callers on Tokio's default
|
||||
// worker stack do not inline its full future into an already-deep scan.
|
||||
Box::pin(process_transition_transaction_record_at(
|
||||
api,
|
||||
transaction,
|
||||
time::OffsetDateTime::now_utc().unix_timestamp_nanos(),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn process_transition_transaction_record_at(
|
||||
api: Arc<ECStore>,
|
||||
observed: &TransitionTransaction,
|
||||
now_unix_nanos: i128,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
let record_name =
|
||||
transition_transaction_record_object_name(observed.transaction_id).map_err(transition_transaction_store_error)?;
|
||||
// The synthetic key avoids nesting the recovery lock with the config
|
||||
// object's own I/O lock. Holding it across the bounded source proof and
|
||||
// remote DELETE elects one destructive recovery worker across nodes.
|
||||
let recovery_lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &format!("{record_name}.recovery-lock"))
|
||||
.await?;
|
||||
let _recovery_guard = recovery_lock
|
||||
.get_write_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await?;
|
||||
let current = match load_transition_transaction_record(api.clone(), observed.transaction_id).await {
|
||||
Ok(current) => current,
|
||||
Err(Error::ConfigNotFound) => return Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if ¤t != observed {
|
||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||
}
|
||||
|
||||
match current.state {
|
||||
TransitionTransactionState::Uploaded => {
|
||||
delete_transition_remote_candidate(api.clone(), transaction).await?;
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
TransitionTransactionState::CleanupPending => match local_commit_matches_transaction(api.clone(), transaction).await {
|
||||
Ok(true) => {
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
if transition_transaction_ownership_is_active(¤t, now_unix_nanos) {
|
||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||
}
|
||||
Ok(false) => {
|
||||
delete_transition_remote_candidate(api.clone(), transaction).await?;
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
Err(err) if transition_source_is_missing(&err) => {
|
||||
delete_transition_remote_candidate(api.clone(), transaction).await?;
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
TransitionTransactionState::LocalCommitStarted => {
|
||||
match local_commit_matches_transaction(api.clone(), transaction).await {
|
||||
Ok(true) => {
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
let mut cleanup = current.clone();
|
||||
cleanup
|
||||
.mark_cleanup_pending(
|
||||
current.fence(),
|
||||
TransitionCleanupProof {
|
||||
transaction_id: current.transaction_id,
|
||||
write_id: current.write_id,
|
||||
remote_object: current.remote_object.clone(),
|
||||
remote_version: current.remote_version.clone(),
|
||||
backend_fingerprint: current.backend_fingerprint,
|
||||
decision: TransitionCleanupDecision::UploadAbortedBeforeLocalCommit,
|
||||
},
|
||||
)
|
||||
.map_err(transition_transaction_store_error)?;
|
||||
#[cfg(test)]
|
||||
pause_before_transition_recovery_claim(current.transaction_id).await;
|
||||
match save_transition_transaction_record_if_current(api.clone(), ¤t, &cleanup).await {
|
||||
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
|
||||
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
TransitionTransactionState::CleanupPending => recover_cleanup_pending(api, ¤t).await,
|
||||
TransitionTransactionState::LocalCommitStarted => match local_commit_matches_transaction(api.clone(), ¤t).await {
|
||||
Ok(true) => {
|
||||
delete_transition_transaction_record(api, ¤t).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
TransitionTransactionState::AbortedNoRemote | TransitionTransactionState::Committed => {
|
||||
delete_transition_transaction_record(api, ¤t).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
TransitionTransactionState::UploadOutcomeUnknown => {
|
||||
if transition_transaction_ownership_is_active(¤t, now_unix_nanos) {
|
||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||
} else {
|
||||
recover_unknown_upload_outcome(api, ¤t).await
|
||||
}
|
||||
}
|
||||
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
}
|
||||
}
|
||||
|
||||
fn transition_transaction_ownership_is_active(transaction: &TransitionTransaction, now_unix_nanos: i128) -> bool {
|
||||
now_unix_nanos < i128::from(transaction.not_after_unix_nanos)
|
||||
}
|
||||
|
||||
async fn recover_cleanup_pending(
|
||||
api: Arc<ECStore>,
|
||||
transaction: &TransitionTransaction,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
match local_commit_matches_transaction(api.clone(), transaction).await {
|
||||
Ok(true) => {
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
|
||||
}
|
||||
TransitionTransactionState::UploadOutcomeUnknown => recover_unknown_upload_outcome(api, transaction).await,
|
||||
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Ok(false) => delete_unreferenced_transition_candidate(api, transaction).await,
|
||||
Err(err) if transition_source_is_missing(&err) => delete_unreferenced_transition_candidate(api, transaction).await,
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_unreferenced_transition_candidate(
|
||||
api: Arc<ECStore>,
|
||||
transaction: &TransitionTransaction,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
|
||||
let current = match load_transition_transaction_record(api.clone(), transaction.transaction_id).await {
|
||||
Ok(current) => current,
|
||||
Err(Error::ConfigNotFound) => return Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if ¤t != transaction || current.state != TransitionTransactionState::CleanupPending {
|
||||
return Ok(TransitionTransactionRecoveryOutcome::Retained);
|
||||
}
|
||||
delete_transition_remote_candidate(api.clone(), ¤t).await?;
|
||||
delete_transition_transaction_record(api, ¤t).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
}
|
||||
|
||||
async fn recover_unknown_upload_outcome(
|
||||
api: Arc<ECStore>,
|
||||
transaction: &TransitionTransaction,
|
||||
@@ -928,17 +1186,7 @@ async fn recover_unknown_upload_outcome(
|
||||
TransitionCandidateProbe::VersionedPresent(version_id)
|
||||
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
|
||||
{
|
||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity(
|
||||
&transaction.remote_object,
|
||||
&version_id,
|
||||
&transaction.tier_name,
|
||||
transaction.backend_fingerprint,
|
||||
&api.tier_config_mgr(),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::other)?;
|
||||
delete_transition_transaction_record(api, transaction).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
Ok(TransitionTransactionRecoveryOutcome::Retained)
|
||||
}
|
||||
TransitionCandidateProbe::VersionedPresent(version_id) => {
|
||||
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
|
||||
@@ -968,10 +1216,11 @@ async fn cleanup_recovered_unknown_upload_candidate(
|
||||
},
|
||||
)
|
||||
.map_err(transition_transaction_store_error)?;
|
||||
save_transition_transaction_record(api.clone(), &cleanup).await?;
|
||||
delete_transition_remote_candidate(api.clone(), &cleanup).await?;
|
||||
delete_transition_transaction_record(api, &cleanup).await?;
|
||||
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
|
||||
match save_transition_transaction_record_if_current(api.clone(), transaction, &cleanup).await {
|
||||
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
|
||||
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn transition_source_is_missing(err: &Error) -> bool {
|
||||
@@ -986,13 +1235,7 @@ fn transition_source_is_missing(err: &Error) -> bool {
|
||||
}
|
||||
|
||||
async fn local_commit_matches_transaction(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<bool> {
|
||||
let opts = ObjectOptions {
|
||||
version_id: transaction.source.version_id.map(|version_id| version_id.to_string()),
|
||||
versioned: transaction.source.version_mode == TransitionSourceVersionMode::Versioned,
|
||||
version_suspended: transaction.source.version_mode == TransitionSourceVersionMode::VersionSuspended,
|
||||
metadata_cache_safe: false,
|
||||
..Default::default()
|
||||
};
|
||||
let opts = transition_source_lookup_options(transaction);
|
||||
let object = api
|
||||
.get_object_info(&transaction.source.bucket, &transaction.source.object, &opts)
|
||||
.await?;
|
||||
@@ -1003,6 +1246,23 @@ async fn local_commit_matches_transaction(api: Arc<ECStore>, transaction: &Trans
|
||||
&& transitioned.version_id == transaction.remote_version.tier_delete_version_id().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn transition_source_lookup_options(transaction: &TransitionTransaction) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
version_id: match transaction.source.version_mode {
|
||||
TransitionSourceVersionMode::Versioned => transaction.source.version_id.map(|version_id| version_id.to_string()),
|
||||
// Both modes identify the stored null version. Query it explicitly
|
||||
// so a later versioning change cannot redirect the proof to a new latest version.
|
||||
TransitionSourceVersionMode::Unversioned | TransitionSourceVersionMode::VersionSuspended => {
|
||||
Some(Uuid::nil().to_string())
|
||||
}
|
||||
},
|
||||
versioned: transaction.source.version_mode == TransitionSourceVersionMode::Versioned,
|
||||
version_suspended: transaction.source.version_mode == TransitionSourceVersionMode::VersionSuspended,
|
||||
metadata_cache_safe: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_transition_remote_candidate(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<()> {
|
||||
let version_id = transaction.remote_version.tier_delete_version_id().unwrap_or_default();
|
||||
let version_id_exact = transaction.remote_version.kind == TransitionRemoteVersionKind::Versioned;
|
||||
@@ -1023,6 +1283,25 @@ pub async fn recover_transition_transaction_records(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
marker: Option<String>,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
|
||||
recover_transition_transaction_records_with_now(api, limit, marker, None).await
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub async fn recover_transition_transaction_records_at(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
marker: Option<String>,
|
||||
now_unix_nanos: i128,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
|
||||
recover_transition_transaction_records_with_now(api, limit, marker, Some(now_unix_nanos)).await
|
||||
}
|
||||
|
||||
async fn recover_transition_transaction_records_with_now(
|
||||
api: Arc<ECStore>,
|
||||
limit: usize,
|
||||
marker: Option<String>,
|
||||
now_unix_nanos: Option<i128>,
|
||||
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
|
||||
if limit == 0 {
|
||||
return Err(Error::other("transition transaction recovery limit must be greater than zero"));
|
||||
@@ -1087,7 +1366,13 @@ pub async fn recover_transition_transaction_records(
|
||||
}
|
||||
};
|
||||
|
||||
match process_transition_transaction_record(api.clone(), &transaction).await {
|
||||
let recovery = match now_unix_nanos {
|
||||
Some(now_unix_nanos) => {
|
||||
Box::pin(process_transition_transaction_record_at(api.clone(), &transaction, now_unix_nanos)).await
|
||||
}
|
||||
None => process_transition_transaction_record(api.clone(), &transaction).await,
|
||||
};
|
||||
match recovery {
|
||||
Ok(
|
||||
TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted
|
||||
| TransitionTransactionRecoveryOutcome::RecordDeleted,
|
||||
@@ -1332,6 +1617,34 @@ mod tests {
|
||||
.expect("expired unknown upload outcome should be eligible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_ownership_window_expires_at_not_after() {
|
||||
let transaction = new_transaction();
|
||||
let deadline = i128::from(transaction.not_after_unix_nanos);
|
||||
|
||||
assert!(transition_transaction_ownership_is_active(&transaction, deadline - 1));
|
||||
assert!(!transition_transaction_ownership_is_active(&transaction, deadline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_transition_source_lookup_targets_the_exact_version_shape() {
|
||||
for mode in [
|
||||
TransitionSourceVersionMode::Unversioned,
|
||||
TransitionSourceVersionMode::VersionSuspended,
|
||||
] {
|
||||
let mut transaction = new_transaction();
|
||||
transaction.source.version_id = None;
|
||||
transaction.source.version_mode = mode;
|
||||
|
||||
let opts = transition_source_lookup_options(&transaction);
|
||||
|
||||
assert_eq!(opts.version_id, Some(Uuid::nil().to_string()));
|
||||
assert!(!opts.versioned);
|
||||
assert_eq!(opts.version_suspended, mode == TransitionSourceVersionMode::VersionSuspended);
|
||||
assert!(!opts.metadata_cache_safe);
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_proof(transaction: &TransitionTransaction, decision: TransitionCleanupDecision) -> TransitionCleanupProof {
|
||||
TransitionCleanupProof {
|
||||
transaction_id: transaction.transaction_id,
|
||||
@@ -1346,10 +1659,17 @@ mod tests {
|
||||
#[test]
|
||||
fn remote_version_distinguishes_unknown_unversioned_and_versioned() {
|
||||
assert_eq!(TransitionRemoteVersion::known_from_put_response("").tier_delete_version_id(), None);
|
||||
let nil_version = Uuid::nil().to_string();
|
||||
let invalid_nil = TransitionRemoteVersion::known_from_put_response(nil_version.clone());
|
||||
assert_eq!(
|
||||
TransitionRemoteVersion::known_from_put_response(Uuid::nil().to_string()).tier_delete_version_id(),
|
||||
None
|
||||
invalid_nil.tier_delete_version_id(),
|
||||
Some(nil_version.as_str()),
|
||||
"a non-empty version must never be downgraded to an unversioned DELETE"
|
||||
);
|
||||
assert!(matches!(
|
||||
invalid_nil.validate(),
|
||||
Err(TransitionTransactionError::Corrupt("versioned remote version is nil uuid"))
|
||||
));
|
||||
|
||||
let version_id = Uuid::new_v4().to_string();
|
||||
assert_eq!(
|
||||
@@ -1468,6 +1788,34 @@ mod tests {
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
TransitionTransaction::new(TransitionTransactionInit {
|
||||
deployment_id: Uuid::new_v4(),
|
||||
transaction_id: Uuid::new_v4(),
|
||||
owner_epoch: Uuid::new_v4(),
|
||||
write_id: Uuid::new_v4(),
|
||||
source: source_identity(TransitionSourceVersionMode::Unversioned),
|
||||
tier_name: "warm-tier".to_string(),
|
||||
backend_fingerprint: [0; 32],
|
||||
not_after_unix_nanos: 1,
|
||||
}),
|
||||
Err(TransitionTransactionError::Corrupt("backend fingerprint is empty"))
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
TransitionTransaction::new(TransitionTransactionInit {
|
||||
deployment_id: Uuid::new_v4(),
|
||||
transaction_id: Uuid::new_v4(),
|
||||
owner_epoch: Uuid::new_v4(),
|
||||
write_id: Uuid::new_v4(),
|
||||
source: source_identity(TransitionSourceVersionMode::Unversioned),
|
||||
tier_name: "warm-tier".to_string(),
|
||||
backend_fingerprint: BACKEND_FINGERPRINT,
|
||||
not_after_unix_nanos: 0,
|
||||
}),
|
||||
Err(TransitionTransactionError::Corrupt("ownership deadline is not positive"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -270,6 +270,7 @@ pub const BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG: &str = "public-access-block.xml";
|
||||
pub const BUCKET_ACL_CONFIG: &str = "bucket-acl.json";
|
||||
pub const BUCKET_TABLE_CONFIG: &str = "table-bucket.json";
|
||||
pub const BUCKET_DURABILITY_CONFIG: &str = "durability.json";
|
||||
pub const BUCKET_ON_DEMAND_MIGRATION_CONFIG: &str = "on-demand-migration.json";
|
||||
pub const BUCKET_TABLE_RESERVED_PREFIX: &str = ".rustfs-table";
|
||||
pub const BUCKET_TABLE_CATALOG_META_PREFIX: &str = "s3tables/catalog";
|
||||
pub const BUCKET_TABLE_CATALOG_TABLE_BUCKETS_PREFIX: &str = "table-buckets";
|
||||
@@ -321,6 +322,7 @@ pub struct BucketMetadata {
|
||||
pub bucket_acl_config_json: Vec<u8>,
|
||||
pub table_bucket_config_json: Vec<u8>,
|
||||
pub durability_config_json: Vec<u8>,
|
||||
pub on_demand_migration_config_json: Vec<u8>,
|
||||
|
||||
pub policy_config_updated_at: OffsetDateTime,
|
||||
pub object_lock_config_updated_at: OffsetDateTime,
|
||||
@@ -342,6 +344,7 @@ pub struct BucketMetadata {
|
||||
pub bucket_acl_config_updated_at: OffsetDateTime,
|
||||
pub table_bucket_config_updated_at: OffsetDateTime,
|
||||
pub durability_config_updated_at: OffsetDateTime,
|
||||
pub on_demand_migration_config_updated_at: OffsetDateTime,
|
||||
|
||||
pub new_field_updated_at: OffsetDateTime,
|
||||
|
||||
@@ -393,6 +396,7 @@ impl Default for BucketMetadata {
|
||||
bucket_acl_config_json: Default::default(),
|
||||
table_bucket_config_json: Default::default(),
|
||||
durability_config_json: Default::default(),
|
||||
on_demand_migration_config_json: Default::default(),
|
||||
policy_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
object_lock_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
encryption_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
@@ -413,6 +417,7 @@ impl Default for BucketMetadata {
|
||||
bucket_acl_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
table_bucket_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
durability_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
on_demand_migration_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
new_field_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
policy_config: Default::default(),
|
||||
notification_config: Default::default(),
|
||||
@@ -477,6 +482,23 @@ impl BucketMetadata {
|
||||
/// Absent/empty/unparsable payloads all mean "no override" (the bucket
|
||||
/// follows the global durability mode); a parse failure is logged so a
|
||||
/// corrupted entry cannot silently change fsync behavior.
|
||||
/// Parsed on-demand migration config, if one is stored.
|
||||
///
|
||||
/// `Ok(None)` means no config (absent or cleared). A stored payload that
|
||||
/// does not parse is an error, never a default: the runtime must not
|
||||
/// pull from a source it cannot describe.
|
||||
pub fn on_demand_migration_config(
|
||||
&self,
|
||||
) -> std::result::Result<
|
||||
Option<super::on_demand_migration::OnDemandMigrationConfig>,
|
||||
super::on_demand_migration::OnDemandMigrationConfigError,
|
||||
> {
|
||||
if self.on_demand_migration_config_json.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
super::on_demand_migration::OnDemandMigrationConfig::from_json(&self.on_demand_migration_config_json).map(Some)
|
||||
}
|
||||
|
||||
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
|
||||
if self.durability_config_json.is_empty() {
|
||||
return None;
|
||||
@@ -555,6 +577,9 @@ impl BucketMetadata {
|
||||
"BucketAclConfigJSON" | "BucketAclConfigJson" => self.bucket_acl_config_json = read_msgp_bin(rd)?,
|
||||
"TableBucketConfigJSON" | "TableBucketConfigJson" => self.table_bucket_config_json = read_msgp_bin(rd)?,
|
||||
"DurabilityConfigJSON" | "DurabilityConfigJson" => self.durability_config_json = read_msgp_bin(rd)?,
|
||||
"OnDemandMigrationConfigJSON" | "OnDemandMigrationConfigJson" => {
|
||||
self.on_demand_migration_config_json = read_msgp_bin(rd)?
|
||||
}
|
||||
"CorsConfigUpdatedAt" => self.cors_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"LoggingConfigUpdatedAt" => self.logging_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"WebsiteConfigUpdatedAt" => self.website_config_updated_at = read_msgp_time_value(rd)?,
|
||||
@@ -564,6 +589,7 @@ impl BucketMetadata {
|
||||
"BucketAclConfigUpdatedAt" => self.bucket_acl_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"TableBucketConfigUpdatedAt" => self.table_bucket_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"DurabilityConfigUpdatedAt" => self.durability_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"OnDemandMigrationConfigUpdatedAt" => self.on_demand_migration_config_updated_at = read_msgp_time_value(rd)?,
|
||||
other => {
|
||||
tracing::debug!(field = %other, "BucketMetadata decode_from: skipping unknown field");
|
||||
skip_msgp_value(rd)?;
|
||||
@@ -576,8 +602,8 @@ impl BucketMetadata {
|
||||
|
||||
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
|
||||
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
|
||||
// Map size: MinIO fields (25) + RustFS extensions (19)
|
||||
let map_len: u32 = 44;
|
||||
// Map size: MinIO fields (25) + RustFS extensions (21)
|
||||
let map_len: u32 = 46;
|
||||
rmp::encode::write_map_len(wr, map_len)?;
|
||||
|
||||
// MinIO field order (same as Go struct)
|
||||
@@ -637,6 +663,7 @@ impl BucketMetadata {
|
||||
write_bin_field(wr, "BucketAclConfigJSON", &self.bucket_acl_config_json)?;
|
||||
write_bin_field(wr, "TableBucketConfigJSON", &self.table_bucket_config_json)?;
|
||||
write_bin_field(wr, "DurabilityConfigJSON", &self.durability_config_json)?;
|
||||
write_bin_field(wr, "OnDemandMigrationConfigJSON", &self.on_demand_migration_config_json)?;
|
||||
rmp::encode::write_str(wr, "CorsConfigUpdatedAt")?;
|
||||
write_msgp_time(wr, self.cors_config_updated_at)?;
|
||||
rmp::encode::write_str(wr, "LoggingConfigUpdatedAt")?;
|
||||
@@ -655,6 +682,8 @@ impl BucketMetadata {
|
||||
write_msgp_time(wr, self.table_bucket_config_updated_at)?;
|
||||
rmp::encode::write_str(wr, "DurabilityConfigUpdatedAt")?;
|
||||
write_msgp_time(wr, self.durability_config_updated_at)?;
|
||||
rmp::encode::write_str(wr, "OnDemandMigrationConfigUpdatedAt")?;
|
||||
write_msgp_time(wr, self.on_demand_migration_config_updated_at)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -756,6 +785,9 @@ impl BucketMetadata {
|
||||
if self.durability_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
||||
self.durability_config_updated_at = self.created
|
||||
}
|
||||
if self.on_demand_migration_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
||||
self.on_demand_migration_config_updated_at = self.created
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||
@@ -871,6 +903,17 @@ impl BucketMetadata {
|
||||
self.durability_config_json = data;
|
||||
self.durability_config_updated_at = updated;
|
||||
}
|
||||
BUCKET_ON_DEMAND_MIGRATION_CONFIG => {
|
||||
// Structural check only (shape, unknown fields); the
|
||||
// deployment-relative rules run in the admin handler with a
|
||||
// `ValidationContext`. A blob this build cannot read must not
|
||||
// be persisted for every later reader to trip over.
|
||||
if !data.is_empty() {
|
||||
super::on_demand_migration::OnDemandMigrationConfig::from_json(&data).map_err(Error::other)?;
|
||||
}
|
||||
self.on_demand_migration_config_json = data;
|
||||
self.on_demand_migration_config_updated_at = updated;
|
||||
}
|
||||
_ => return Err(Error::other(format!("config file not found : {config_file}"))),
|
||||
}
|
||||
|
||||
@@ -1779,6 +1822,117 @@ mod test {
|
||||
assert!(!bm.table_bucket_enabled());
|
||||
}
|
||||
|
||||
const ODM_JSON: &[u8] = br#"{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
||||
|
||||
/// rustfs/backlog#2148: the on-demand migration config is a RustFS
|
||||
/// extension entry that round-trips through `update_config` and the
|
||||
/// msgpack codec, clears on delete, and never parses corruption into a
|
||||
/// default.
|
||||
#[test]
|
||||
fn on_demand_migration_config_round_trips_and_tracks_updates() {
|
||||
use crate::bucket::on_demand_migration::{OnDemandMigrationConfig, OnDemandMigrationConfigError};
|
||||
|
||||
let mut bm = BucketMetadata::new("odm-bucket");
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(None), "fresh metadata carries no config");
|
||||
|
||||
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.expect("valid config is accepted");
|
||||
assert_ne!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(Some(expected.clone())));
|
||||
|
||||
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
|
||||
assert_eq!(back.on_demand_migration_config_json, bm.on_demand_migration_config_json);
|
||||
assert_eq!(
|
||||
back.on_demand_migration_config_updated_at.unix_timestamp(),
|
||||
bm.on_demand_migration_config_updated_at.unix_timestamp()
|
||||
);
|
||||
assert_eq!(back.on_demand_migration_config(), Ok(Some(expected)));
|
||||
|
||||
// A blob this build cannot read is rejected at the write boundary
|
||||
// rather than persisted for every reader to trip over.
|
||||
let before = bm.on_demand_migration_config_json.clone();
|
||||
assert!(
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec())
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(bm.on_demand_migration_config_json, before, "a rejected update leaves the blob untouched");
|
||||
|
||||
// Delete clears the entry.
|
||||
let stamped = bm.on_demand_migration_config_updated_at;
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
|
||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
||||
assert!(bm.on_demand_migration_config_updated_at >= stamped);
|
||||
|
||||
// Corruption that bypassed `update_config` (disk, another writer)
|
||||
// is a typed error, never a default.
|
||||
bm.on_demand_migration_config_json = b"not-json".to_vec();
|
||||
assert!(matches!(bm.on_demand_migration_config(), Err(OnDemandMigrationConfigError::Malformed(_))));
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
|
||||
/// migration keys existed decodes with an empty blob and an epoch
|
||||
/// timestamp that `default_timestamps` back-fills from `created`.
|
||||
#[test]
|
||||
fn on_demand_migration_config_absent_in_legacy_blob_defaults_to_created() {
|
||||
let blob = decode_hex(include_str!("../../tests/fixtures/minio/bucket_metadata.blob.hex"));
|
||||
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
|
||||
assert!(bm.on_demand_migration_config_json.is_empty());
|
||||
assert_eq!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||
assert_eq!(bm.on_demand_migration_config(), Ok(None));
|
||||
|
||||
bm.default_timestamps();
|
||||
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
|
||||
assert_eq!(bm.on_demand_migration_config_updated_at, bm.created);
|
||||
|
||||
// A metadata blob from this build with no config set stays
|
||||
// indistinguishable from the legacy one for these fields.
|
||||
let fresh = BucketMetadata::unmarshal(&BucketMetadata::new("fresh").marshal_msg().unwrap()).unwrap();
|
||||
assert!(fresh.on_demand_migration_config_json.is_empty());
|
||||
assert_eq!(fresh.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: a reader that predates the two on-demand
|
||||
/// migration keys takes `decode_from`'s unknown-field branch, which is
|
||||
/// `skip_msgp_value`. Walk the new-format blob with exactly that
|
||||
/// primitive and prove both keys are skipped without desynchronising the
|
||||
/// stream, so the fields that follow them still decode.
|
||||
#[test]
|
||||
fn old_decoder_skips_on_demand_migration_fields_without_desync() {
|
||||
let mut bm = BucketMetadata::new("odm-skip");
|
||||
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.unwrap();
|
||||
bm.update_config(BUCKET_DURABILITY_CONFIG, br#"{"mode":"relaxed"}"#.to_vec())
|
||||
.unwrap();
|
||||
let buf = bm.marshal_msg().unwrap();
|
||||
|
||||
let mut rd = std::io::Cursor::new(buf.as_slice());
|
||||
let fields = rmp::decode::read_map_len(&mut rd).unwrap();
|
||||
let mut skipped = Vec::new();
|
||||
let mut durability_json = Vec::new();
|
||||
for _ in 0..fields {
|
||||
let key_len = rmp::decode::read_str_len(&mut rd).unwrap();
|
||||
let mut key = vec![0u8; key_len as usize];
|
||||
rd.read_exact(&mut key).unwrap();
|
||||
let key = String::from_utf8(key).unwrap();
|
||||
match key.as_str() {
|
||||
// The field an old reader knows that is encoded *after* the
|
||||
// unknown JSON key and *before* the unknown timestamp key.
|
||||
"DurabilityConfigJSON" => durability_json = read_msgp_bin(&mut rd).unwrap(),
|
||||
other => {
|
||||
if other.starts_with("OnDemandMigration") {
|
||||
skipped.push(other.to_string());
|
||||
}
|
||||
skip_msgp_value(&mut rd).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(skipped, ["OnDemandMigrationConfigJSON", "OnDemandMigrationConfigUpdatedAt"]);
|
||||
assert_eq!(durability_json, br#"{"mode":"relaxed"}"#);
|
||||
assert_eq!(rd.position() as usize, buf.len(), "old-style walk must consume the blob exactly");
|
||||
}
|
||||
|
||||
/// HP-5b (rustfs/backlog#938): the durability override is a RustFS
|
||||
/// extension entry and must survive an encode/decode round trip.
|
||||
#[test]
|
||||
|
||||
@@ -19,9 +19,10 @@ use super::quota::BucketQuota;
|
||||
use super::target::BucketTargets;
|
||||
use crate::bucket::bucket_target_sys::BucketTargetSys;
|
||||
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
|
||||
use crate::bucket::on_demand_migration::{ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig};
|
||||
use crate::bucket::utils::is_meta_bucketname;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::{Error, Result, is_err_bucket_not_found};
|
||||
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
@@ -384,6 +385,42 @@ fn clear_bucket_durability(bucket: &str) {
|
||||
crate::disk::local::bucket_durability::set(bucket, None);
|
||||
}
|
||||
|
||||
/// Publish the bucket's on-demand migration config (or its absence) to the
|
||||
/// runtime registered in `ON_DEMAND_MIGRATION_CONFIG_HOOK`.
|
||||
///
|
||||
/// Called from the same five cache-install paths as
|
||||
/// [`sync_bucket_durability`]. A stored payload this build cannot parse is
|
||||
/// published as `None`: the runtime must stop pulling for that bucket rather
|
||||
/// than keep an older config or guess.
|
||||
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
|
||||
let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() else {
|
||||
return;
|
||||
};
|
||||
match bm.on_demand_migration_config() {
|
||||
Ok(config) => hook(bucket, config.as_ref()),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = "bucket_metadata_parse_failed",
|
||||
component = "ecstore",
|
||||
subsystem = "bucket_metadata",
|
||||
bucket = %bucket,
|
||||
config = "on_demand_migration",
|
||||
error = %err,
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
hook(bucket, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Withdraw a bucket's on-demand migration config when its metadata leaves
|
||||
/// the cache.
|
||||
fn clear_on_demand_migration(bucket: &str) {
|
||||
if let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() {
|
||||
hook(bucket, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(bucket: &str) -> Result<Arc<BucketMetadata>> {
|
||||
let sys = get_bucket_metadata_sys()?;
|
||||
let lock = sys.read().await;
|
||||
@@ -412,8 +449,14 @@ pub(crate) fn require_bucket_metadata_sys_in(
|
||||
}
|
||||
|
||||
pub(crate) async fn object_store_in(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<ECStore>> {
|
||||
let sys = bucket_metadata_sys_of(ctx)?;
|
||||
Ok(sys.read().await.api.clone())
|
||||
object_store_if_initialized_in(ctx)
|
||||
.await
|
||||
.ok_or_else(|| Error::other("bucket metadata sys not initialized for this instance"))
|
||||
}
|
||||
|
||||
pub(crate) async fn object_store_if_initialized_in(ctx: &crate::runtime::instance::InstanceContext) -> Option<Arc<ECStore>> {
|
||||
let sys = ctx.bucket_metadata_sys().or_else(get_global_bucket_metadata_sys)?;
|
||||
Some(sys.read().await.api.clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_in(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<Arc<BucketMetadata>> {
|
||||
@@ -653,13 +696,12 @@ async fn acquire_config_write_guard_for_incarnation(
|
||||
async {
|
||||
match metadata_sys
|
||||
.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(crate::disk::error::Error::VolumeNotFound) => Err(Error::BucketNotFound(bucket.to_string())),
|
||||
Err(err) => Err(err.into()),
|
||||
Err(err) if is_err_strict_volume_not_found(&err) => Err(Error::BucketNotFound(bucket.to_string())),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -965,6 +1007,16 @@ pub async fn get_durability_config(
|
||||
Ok((bm.durability_config(), bm.durability_config_updated_at))
|
||||
}
|
||||
|
||||
/// The bucket's on-demand migration config with its update time, or
|
||||
/// `Ok(None)` when the bucket has none. A stored payload that does not parse
|
||||
/// is a typed error (`OnDemandMigrationConfigError` inside `Error::Io`).
|
||||
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
bucket_meta_sys.get_on_demand_migration_config(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -1109,6 +1161,16 @@ pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfigur
|
||||
bucket_meta_sys.get_replication_config(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_replication_config_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
bucket_meta_sys.get_replication_config(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_notification_config(bucket: &str) -> Result<Option<NotificationConfiguration>> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -1130,6 +1192,16 @@ pub(crate) async fn has_authoritative_never_versioned_state(bucket: &str) -> Res
|
||||
bucket_meta_sys.has_authoritative_never_versioned_state(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn has_authoritative_never_versioned_state_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
) -> Result<bool> {
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
|
||||
|
||||
bucket_meta_sys.has_authoritative_never_versioned_state(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_website_config(bucket: &str) -> Result<(WebsiteConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
@@ -1258,7 +1330,6 @@ pub struct BucketMetadataSys {
|
||||
/// name floods while avoiding repeated namespace and erasure reads.
|
||||
missing_buckets: moka::future::Cache<String, ()>,
|
||||
api: Arc<ECStore>,
|
||||
initialized: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl BucketMetadataSys {
|
||||
@@ -1287,7 +1358,6 @@ impl BucketMetadataSys {
|
||||
.time_to_live(MISSING_BUCKET_TTL)
|
||||
.build(),
|
||||
api,
|
||||
initialized: Arc::new(RwLock::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1348,13 +1418,12 @@ impl BucketMetadataSys {
|
||||
await_bucket_namespace_operation(Some(namespace_guard), bucket, operation, async {
|
||||
match self
|
||||
.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(crate::disk::error::Error::VolumeNotFound) => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
Err(Error::VolumeNotFound) => Ok(false),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -1364,9 +1433,15 @@ impl BucketMetadataSys {
|
||||
let _ = self.init_internal(buckets).await;
|
||||
}
|
||||
async fn init_internal(&self, buckets: Vec<String>) -> Result<()> {
|
||||
let count = runtime_sources::endpoint_erasure_set_count()
|
||||
.map(|count| count * 10)
|
||||
.ok_or_else(|| Error::other("endpoint pools not initialized"))?;
|
||||
let count = self
|
||||
.api
|
||||
.pools
|
||||
.iter()
|
||||
.map(|pool| pool.disk_set.len())
|
||||
.sum::<usize>()
|
||||
.checked_mul(10)
|
||||
.filter(|count| *count != 0)
|
||||
.ok_or_else(|| Error::other("bucket metadata store has no erasure sets"))?;
|
||||
|
||||
let mut failed_buckets: HashSet<String> = HashSet::new();
|
||||
let mut buckets = buckets.as_slice();
|
||||
@@ -1384,9 +1459,6 @@ impl BucketMetadataSys {
|
||||
buckets = &buckets[count..]
|
||||
}
|
||||
|
||||
let mut initialized = self.initialized.write().await;
|
||||
*initialized = true;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1463,14 +1535,6 @@ impl BucketMetadataSys {
|
||||
expected: Option<&Arc<BucketMetadata>>,
|
||||
namespace_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
) -> Result<()> {
|
||||
await_bucket_namespace_operation(
|
||||
Some(namespace_guard),
|
||||
bucket,
|
||||
"bucket metadata heal",
|
||||
self.api.heal_bucket(bucket, &HealOpts::default()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !self
|
||||
.bucket_exists(bucket, namespace_guard, "bucket metadata existence check")
|
||||
.await?
|
||||
@@ -1485,11 +1549,26 @@ impl BucketMetadataSys {
|
||||
if removed {
|
||||
BucketTargetSys::get().delete(bucket).await;
|
||||
clear_bucket_durability(bucket);
|
||||
clear_on_demand_migration(bucket);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
await_bucket_namespace_operation(
|
||||
Some(namespace_guard),
|
||||
bucket,
|
||||
"bucket metadata heal",
|
||||
self.api.heal_bucket(
|
||||
bucket,
|
||||
&HealOpts {
|
||||
recreate: true,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (bm, persisted) = await_bucket_namespace_operation(
|
||||
Some(namespace_guard),
|
||||
bucket,
|
||||
@@ -1508,6 +1587,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
sync_on_demand_migration(bucket, &bm);
|
||||
}
|
||||
MetadataLoadMode::Initial => {
|
||||
let _publish_guard = self
|
||||
@@ -1554,6 +1634,7 @@ impl BucketMetadataSys {
|
||||
if removed {
|
||||
BucketTargetSys::get().delete(bucket).await;
|
||||
clear_bucket_durability(bucket);
|
||||
clear_on_demand_migration(bucket);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1576,6 +1657,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &metadata).await;
|
||||
sync_bucket_durability(bucket, &metadata);
|
||||
sync_on_demand_migration(bucket, &metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1603,6 +1685,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(&bucket).await;
|
||||
sync_bucket_target_sys(&bucket, &bm).await;
|
||||
sync_bucket_durability(&bucket, &bm);
|
||||
sync_on_demand_migration(&bucket, &bm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1623,6 +1706,7 @@ impl BucketMetadataSys {
|
||||
if removed {
|
||||
BucketTargetSys::get().delete(bucket).await;
|
||||
clear_bucket_durability(bucket);
|
||||
clear_on_demand_migration(bucket);
|
||||
}
|
||||
removed || removed_fabricated
|
||||
}
|
||||
@@ -1871,23 +1955,13 @@ impl BucketMetadataSys {
|
||||
"lazy metadata IO must start while the bucket namespace read lock is held"
|
||||
);
|
||||
}
|
||||
let (bm, persisted) = match await_bucket_namespace_operation(
|
||||
let (bm, persisted) = await_bucket_namespace_operation(
|
||||
Some(&guard),
|
||||
bucket,
|
||||
"lazy bucket metadata load",
|
||||
Box::pin(load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
return if *self.initialized.read().await {
|
||||
Err(Error::other("errBucketMetadataNotInitialized"))
|
||||
} else {
|
||||
Err(err)
|
||||
};
|
||||
}
|
||||
};
|
||||
.await?;
|
||||
|
||||
let bm = Arc::new(bm);
|
||||
|
||||
@@ -1898,11 +1972,9 @@ impl BucketMetadataSys {
|
||||
"lazy bucket metadata existence check",
|
||||
Box::pin(async {
|
||||
self.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Into::into)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
@@ -1924,6 +1996,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
sync_on_demand_migration(bucket, &bm);
|
||||
} else {
|
||||
let exists = self
|
||||
.bucket_exists(bucket, &guard, "lazy bucket metadata existence check")
|
||||
@@ -2181,10 +2254,8 @@ impl BucketMetadataSys {
|
||||
"legacy bucket metadata existence check",
|
||||
async {
|
||||
self.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::StorageError::from)
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2264,6 +2335,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &metadata).await;
|
||||
sync_bucket_durability(bucket, &metadata);
|
||||
sync_on_demand_migration(bucket, &metadata);
|
||||
Ok(BucketMetadataAuthority::Authoritative(metadata))
|
||||
}
|
||||
|
||||
@@ -2286,10 +2358,8 @@ impl BucketMetadataSys {
|
||||
"bucket metadata snapshot existence check",
|
||||
async {
|
||||
self.api
|
||||
.peer_sys
|
||||
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
.map_err(crate::error::StorageError::from)
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2458,6 +2528,17 @@ impl BucketMetadataSys {
|
||||
Err(Error::ConfigNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`get_on_demand_migration_config`].
|
||||
pub async fn get_on_demand_migration_config(
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
let config = bm.on_demand_migration_config().map_err(Error::other)?;
|
||||
Ok(config.map(|config| (config, bm.on_demand_migration_config_updated_at)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only fixture shared with sibling modules (e.g. the quota checker
|
||||
@@ -2512,11 +2593,169 @@ pub(crate) mod test_support {
|
||||
mod tests {
|
||||
use super::test_support::isolated_store_over_temp_disks;
|
||||
use super::*;
|
||||
use crate::bucket::metadata::{
|
||||
BUCKET_ACCELERATE_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_LOGGING_CONFIG, BUCKET_NOTIFICATION_CONFIG,
|
||||
BUCKET_POLICY_CONFIG, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_REPLICATION_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG,
|
||||
BUCKET_SSECONFIG, BUCKET_TAGGING_CONFIG, BUCKET_VERSIONING_CONFIG, BUCKET_WEBSITE_CONFIG, OBJECT_LOCK_CONFIG,
|
||||
};
|
||||
use crate::bucket::target::{BucketTarget, BucketTargetType, Credentials};
|
||||
use crate::config::com::read_config;
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
|
||||
use byteorder::{ByteOrder as _, LittleEndian};
|
||||
use serial_test::serial;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const NEW_WRITER_REPLICATION_XML: &[u8] = br#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Role>arn:aws:iam::111122223333:role/replication-role</Role><Rule><ID>rollback</ID><Priority>1</Priority><Filter><Prefix>documents/</Prefix></Filter><Status>Enabled</Status><Destination><Bucket>arn:aws:s3:::replica-bucket</Bucket></Destination><DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication></Rule></ReplicationConfiguration>"#;
|
||||
|
||||
const NEW_WRITER_CONFIGS: [(&str, &[u8]); 14] = [
|
||||
(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#),
|
||||
(BUCKET_NOTIFICATION_CONFIG, br#"<NotificationConfiguration/>"#),
|
||||
(
|
||||
BUCKET_LIFECYCLE_CONFIG,
|
||||
br#"<LifecycleConfiguration><Rule><ID>expire</ID><Status>Enabled</Status><Filter><Prefix>logs/</Prefix></Filter><Expiration><Days>30</Days></Expiration></Rule></LifecycleConfiguration>"#,
|
||||
),
|
||||
(
|
||||
OBJECT_LOCK_CONFIG,
|
||||
br#"<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>7</Days></DefaultRetention></Rule></ObjectLockConfiguration>"#,
|
||||
),
|
||||
(
|
||||
BUCKET_VERSIONING_CONFIG,
|
||||
br#"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"#,
|
||||
),
|
||||
(
|
||||
BUCKET_SSECONFIG,
|
||||
br#"<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>"#,
|
||||
),
|
||||
(
|
||||
BUCKET_TAGGING_CONFIG,
|
||||
r#"<Tagging><TagSet><Tag><Key>environment</Key><Value>测试-🦀</Value></Tag></TagSet></Tagging>"#.as_bytes(),
|
||||
),
|
||||
(BUCKET_REPLICATION_CONFIG, NEW_WRITER_REPLICATION_XML),
|
||||
(
|
||||
BUCKET_CORS_CONFIG,
|
||||
br#"<CORSConfiguration><CORSRule><AllowedMethod>GET</AllowedMethod><AllowedOrigin>https://example.test</AllowedOrigin></CORSRule></CORSConfiguration>"#,
|
||||
),
|
||||
(BUCKET_LOGGING_CONFIG, br#"<BucketLoggingStatus/>"#),
|
||||
(
|
||||
BUCKET_WEBSITE_CONFIG,
|
||||
br#"<WebsiteConfiguration><IndexDocument><Suffix>index.html</Suffix></IndexDocument></WebsiteConfiguration>"#,
|
||||
),
|
||||
(
|
||||
BUCKET_ACCELERATE_CONFIG,
|
||||
br#"<AccelerateConfiguration><Status>Enabled</Status></AccelerateConfiguration>"#,
|
||||
),
|
||||
(
|
||||
BUCKET_REQUEST_PAYMENT_CONFIG,
|
||||
br#"<RequestPaymentConfiguration><Payer>Requester</Payer></RequestPaymentConfiguration>"#,
|
||||
),
|
||||
(
|
||||
BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG,
|
||||
br#"<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls><IgnorePublicAcls>true</IgnorePublicAcls><BlockPublicPolicy>true</BlockPublicPolicy><RestrictPublicBuckets>false</RestrictPublicBuckets></PublicAccessBlockConfiguration>"#,
|
||||
),
|
||||
];
|
||||
|
||||
#[tokio::test]
|
||||
async fn g_d3_003_new_writer_replication_loads_without_fail_closed_state() {
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "rollback-new-replication";
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("rollback fixture bucket should be created");
|
||||
}
|
||||
|
||||
let writer = BucketMetadataSys::new(store.clone());
|
||||
let mut metadata = BucketMetadata::new(bucket);
|
||||
metadata
|
||||
.update_config(BUCKET_REPLICATION_CONFIG, NEW_WRITER_REPLICATION_XML.to_vec())
|
||||
.expect("new-writer replication XML should be accepted before persistence");
|
||||
writer
|
||||
.persist_new_and_set(metadata)
|
||||
.await
|
||||
.expect("new-writer replication metadata should persist");
|
||||
|
||||
let old_reader = BucketMetadataSys::new(store);
|
||||
let (loaded, _) = old_reader
|
||||
.get_replication_config(bucket)
|
||||
.await
|
||||
.expect("old metadata_sys must not classify new-writer replication XML as invalid");
|
||||
assert_eq!(loaded.role, "arn:aws:iam::111122223333:role/replication-role");
|
||||
assert_eq!(loaded.rules.len(), 1);
|
||||
assert_eq!(loaded.rules[0].id.as_deref(), Some("rollback"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn g_d3_004_new_writer_metadata_blob_keeps_legacy_header_and_configs() {
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "rollback-new-metadata";
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("rollback fixture bucket should be created");
|
||||
}
|
||||
|
||||
let writer = BucketMetadataSys::new(store.clone());
|
||||
let mut metadata = BucketMetadata::new(bucket);
|
||||
for (config_file, bytes) in NEW_WRITER_CONFIGS {
|
||||
metadata
|
||||
.update_config(config_file, bytes.to_vec())
|
||||
.unwrap_or_else(|err| panic!("new-writer {config_file} fixture must be valid: {err}"));
|
||||
}
|
||||
writer
|
||||
.persist_new_and_set(metadata)
|
||||
.await
|
||||
.expect("new-writer metadata should persist");
|
||||
|
||||
let path = BucketMetadata::new(bucket).save_file_path();
|
||||
let blob = read_config(store.clone(), &path)
|
||||
.await
|
||||
.expect("persisted .metadata.bin should be readable");
|
||||
assert_eq!(
|
||||
LittleEndian::read_u16(&blob[0..2]),
|
||||
1,
|
||||
"bucket metadata format must stay rollback-readable"
|
||||
);
|
||||
assert_eq!(
|
||||
LittleEndian::read_u16(&blob[2..4]),
|
||||
1,
|
||||
"bucket metadata version must stay rollback-readable"
|
||||
);
|
||||
|
||||
let loaded = load_bucket_metadata(store, bucket)
|
||||
.await
|
||||
.expect("old read_bucket_metadata path must load the new-writer blob");
|
||||
let loaded_configs: [(&str, &[u8]); 14] = [
|
||||
(BUCKET_POLICY_CONFIG, &loaded.policy_config_json),
|
||||
(BUCKET_NOTIFICATION_CONFIG, &loaded.notification_config_xml),
|
||||
(BUCKET_LIFECYCLE_CONFIG, &loaded.lifecycle_config_xml),
|
||||
(OBJECT_LOCK_CONFIG, &loaded.object_lock_config_xml),
|
||||
(BUCKET_VERSIONING_CONFIG, &loaded.versioning_config_xml),
|
||||
(BUCKET_SSECONFIG, &loaded.encryption_config_xml),
|
||||
(BUCKET_TAGGING_CONFIG, &loaded.tagging_config_xml),
|
||||
(BUCKET_REPLICATION_CONFIG, &loaded.replication_config_xml),
|
||||
(BUCKET_CORS_CONFIG, &loaded.cors_config_xml),
|
||||
(BUCKET_LOGGING_CONFIG, &loaded.logging_config_xml),
|
||||
(BUCKET_WEBSITE_CONFIG, &loaded.website_config_xml),
|
||||
(BUCKET_ACCELERATE_CONFIG, &loaded.accelerate_config_xml),
|
||||
(BUCKET_REQUEST_PAYMENT_CONFIG, &loaded.request_payment_config_xml),
|
||||
(BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, &loaded.public_access_block_config_xml),
|
||||
];
|
||||
for ((expected_name, expected), (loaded_name, actual)) in NEW_WRITER_CONFIGS.into_iter().zip(loaded_configs) {
|
||||
assert_eq!(loaded_name, expected_name);
|
||||
assert_eq!(actual, expected, "old read_bucket_metadata changed {expected_name} bytes");
|
||||
}
|
||||
assert!(loaded.policy_config.is_some());
|
||||
assert!(loaded.notification_config.is_some());
|
||||
assert!(loaded.lifecycle_config.is_some());
|
||||
assert!(loaded.object_lock_config.is_some());
|
||||
assert!(loaded.versioning_config.is_some());
|
||||
assert!(loaded.sse_config.is_some());
|
||||
assert!(loaded.tagging_config.is_some());
|
||||
assert!(loaded.replication_config.is_some());
|
||||
assert!(loaded.cors_config.is_some());
|
||||
assert!(loaded.logging_config.is_some());
|
||||
assert!(loaded.website_config.is_some());
|
||||
assert!(loaded.accelerate_config.is_some());
|
||||
assert!(loaded.request_payment_config.is_some());
|
||||
assert!(loaded.public_access_block_config.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_delete_configs_are_not_treated_as_absent() {
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
@@ -3880,6 +4119,151 @@ mod tests {
|
||||
assert_eq!(bucket_durability::lookup(bucket), None);
|
||||
}
|
||||
|
||||
const ODM_JSON: &[u8] = br#"{"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
|
||||
|
||||
/// Every `(bucket, config)` the recording hook has seen. Tests filter by
|
||||
/// their own bucket name; the hook is process-wide and set once.
|
||||
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<(String, Option<OnDemandMigrationConfig>)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn install_recording_odm_hook() {
|
||||
ON_DEMAND_MIGRATION_CONFIG_HOOK.get_or_init(|| {
|
||||
Box::new(|bucket, config| {
|
||||
ODM_HOOK_CALLS.lock().unwrap().push((bucket.to_string(), config.cloned()));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn odm_hook_calls(bucket: &str) -> Vec<Option<OnDemandMigrationConfig>> {
|
||||
ODM_HOOK_CALLS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(name, _)| name == bucket)
|
||||
.map(|(_, config)| config.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: the accessor reports absence as `Ok(None)` and a
|
||||
/// stored payload it cannot parse as a typed error, never as a default
|
||||
/// and never as `ConfigNotFound`.
|
||||
#[tokio::test]
|
||||
async fn get_on_demand_migration_config_distinguishes_absent_from_corrupt() {
|
||||
use crate::bucket::on_demand_migration::OnDemandMigrationConfigError;
|
||||
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
let bucket = "odm-accessor";
|
||||
|
||||
sys.set(bucket.to_string(), Arc::new(BucketMetadata::new(bucket))).await;
|
||||
assert_eq!(sys.get_on_demand_migration_config(bucket).await.unwrap(), None);
|
||||
|
||||
let mut corrupt = BucketMetadata::new(bucket);
|
||||
corrupt.on_demand_migration_config_json = br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec();
|
||||
sys.set(bucket.to_string(), Arc::new(corrupt)).await;
|
||||
let err = sys
|
||||
.get_on_demand_migration_config(bucket)
|
||||
.await
|
||||
.expect_err("corrupt config must not read as a default");
|
||||
assert_ne!(err, Error::ConfigNotFound, "corruption must not be reported as absence");
|
||||
let typed = match &err {
|
||||
Error::Io(io) => io
|
||||
.get_ref()
|
||||
.and_then(|source| source.downcast_ref::<OnDemandMigrationConfigError>()),
|
||||
_ => None,
|
||||
};
|
||||
assert!(
|
||||
matches!(typed, Some(OnDemandMigrationConfigError::Malformed(_))),
|
||||
"typed parse error must survive the Result boundary, got: {err:?}"
|
||||
);
|
||||
|
||||
let mut valid = BucketMetadata::new(bucket);
|
||||
valid
|
||||
.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.unwrap();
|
||||
let stamped = valid.on_demand_migration_config_updated_at;
|
||||
sys.set(bucket.to_string(), Arc::new(valid)).await;
|
||||
let (config, updated_at) = sys
|
||||
.get_on_demand_migration_config(bucket)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("stored config is returned");
|
||||
assert_eq!(config, OnDemandMigrationConfig::from_json(ODM_JSON).unwrap());
|
||||
assert_eq!(updated_at, stamped);
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2148: the publish hook fires on every path that
|
||||
/// installs bucket metadata into the cache (set, initial load, peer
|
||||
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring
|
||||
/// `sync_bucket_durability`.
|
||||
#[tokio::test]
|
||||
async fn on_demand_migration_hook_fires_on_every_cache_install_path() {
|
||||
install_recording_odm_hook();
|
||||
|
||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let bucket = "odm-hook-paths";
|
||||
for dir in &dirs {
|
||||
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
|
||||
}
|
||||
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
|
||||
let expect_publish = |before: usize, label: &str| {
|
||||
let calls = odm_hook_calls(bucket);
|
||||
assert_eq!(calls.len(), before + 1, "{label} must publish exactly once");
|
||||
assert_eq!(calls.last().unwrap().as_ref(), Some(&expected), "{label} must publish the stored config");
|
||||
};
|
||||
|
||||
// set (via persist_new_and_set, which installs through `set`).
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
|
||||
.unwrap();
|
||||
let writer = BucketMetadataSys::new(ecstore.clone());
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
writer.persist_new_and_set(bm).await.expect("metadata should persist");
|
||||
expect_publish(before, "set");
|
||||
|
||||
// init (initial load on a cold system).
|
||||
let mut cold = BucketMetadataSys::new(ecstore.clone());
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
cold.init(vec![bucket.to_string()]).await;
|
||||
assert!(cold.get(bucket).await.is_ok(), "initial load must cache the bucket");
|
||||
expect_publish(before, "init");
|
||||
|
||||
// peer reload.
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
cold.reload_from_store(bucket).await.expect("peer reload should publish");
|
||||
expect_publish(before, "peer reload");
|
||||
|
||||
// refresh loop.
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
let mut failed = HashSet::new();
|
||||
cold.concurrent_load(&[bucket.to_string()], &mut failed, MetadataLoadMode::Refresh)
|
||||
.await;
|
||||
assert!(failed.is_empty(), "refresh must succeed");
|
||||
expect_publish(before, "refresh loop");
|
||||
|
||||
// lazy load on another cold system.
|
||||
let lazy = BucketMetadataSys::new(ecstore);
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
let (_, loaded) = lazy.get_config(bucket).await.expect("lazy load should publish");
|
||||
assert!(loaded, "the lazy path must have gone to disk");
|
||||
expect_publish(before, "lazy load");
|
||||
|
||||
// Removal withdraws the config.
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
assert!(lazy.remove(bucket).await);
|
||||
let calls = odm_hook_calls(bucket);
|
||||
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
|
||||
assert_eq!(calls.last().unwrap(), &None);
|
||||
|
||||
// A corrupt payload is withdrawn, never published as a config.
|
||||
let mut corrupt = BucketMetadata::new(bucket);
|
||||
corrupt.on_demand_migration_config_json = b"not-json".to_vec();
|
||||
let before = odm_hook_calls(bucket).len();
|
||||
lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
|
||||
let calls = odm_hook_calls(bucket);
|
||||
assert_eq!(calls.len(), before + 1);
|
||||
assert_eq!(calls.last().unwrap(), &None, "unreadable config must publish absence");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_wait_exits_when_cancelled() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
@@ -26,9 +26,12 @@ mod metadata_test;
|
||||
pub mod migration;
|
||||
mod msgp_decode;
|
||||
pub mod object_lock;
|
||||
pub mod on_demand_migration;
|
||||
pub mod policy_sys;
|
||||
pub mod quota;
|
||||
pub mod remote_s3_client;
|
||||
pub mod replication;
|
||||
pub mod sealed_credentials;
|
||||
pub mod tagging;
|
||||
pub mod target;
|
||||
pub mod utils;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user