mirror of
https://github.com/rustfs/rustfs.git
synced 2026-11-13 05:23:17 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4533e6698 | |||
| 7badcd501d | |||
| 9d513cffe9 | |||
| c5afd3738c | |||
| 6b44869b4f |
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=8fef326a3fddd8a548e2cbcc6ea8159f831113595fb003eeebbd9aeb4ac23e48
|
||||
sha256-linux=8b4381578377704ac8b1b5156128b83f036ec4aedc9d863fff25df15112420fc
|
||||
sha256-darwin=0374e6b9b061dd5d622686301386232a4aa1cb22eedc18af70d2a7411bc66f8d
|
||||
sha256-linux=f2cee240f7a744ae991971820f48a6c9c28d19f525e8b2345fcb76eeaaf1e382
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=dcc076f8a36320d2fe36b4d2f26e2d54c3b9bc2ff7e14c6e90241f4fd8cf4a33
|
||||
sha256-linux=229ebc0cbc9b35863600ae60c6fbb548516efe2a2f41f4b5db503b3283c2f64f
|
||||
sha256-darwin=a2cb67d91eb0d093928371d6e84a84b2dd41b2ba975a28094e81879bd65838ad
|
||||
sha256-linux=82db0561cf9b111f11782d647d0859c9d2a44c5c68481b5e39c48505828bd392
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=061d0a697967d2dc8f472ff885886e069b1cf4aeaa5f230e177b7236017c23b6
|
||||
sha256=21e9a1561ac20382503c38353bbb9b830658534a4f277c254a42d0afc6ebed8b
|
||||
|
||||
@@ -70,26 +70,11 @@ s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
|
||||
@echo "📦 Checking s3s footprint ratchet..."
|
||||
./scripts/check_s3s_footprint.sh
|
||||
|
||||
.PHONY: release-feature-boundary-check
|
||||
release-feature-boundary-check: ## Check no release-reachable feature enables a debug-only one
|
||||
@echo "🚧 Checking release feature boundaries..."
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_connect_release_boundary.py --features-only
|
||||
|
||||
.PHONY: test-count-ratchet-check
|
||||
test-count-ratchet-check: ## Check no workspace crate fell below its test floor or rose above its #[ignore] ceiling in the committed baseline
|
||||
@echo "🧪 Checking per-crate test-count ratchet..."
|
||||
./scripts/check_test_count_ratchet.sh
|
||||
|
||||
.PHONY: test-seams-check
|
||||
test-seams-check: ## Check tests outside an operation's owner call its use-case only through rustfs/src/app/test_seams
|
||||
@echo "🧪 Checking cross-owner test seams..."
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_seams.py
|
||||
|
||||
.PHONY: test-retirements-check
|
||||
test-retirements-check: ## Check every test retired by a migration slice names a replacement that exists
|
||||
@echo "🧪 Checking test retirement tables..."
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_retirements.py
|
||||
|
||||
.PHONY: fips-wording-check
|
||||
fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabilities
|
||||
@echo "📣 Checking cryptographic capability wording guard..."
|
||||
|
||||
@@ -20,11 +20,11 @@ planning-docs-check: ## Check that no planning-type documents are committed
|
||||
./scripts/check_no_planning_docs.sh
|
||||
|
||||
.PHONY: pre-commit
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check release-feature-boundary-check test-count-ratchet-check test-seams-check test-retirements-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
@echo "✅ All pre-commit checks passed!"
|
||||
|
||||
.PHONY: pre-pr
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check release-feature-boundary-check test-count-ratchet-check test-seams-check test-retirements-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
@echo "✅ All pre-PR checks passed!"
|
||||
|
||||
.PHONY: dev-check
|
||||
|
||||
+1
-14
@@ -38,7 +38,6 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_entrypoint_credentials.sh
|
||||
./scripts/test_entrypoint_directories.sh
|
||||
./scripts/test_internode_grpc_ab_bench.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_internode_grpc_window.py
|
||||
./scripts/test_object_batch_bench_enhanced.sh
|
||||
./scripts/test_package_service_scripts.sh
|
||||
./scripts/test_hotpath_warp_ab_gate.sh
|
||||
@@ -63,18 +62,10 @@ script-tests: ## Run shell script tests
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
./scripts/check_s3s_footprint.sh --self-test
|
||||
./scripts/test_check_test_count_ratchet.sh
|
||||
./scripts/test_guard_source_set.sh
|
||||
./scripts/test_split_guard_rules.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_bucket_usecase.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_ecfs_legacy_edge.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/split_lifecycle_transition_api_test.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_seams.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_retirements.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_download_release_asset.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_reference_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_inspect_btf_prototypes.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_release_build_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) -m unittest discover -s scripts/release -p "test_*.py"
|
||||
@@ -84,7 +75,7 @@ script-tests: ## Run shell script tests
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_connect_release_boundary.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_test_artifact.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_merge_group_proof.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_test_shards.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_merge_queue.py
|
||||
$(RUSTFS_PYTHON_BIN) -m unittest discover -s scripts/ci -p 'test_*.py'
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||
@@ -99,12 +90,8 @@ script-tests: ## Run shell script tests
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_retain_validation_evidence.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_ci_timing_report.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_junit_diff.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_dual_stack.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_mint_stack_diff.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_runner_tools.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_api_coverage.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_reference_parity.py
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||
|
||||
@@ -137,7 +137,7 @@ filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::complete::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))$/))'
|
||||
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'
|
||||
|
||||
# These handler/store fixtures and the relocated-pool GET compose deep storage
|
||||
@@ -455,7 +455,7 @@ filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::complete::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))$/))'
|
||||
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]]
|
||||
@@ -946,10 +946,6 @@ path = "junit.xml"
|
||||
# parallel-safe — the same property e2e-smoke relies on. The exceptions are the
|
||||
# 4-disk reliability / degraded-read fault-injection tests, multi-node heal
|
||||
# clusters, and the fixed-port Vault tests, all serialized below.
|
||||
# `dual_stack_differential::reference::` holds hermetic unit tests of how the
|
||||
# differential picks side A (shell-script stand-ins, no server, no reference
|
||||
# binary). They already run on every pull request in e2e-smoke, so e2e-full
|
||||
# excludes them to keep its selection digest unchanged.
|
||||
[profile.e2e-full]
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
@@ -960,7 +956,6 @@ default-filter = """
|
||||
& !test(/^replication_target_matrix_test::/)
|
||||
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
||||
& !test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)
|
||||
& !test(/^dual_stack_differential::reference::/)
|
||||
"""
|
||||
fail-fast = false
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Pinned RustFS release used as the "reference" target of parity lanes
|
||||
# (.github/workflows/reference-parity.yml). This is the single place that names
|
||||
# the reference release; .github/actions/reference-binary resolves, downloads and
|
||||
# verifies it, and fails on a missing pin, a missing asset or a digest mismatch.
|
||||
#
|
||||
# Bumping the reference is a reviewed event: every per-test difference between
|
||||
# the candidate and the reference is measured against this exact build.
|
||||
#
|
||||
# Each sha256 is the value published in the release's SHA256SUMS asset, which
|
||||
# matches the digest GitHub reports for the same asset. The action checks both.
|
||||
|
||||
schema = 1
|
||||
repository = "rustfs/rustfs"
|
||||
version = "1.0.5"
|
||||
tag = "1.0.5"
|
||||
|
||||
[platforms.linux-x86_64-gnu]
|
||||
asset = "rustfs-linux-x86_64-gnu-v1.0.5.zip"
|
||||
sha256 = "5addae2f74756bf80104d0975e6347cda843bc2daffdfcd1f0cb80118b95cbd0"
|
||||
executable = "rustfs"
|
||||
@@ -1,24 +0,0 @@
|
||||
# Targets of the s3-tests reference parity lane (.github/workflows/reference-parity.yml).
|
||||
#
|
||||
# Exactly one target uses `binary = "reference"` (the release pinned in
|
||||
# .config/reference-binary.toml). Every other target uses `binary = "candidate"`
|
||||
# (the binary built from the branch under test) and is compared per node id
|
||||
# against the reference. All targets run the same harness, test list and server
|
||||
# configuration; `env` adds only the target's own RUSTFS_* settings.
|
||||
#
|
||||
# Adding a target is one more [[targets]] table, for example:
|
||||
#
|
||||
# [[targets]]
|
||||
# name = "candidate-gateway"
|
||||
# binary = "candidate"
|
||||
# env = { RUSTFS_S3_STACK = "gateway" }
|
||||
|
||||
schema = 1
|
||||
|
||||
[[targets]]
|
||||
name = "reference"
|
||||
binary = "reference"
|
||||
|
||||
[[targets]]
|
||||
name = "candidate-legacy"
|
||||
binary = "candidate"
|
||||
@@ -1,12 +0,0 @@
|
||||
# Tolerated per-test differences of the s3-tests reference parity lane.
|
||||
#
|
||||
# One entry per line, four fields separated by " | ":
|
||||
#
|
||||
# <target> | <pytest node id> | <tracking issue URL> | <reason>
|
||||
#
|
||||
# <target> is a candidate name from .config/reference-parity-targets.toml, the
|
||||
# node id is exactly as the lane prints it (s3tests/functional/test_s3.py::...),
|
||||
# and the issue URL is https://github.com/rustfs/<repo>/issues/<number>.
|
||||
#
|
||||
# Same semantics as scripts/junit_diff.py --allow: an entry whose difference no
|
||||
# longer occurs is stale and fails the lane, so remove it when it is fixed.
|
||||
@@ -1,18 +1,17 @@
|
||||
# Baseline for the direct s3s footprint, consumed only by
|
||||
# scripts/check_s3s_footprint.sh (rustfs/backlog#1677; rustfs/backlog#2734 T0.4;
|
||||
# comparison rules on rustfs/backlog#2765).
|
||||
# Exact baseline for the direct s3s footprint, consumed only by
|
||||
# scripts/check_s3s_footprint.sh (rustfs/backlog#1677; rustfs/backlog#2734 T0.4).
|
||||
#
|
||||
# Each line is a ceiling everywhere: a counter above it fails, because new code
|
||||
# widened the s3s surface; route it through the gateway abstractions instead and
|
||||
# never raise a value here. Pull requests and merge groups are additionally held
|
||||
# to their base revision's measurement, so a slice that shrinks the surface need
|
||||
# not edit this file. A counter below its line passes and the check prints the
|
||||
# lowered line; the migration coordinator lowers the lines in batches.
|
||||
# Each value is the exact measurement of the tree, not a ceiling. CI fails when a
|
||||
# measured counter differs from its line here in either direction:
|
||||
# - a larger measurement means new code widened the s3s surface: route it
|
||||
# through the gateway abstractions instead; never raise a value here;
|
||||
# - a smaller measurement means the PR shrank the surface: lower the matching
|
||||
# value to the measured number in the same PR.
|
||||
#
|
||||
# files Rust files referencing s3s paths, repo-wide minus crates/e2e_test
|
||||
# s3_error_lines lines holding s3_error!, same scope, minus those the script
|
||||
# header's rule clears (rustfs_s3_types's macro); fails closed
|
||||
# ecstore_files Rust files referencing s3s paths under crates/ecstore/src
|
||||
files=63
|
||||
s3_error_lines=209
|
||||
files=65
|
||||
s3_error_lines=219
|
||||
ecstore_files=0
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# Regenerate with:
|
||||
# scripts/check_test_count_ratchet.sh --update
|
||||
e2e_test 1026 20
|
||||
rustfs 6449 64
|
||||
rustfs 6440 64
|
||||
rustfs-audit 70 0
|
||||
rustfs-checksums 28 1
|
||||
rustfs-common 36 0
|
||||
@@ -25,7 +25,6 @@ rustfs-data-usage 58 0
|
||||
rustfs-ecstore 7858 2
|
||||
rustfs-extension-schema 23 0
|
||||
rustfs-filemeta 361 0
|
||||
rustfs-gateway-adapter 74 0
|
||||
rustfs-heal 1128 3
|
||||
rustfs-heal-contracts 8 0
|
||||
rustfs-iam 415 0
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the bucket_access slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the bucket_configs slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the bucket_core slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the bucket_replication_lifecycle slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the cutover slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the listing_multipart slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the object_commit slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the object_meta slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the object_misc slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the object_read slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the object_write slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the t211 slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tests the t212 slice retires (rustfs/backlog#2765); format and checks: scripts/check_test_retirements.py.
|
||||
# <crate> | <module path>::<test fn> | <kind: test, differential or e2e> | <replacement> | <reason>
|
||||
# The retired test must still exist when its entry is added; the change that deletes it deletes the entry.
|
||||
@@ -140,12 +140,6 @@ With list syntax, use `- RUSTFS_OBS_LOG_DIRECTORY=/var/log/rustfs/` or quote the
|
||||
|
||||
---
|
||||
|
||||
## Local storage pool expansion
|
||||
|
||||
To append a second four-drive pool, set `RUSTFS_VOLUMES: "/data/rustfs0/{0...3} /data/rustfs1/{0...3}"` and keep the original pool's drives, order, and erasure set width. The container prepares each drive directory and passes one argument per pool to RustFS.
|
||||
|
||||
If an older image already formatted both ranges together as a single eight-drive pool, list the eight individual paths in their original order to keep that layout when upgrading.
|
||||
|
||||
## 🔧 Common Operations
|
||||
|
||||
### Cleaning Up
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Both-stack leg
|
||||
description: Prove which S3 stack one e2e leg's servers selected and upload the leg for the summary (call with if always)
|
||||
|
||||
inputs:
|
||||
profile:
|
||||
description: The nextest profile the leg ran
|
||||
required: true
|
||||
stack:
|
||||
description: The stack the leg requested, legacy or gateway
|
||||
required: true
|
||||
logs:
|
||||
description: Directory or file holding the servers' output
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Prove which stack the servers selected
|
||||
shell: bash
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile }}
|
||||
STACK: ${{ inputs.stack }}
|
||||
LOGS: ${{ inputs.logs }}
|
||||
run: |
|
||||
# Outside the workspace: e2e_binary.py refuses a run whose source tree changed.
|
||||
mkdir -p "$RUNNER_TEMP/leg"
|
||||
junit="target/nextest/${PROFILE}/junit.xml"
|
||||
if [ -s "$junit" ]; then cp "$junit" "$RUNNER_TEMP/leg/junit.xml"; fi
|
||||
# A gateway leg stopped by its preflight has no output; the preflight's stack.json stays.
|
||||
if [ -e "$LOGS" ]; then python3 scripts/dual_stack.py stack --expect "$STACK" --out "$RUNNER_TEMP/leg/stack.json" "$LOGS"; fi
|
||||
|
||||
- name: Upload the leg
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dual-stack-leg-${{ inputs.profile }}-${{ inputs.stack }}
|
||||
overwrite: true
|
||||
path: ${{ runner.temp }}/leg
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -1,56 +0,0 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Both-stack summary
|
||||
description: Diff the legacy and gateway legs of this run (scripts/junit_diff.py) and upload one dated summary
|
||||
|
||||
inputs:
|
||||
profiles:
|
||||
description: Comma-separated nextest profiles whose two legs this run uploaded
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download the legs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: dual-stack-leg-*
|
||||
path: legs
|
||||
|
||||
- name: Diff every profile and write the summary
|
||||
id: summary
|
||||
shell: bash
|
||||
env:
|
||||
PROFILES: ${{ inputs.profiles }}
|
||||
run: |
|
||||
# The shell runs with -e; the exit status is reported after the summary is written.
|
||||
set +e -u -o pipefail
|
||||
date="$(date -u +%F)"
|
||||
echo "date=${date}" >> "$GITHUB_OUTPUT"
|
||||
mkdir -p legs
|
||||
python3 scripts/dual_stack.py summary --profiles "$PROFILES" --legs legs --date "$date" --out-dir dual-stack
|
||||
status=$?
|
||||
if [ -f dual-stack/summary.md ]; then cat dual-stack/summary.md >> "$GITHUB_STEP_SUMMARY"; fi
|
||||
exit "$status"
|
||||
|
||||
- name: Upload the dated summary
|
||||
if: always() && steps.summary.outputs.date != ''
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dual-stack-summary-${{ steps.summary.outputs.date }}
|
||||
overwrite: true
|
||||
path: dual-stack
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -95,34 +95,12 @@ runs:
|
||||
|
||||
- name: Check s3s footprint ratchet
|
||||
shell: bash
|
||||
env:
|
||||
# Pull requests and merge groups compare with their base revision; a
|
||||
# push or scheduled run holds the tree to the baseline file exactly.
|
||||
FOOTPRINT_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
run: |
|
||||
if [[ -z "$FOOTPRINT_BASE" ]]; then
|
||||
./scripts/check_s3s_footprint.sh
|
||||
exit
|
||||
fi
|
||||
git cat-file -e "$FOOTPRINT_BASE^{commit}" 2>/dev/null || git fetch --no-tags --depth=1 origin "$FOOTPRINT_BASE"
|
||||
./scripts/check_s3s_footprint.sh --base "$FOOTPRINT_BASE"
|
||||
|
||||
- name: Check release feature boundaries
|
||||
shell: bash
|
||||
run: python3 scripts/check_connect_release_boundary.py --features-only
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check per-crate test-count ratchet
|
||||
shell: bash
|
||||
run: ./scripts/check_test_count_ratchet.sh
|
||||
|
||||
- name: Check cross-owner tests use test seams
|
||||
shell: bash
|
||||
run: python3 ./scripts/check_test_seams.py
|
||||
|
||||
- name: Check retired tests name existing replacements
|
||||
shell: bash
|
||||
run: python3 ./scripts/check_test_retirements.py
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
shell: bash
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: "Reference RustFS binary"
|
||||
description: "Download the pinned reference RustFS release, verify its sha256 and expose the executable path. A missing pin, missing asset or digest mismatch fails the step."
|
||||
|
||||
inputs:
|
||||
pin-file:
|
||||
description: "Pin file naming the reference release (repository-relative)"
|
||||
required: false
|
||||
default: ".config/reference-binary.toml"
|
||||
platform:
|
||||
description: "Platform table in the pin file"
|
||||
required: false
|
||||
default: "linux-x86_64-gnu"
|
||||
destination:
|
||||
description: "Directory that receives the executable; defaults to $RUNNER_TEMP/reference-binary"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
path:
|
||||
description: "Absolute path of the verified executable"
|
||||
value: ${{ steps.unpack.outputs.path }}
|
||||
version:
|
||||
description: "Pinned reference version"
|
||||
value: ${{ steps.pin.outputs.version }}
|
||||
sha256:
|
||||
description: "Pinned and verified archive sha256"
|
||||
value: ${{ steps.pin.outputs.sha256 }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Resolve reference pin
|
||||
id: pin
|
||||
shell: bash
|
||||
env:
|
||||
PIN_TOOL: ${{ github.action_path }}/reference_binary.py
|
||||
PIN_FILE: ${{ inputs.pin-file }}
|
||||
PIN_PLATFORM: ${{ inputs.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
resolved="$(python3 "$PIN_TOOL" resolve --pin "$PIN_FILE" --platform "$PIN_PLATFORM")"
|
||||
printf '%s\n' "$resolved"
|
||||
printf '%s\n' "$resolved" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download and verify reference archive
|
||||
id: download
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ASSET_DOWNLOADER: ${{ github.action_path }}/../setup/download_release_asset.py
|
||||
REF_REPOSITORY: ${{ steps.pin.outputs.repository }}
|
||||
REF_TAG: ${{ steps.pin.outputs.tag }}
|
||||
REF_ASSET: ${{ steps.pin.outputs.asset }}
|
||||
REF_SHA256: ${{ steps.pin.outputs.sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="$RUNNER_TEMP/reference-archive/$REF_ASSET"
|
||||
# Fails unless the release lists exactly one asset of this name whose
|
||||
# published digest equals the pin and whose downloaded bytes match it.
|
||||
python3 "$ASSET_DOWNLOADER" --repository "$REF_REPOSITORY" --tag "$REF_TAG" \
|
||||
--asset "$REF_ASSET" --output "$archive" --sha256 "$REF_SHA256"
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Unpack reference executable
|
||||
id: unpack
|
||||
shell: bash
|
||||
env:
|
||||
PIN_TOOL: ${{ github.action_path }}/reference_binary.py
|
||||
REF_ARCHIVE: ${{ steps.download.outputs.archive }}
|
||||
REF_SHA256: ${{ steps.pin.outputs.sha256 }}
|
||||
REF_EXECUTABLE: ${{ steps.pin.outputs.executable }}
|
||||
REF_DESTINATION: ${{ inputs.destination }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
destination="${REF_DESTINATION:-$RUNNER_TEMP/reference-binary}"
|
||||
unpacked="$(python3 "$PIN_TOOL" unpack --archive "$REF_ARCHIVE" --sha256 "$REF_SHA256" \
|
||||
--executable "$REF_EXECUTABLE" --destination "$destination")"
|
||||
path="${unpacked#path=}"
|
||||
test -x "$path"
|
||||
echo "Reference executable: $path"
|
||||
"$path" --version
|
||||
echo "path=$path" >> "$GITHUB_OUTPUT"
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""Resolve and unpack the pinned reference RustFS release binary.
|
||||
|
||||
`resolve` reads the pin file and prints the selected platform's identity as
|
||||
`key=value` lines (GitHub step-output format). `unpack` re-verifies the
|
||||
downloaded archive digest against the pin and extracts exactly one executable.
|
||||
Every missing or malformed input is a failure; nothing is skipped.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import zipfile
|
||||
|
||||
REPOSITORY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*")
|
||||
TAG = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.+-]*")
|
||||
PLATFORM = re.compile(r"[a-z0-9][a-z0-9_-]*")
|
||||
ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.+-]*\.zip")
|
||||
SHA256 = re.compile(r"[0-9a-f]{64}")
|
||||
EXECUTABLE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*")
|
||||
MAX_EXECUTABLE_BYTES = 1024 * 1024 * 1024
|
||||
|
||||
|
||||
class PinError(Exception):
|
||||
"""A pin, archive or argument that cannot be trusted."""
|
||||
|
||||
|
||||
def field(table, key, pattern, where):
|
||||
value = table.get(key)
|
||||
if not isinstance(value, str) or not pattern.fullmatch(value):
|
||||
raise PinError(f"{where}: missing or invalid '{key}'")
|
||||
return value
|
||||
|
||||
|
||||
def resolve(pin_path, platform):
|
||||
if not PLATFORM.fullmatch(platform or ""):
|
||||
raise PinError("invalid platform name")
|
||||
try:
|
||||
data = tomllib.loads(Path(pin_path).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise PinError(f"{pin_path}: reference pin file is missing") from exc
|
||||
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
|
||||
raise PinError(f"{pin_path}: cannot read reference pin: {exc}") from exc
|
||||
if data.get("schema") != 1 or type(data.get("schema")) is not int:
|
||||
raise PinError(f"{pin_path}: unsupported pin schema")
|
||||
identity = {
|
||||
"repository": field(data, "repository", REPOSITORY, pin_path),
|
||||
"version": field(data, "version", TAG, pin_path),
|
||||
"tag": field(data, "tag", TAG, pin_path),
|
||||
}
|
||||
platforms = data.get("platforms")
|
||||
if not isinstance(platforms, dict) or not platforms:
|
||||
raise PinError(f"{pin_path}: no pinned platforms")
|
||||
entry = platforms.get(platform)
|
||||
if not isinstance(entry, dict):
|
||||
raise PinError(f"{pin_path}: no pin for platform '{platform}'")
|
||||
where = f"{pin_path} [platforms.{platform}]"
|
||||
identity["asset"] = field(entry, "asset", ASSET, where)
|
||||
identity["sha256"] = field(entry, "sha256", SHA256, where)
|
||||
identity["executable"] = field(entry, "executable", EXECUTABLE, where)
|
||||
if identity["version"] not in identity["asset"]:
|
||||
raise PinError(f"{where}: asset name does not carry version {identity['version']}")
|
||||
unknown = set(entry) - {"asset", "sha256", "executable"}
|
||||
if unknown:
|
||||
raise PinError(f"{where}: unknown keys {sorted(unknown)}")
|
||||
return identity
|
||||
|
||||
|
||||
def file_sha256(path):
|
||||
with open(path, "rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def unpack(archive, sha256, executable, destination):
|
||||
if not SHA256.fullmatch(sha256 or "") or not EXECUTABLE.fullmatch(executable or ""):
|
||||
raise PinError("invalid expected digest or executable name")
|
||||
try:
|
||||
actual = file_sha256(archive)
|
||||
except OSError as exc:
|
||||
raise PinError(f"{archive}: reference archive is missing or unreadable") from exc
|
||||
if actual != sha256:
|
||||
raise PinError(f"{archive}: sha256 {actual} does not match the pinned digest")
|
||||
try:
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
matches = [info for info in bundle.infolist() if info.filename == executable]
|
||||
if len(matches) != 1:
|
||||
raise PinError(f"{archive}: must contain exactly one top-level '{executable}'")
|
||||
info = matches[0]
|
||||
if info.is_dir() or not 0 < info.file_size <= MAX_EXECUTABLE_BYTES:
|
||||
raise PinError(f"{archive}: '{executable}' is not a plausible executable")
|
||||
destination = Path(destination)
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
target = destination / executable
|
||||
with bundle.open(info) as source, open(target, "wb") as sink:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
sink.write(chunk)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise PinError(f"{archive}: not a valid zip archive") from exc
|
||||
target.chmod(0o755)
|
||||
return target
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = parser.add_subparsers(dest="mode", required=True)
|
||||
res = sub.add_parser("resolve")
|
||||
res.add_argument("--pin", required=True)
|
||||
res.add_argument("--platform", required=True)
|
||||
unp = sub.add_parser("unpack")
|
||||
unp.add_argument("--archive", required=True)
|
||||
unp.add_argument("--sha256", required=True)
|
||||
unp.add_argument("--executable", required=True)
|
||||
unp.add_argument("--destination", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.mode == "resolve":
|
||||
for key, value in resolve(args.pin, args.platform).items():
|
||||
print(f"{key}={value}")
|
||||
else:
|
||||
print(f"path={unpack(args.archive, args.sha256, args.executable, args.destination)}")
|
||||
except PinError as exc:
|
||||
print(f"reference binary: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -63,11 +63,6 @@ inputs:
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: "'true' when the Rust cache was restored from an exact key match"
|
||||
value: ${{ steps.rust-cache.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -148,7 +143,6 @@ runs:
|
||||
tool: cargo-nextest@0.9.138
|
||||
|
||||
- name: Setup Rust cache
|
||||
id: rust-cache
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
# false is rust-cache's own default. With true, cleanup.ts returns
|
||||
|
||||
@@ -360,15 +360,14 @@
|
||||
"cadence": "nightly",
|
||||
"timezone": "UTC",
|
||||
"schedule": [
|
||||
"13 1 * * *",
|
||||
"17 16 * * 0"
|
||||
"13 1 * * *"
|
||||
],
|
||||
"owner": "@overtrue",
|
||||
"policy": "active",
|
||||
"monitor": "workflow",
|
||||
"max_age_hours": 36,
|
||||
"result_owner": "build.yml",
|
||||
"disposition": "Daily cron builds Linux; Sunday 16:17 UTC builds macOS and Windows. Public release writes belong to release-publish.yml.",
|
||||
"disposition": "Builds artifacts only; public release writes belong to release-publish.yml.",
|
||||
"failure_owner": "workflow",
|
||||
"origin": "baseline",
|
||||
"alert_contract": "scheduled-validation"
|
||||
@@ -765,31 +764,6 @@
|
||||
"monitor_reason": "Event-driven or operator-requested work has no periodic freshness obligation.",
|
||||
"origin": "baseline"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/dual-stack-nightly.yml",
|
||||
"name": "dual-stack-nightly",
|
||||
"roles": [
|
||||
"periodic",
|
||||
"manual"
|
||||
],
|
||||
"events": [
|
||||
"workflow_dispatch",
|
||||
"schedule"
|
||||
],
|
||||
"cadence": "nightly",
|
||||
"timezone": "UTC",
|
||||
"schedule": [
|
||||
"13 3 * * *"
|
||||
],
|
||||
"owner": "@overtrue",
|
||||
"policy": "active",
|
||||
"monitor": "workflow",
|
||||
"max_age_hours": 36,
|
||||
"result_owner": "dual-stack-nightly.yml",
|
||||
"disposition": "both-stack JUnit diff of the two profiles only ci.yml runs; the gateway legs are report-only until T2.10",
|
||||
"failure_owner": "watchdog",
|
||||
"origin": "added"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/e2e-distributed.yml",
|
||||
"name": "e2e-distributed",
|
||||
@@ -895,33 +869,6 @@
|
||||
"failure_owner": "watchdog",
|
||||
"origin": "baseline"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/reference-parity.yml",
|
||||
"name": "Reference Parity",
|
||||
"roles": [
|
||||
"pr",
|
||||
"periodic",
|
||||
"manual"
|
||||
],
|
||||
"events": [
|
||||
"pull_request",
|
||||
"schedule",
|
||||
"workflow_dispatch"
|
||||
],
|
||||
"cadence": "nightly",
|
||||
"timezone": "UTC",
|
||||
"schedule": [
|
||||
"37 1 * * *"
|
||||
],
|
||||
"owner": "@overtrue",
|
||||
"policy": "active",
|
||||
"monitor": "workflow",
|
||||
"max_age_hours": 36,
|
||||
"result_owner": "reference-parity.yml",
|
||||
"disposition": "s3-tests per-node-id parity of branch candidates against the pinned reference release",
|
||||
"failure_owner": "watchdog",
|
||||
"origin": "added"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/functional-chain-health.yml",
|
||||
"name": "Functional chain health",
|
||||
@@ -1047,26 +994,6 @@
|
||||
"policy_reason": "Preserve operator-disabled state; enabling requires a separate policy decision.",
|
||||
"origin": "baseline"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/manual-main-packages.yml",
|
||||
"name": "Manual Linux packages",
|
||||
"roles": [
|
||||
"manual"
|
||||
],
|
||||
"events": [
|
||||
"workflow_dispatch"
|
||||
],
|
||||
"cadence": "event",
|
||||
"timezone": "UTC",
|
||||
"schedule": [],
|
||||
"owner": "@overtrue",
|
||||
"policy": "active",
|
||||
"monitor": "none",
|
||||
"result_owner": "manual-main-packages.yml",
|
||||
"disposition": "Packages a git tag, or the origin/main tip when the ref input is empty, as workflow artifacts only. No GitHub Release or download-channel writes.",
|
||||
"monitor_reason": "Operator-requested packaging has no periodic freshness obligation.",
|
||||
"origin": "manual-main-packages"
|
||||
},
|
||||
{
|
||||
"workflow": ".github/workflows/minio-interop.yml",
|
||||
"name": "minio-interop",
|
||||
|
||||
@@ -44,7 +44,6 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
|
||||
`Swatinem/rust-cache` builds its key from the OS, architecture, toolchain and lockfile. It does not include the runner label or image, so a cache written on an `sm-standard-4` pod and one written on `ubuntu-latest` would overwrite each other under the same `shared-key`.
|
||||
|
||||
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
|
||||
- The `ci-dev` key ends in a digest of `warm-ci-dev`'s build commands. Changing what that job warms therefore needs a new key, or readers keep restoring the old artifacts: `scripts/ci_gate.py --check-workflow` prints the key the commands require and fails until the writer and every reader in `ci.yml` and `reference-parity.yml` name it.
|
||||
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
|
||||
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build. The `build.yml` aarch64 release legs append `-ubicloud-arm` for this reason. `cache-warm.yml` does not write those keys.
|
||||
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
|
||||
@@ -54,7 +53,7 @@ A hosted runner is also the only choice for a few things the pods cannot do: `mo
|
||||
These are the patterns that have cost real money here without validating anything more:
|
||||
|
||||
- An `if: always()` summary or alert job on a self-hosted label inside a `workflow_run`-triggered workflow. The upstream workflow completes many times a day, the real jobs skip, and the summary still starts a pod each time. Gate it on the upstream job having run, and keep it on `ubuntu-latest`.
|
||||
- Per-push workflows on `main`. A merge triggers cache warming and a `ci.yml` run, which validates the merged tree in full unless a merge-queue run already validated that tree; `build.yml` development builds are scheduled or manual; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
|
||||
- Per-push workflows on `main`. A merge triggers the applicable `ci.yml` matrix and cache warming; `build.yml` development builds are scheduled or manual; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
|
||||
- `apt-get install` of tools the hosted image already ships. It is only needed on the minimal pod images.
|
||||
- A throttled `CARGO_BUILD_JOBS` on a 14-CPU pod. The limit exists for link-storm reasons documented in `ci.yml`; measure before raising it, and measure before lowering it further.
|
||||
- Tiny jobs on ARC pods. Each one pays roughly a minute of pod start-up before its first step; a compile-free job that takes ten seconds costs more to schedule than to run.
|
||||
@@ -81,6 +80,6 @@ Rows using `macos-latest-xlarge` or a label starting with `sm-`, `dind-`, `ubicl
|
||||
|
||||
## Release stages
|
||||
|
||||
The [candidate release pipeline](../../docs/operations/release-pipeline.md) builds Linux first and defers paid desktop capacity until acceptance. The daily `build.yml` schedule selects Linux; the Sunday 16:17 UTC schedule selects macOS and Windows. Manual dispatch can still select any platform list. All six platforms remain required before publication. The pipeline records and promotes the exact tested bytes. Builder workflows have no public-release credentials. Keep periodic development builds, PR verification and field-test chains separate when their contracts differ; do not re-enable disabled workflows as part of a source-file migration.
|
||||
The [candidate release pipeline](../../docs/operations/release-pipeline.md) builds Linux first and defers paid desktop capacity until acceptance. Daily development builds also select Linux; use manual platform selection for desktop development artifacts. All six platforms remain required before publication. The pipeline records and promotes the exact tested bytes. Builder workflows have no public-release credentials. Keep periodic development builds, PR verification and field-test chains separate when their contracts differ; do not re-enable disabled workflows as part of a source-file migration.
|
||||
|
||||
Larger runners remain billed for public repositories; see [GitHub runner billing](https://docs.github.com/en/actions/concepts/runners/larger-runners#billing). Actual spending comes from organization billing, not runner labels or an estimated build duration.
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Scheduled development artifacts and reusable immutable-candidate builds.
|
||||
# The daily cron builds Linux. Sunday 16:17 UTC builds macOS and Windows.
|
||||
# Nightly development artifacts and reusable immutable-candidate builds.
|
||||
# Public release writes live exclusively in release-publish.yml. Candidate callers
|
||||
# select Linux or desktop and pin release inputs before any matrix starts.
|
||||
|
||||
@@ -31,8 +30,7 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
schedule:
|
||||
- cron: "13 1 * * *" # Daily Linux, 01:13 UTC
|
||||
- cron: "17 16 * * 0" # Weekly macOS and Windows, Sunday 16:17 UTC
|
||||
- cron: "13 1 * * *" # Nightly 01:13 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
@@ -221,33 +219,16 @@ jobs:
|
||||
env:
|
||||
# via env, not interpolation: a dispatch input is free-form text and
|
||||
# would otherwise be pasted into the script for bash to evaluate.
|
||||
# A direct schedule has no platforms input. Choose its subset from
|
||||
# github.event.schedule, which is fixed when that cron fires, so a
|
||||
# queued job cannot switch subsets because the runner clock moved.
|
||||
# Callers that pass platforms keep that list, including a scheduled
|
||||
# caller: inputs.platforms is set even though event_name stays schedule.
|
||||
# Daily development builds cover Linux. Desktop remains available on
|
||||
# manual dispatch and is required after candidate field acceptance.
|
||||
RAW_PLATFORMS: ${{ inputs.platforms || 'linux' }}
|
||||
HAS_PLATFORM_INPUT: ${{ inputs.platforms && 'true' || 'false' }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
EVENT_SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EVENT_NAME}" == "schedule" && "${HAS_PLATFORM_INPUT}" != "true" ]]; then
|
||||
if [[ "${EVENT_SCHEDULE}" == "13 1 * * *" ]]; then
|
||||
selected="linux"
|
||||
elif [[ "${EVENT_SCHEDULE}" == "17 16 * * 0" ]]; then
|
||||
selected="desktop"
|
||||
else
|
||||
echo "Unrecognized schedule cron: ${EVENT_SCHEDULE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
selected="${RAW_PLATFORMS}"
|
||||
selected="$(echo "${selected}" | tr -d '[:space:]')"
|
||||
if [[ -z "${selected}" ]]; then
|
||||
selected="all"
|
||||
fi
|
||||
selected="$RAW_PLATFORMS"
|
||||
selected="$(echo "${selected}" | tr -d '[:space:]')"
|
||||
if [[ -z "${selected}" ]]; then
|
||||
selected="all"
|
||||
fi
|
||||
|
||||
# linux-aarch64-* need a real aarch64 host (placement rule: hardware)
|
||||
|
||||
@@ -33,22 +33,22 @@
|
||||
# cache-save-if to anything but 'false' for it; every lane in ci.yml reads.
|
||||
# scripts/security/check_cache_save_if.sh keeps the declarations explicit.
|
||||
#
|
||||
# A reader restores only what the writer saved, and Cargo reuses a dependency
|
||||
# only when it resolves the same features for it. Every package selection and
|
||||
# feature set resolves differently and changes -Cmetadata, so each job builds
|
||||
# its readers' own invocations, not a superset (rustfs/backlog#1600).
|
||||
#
|
||||
# Rust-cache never replaces an entry restored from an exact key match: the key
|
||||
# hashes the lockfile, toolchain and compiler environment, not the commit. A
|
||||
# job whose restore was an exact hit therefore skips its builds; they could
|
||||
# only produce what is already stored.
|
||||
# The builds are supersets of what the reading lanes compile, because a reader
|
||||
# restores only what the writer saved. Feature resolution matters here: a lane
|
||||
# built with e2e-test-hooks resolves dependency features differently, which
|
||||
# changes -Cmetadata, so the plain build does not cover it. See
|
||||
# rustfs/backlog#1600.
|
||||
|
||||
name: Cache Warm
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Writes a key that no push created, such as one for a new stable
|
||||
# toolchain; with every key present the jobs stop after the restore.
|
||||
# Every six hours; the 22:41 UTC run lands before ci.yml's nightly 00:11
|
||||
# UTC matrix. A reader restores the newest entry under its key and
|
||||
# rebuilds whatever its own diff changed, so a cache up to six hours old
|
||||
# costs a PR the handful of crates merged since. Warming after every merge
|
||||
# (fourteen completed runs a day, about two sm-standard-4 hours each)
|
||||
# spent far more than that to avoid it.
|
||||
- cron: "41 4,10,16,22 * * *"
|
||||
push:
|
||||
branches: [ main ]
|
||||
@@ -96,8 +96,8 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
# Readers: workspace-clippy, workspace-lint-and-build, build-rustfs-debug-binary,
|
||||
# connect-feature-boundaries (nightly) and reference-parity.yml.
|
||||
# Readers: workspace-lint-and-build, workspace-tests (workspace and ILM), build-rustfs-debug-binary,
|
||||
# e2e-tests, e2e-full.
|
||||
warm-ci-dev:
|
||||
name: Warm ci-dev
|
||||
runs-on: sm-standard-4
|
||||
@@ -111,13 +111,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
id: setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
# Digest of the build steps below; scripts/ci_gate.py --check-workflow
|
||||
# names the new key when they change, and every reader must follow.
|
||||
cache-shared-key: ci-dev-ea7eec74fe14
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'true'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
@@ -133,7 +130,7 @@ jobs:
|
||||
# 50% and 25% respectively; if linking dominates instead, the answer is
|
||||
# mold/lld plus split-debuginfo, which is exactly the part sccache cannot
|
||||
# touch. Off by default — this doubles the ci-dev build.
|
||||
- name: Build the workspace with --timings
|
||||
- name: Build ci-dev superset (with --timings)
|
||||
if: inputs.emit_timings
|
||||
env:
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
@@ -148,18 +145,18 @@ jobs:
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
# The workspace and E2E test archives of workspace-lint-and-build and
|
||||
# build-rustfs-debug-binary, then that job's hooks server.
|
||||
- name: Build ci-dev reader selections
|
||||
if: steps.setup.outputs.cache-hit != 'true'
|
||||
# --all-targets covers the test binaries nextest builds, including
|
||||
# e2e_test, which the workspace run excludes. The second build adds
|
||||
# the e2e-test-hooks feature resolution that build-rustfs-debug-binary uses
|
||||
# and that no lint lane enables.
|
||||
- name: Build ci-dev superset
|
||||
env:
|
||||
# Same limit ci.yml puts on its archive steps: three concurrent test
|
||||
# binary links saturate the self-hosted runner's overlay I/O and can
|
||||
# wedge Cargo (#5394).
|
||||
# Same limit ci.yml puts on its nextest step: this builds the same
|
||||
# ~100 workspace test binaries, and three concurrent links saturate the
|
||||
# self-hosted runner's overlay I/O and can wedge Cargo (#5394).
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
python3 scripts/ci_test_artifact.py cargo warm --configuration workspace
|
||||
python3 scripts/ci_test_artifact.py cargo warm --configuration e2e
|
||||
cargo build --workspace --all-targets
|
||||
cargo build -p rustfs --bins --features e2e-test-hooks
|
||||
|
||||
# ci.yml runs Clippy on every pull request. Clippy's check-mode artifacts
|
||||
@@ -168,9 +165,8 @@ jobs:
|
||||
# touches. A lint failure on main must not stop the warm-up: the cache is
|
||||
# still saved (cache-on-failure), and the failure belongs to ci.yml.
|
||||
- name: Warm Clippy check artifacts
|
||||
if: steps.setup.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if ! python3 scripts/ci_test_artifact.py cargo lint --configuration workspace; then
|
||||
if ! cargo clippy --all-targets -- -D warnings; then
|
||||
echo "::warning::cargo clippy reports findings on this commit; check artifacts are still cached"
|
||||
fi
|
||||
|
||||
@@ -217,7 +213,6 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
id: setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
@@ -226,7 +221,6 @@ jobs:
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build ci-feat-rio superset
|
||||
if: steps.setup.outputs.cache-hit != 'true'
|
||||
env:
|
||||
# --all-targets links the same test binaries as the reader lane.
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
@@ -250,7 +244,6 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
id: setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
@@ -259,7 +252,6 @@ jobs:
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build ci-feat-proto superset
|
||||
if: steps.setup.outputs.cache-hit != 'true'
|
||||
env:
|
||||
# Avoid an unbounded burst of concurrent test-binary links (#5394).
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
@@ -282,7 +274,6 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
id: setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
@@ -291,9 +282,7 @@ jobs:
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Install build dependencies
|
||||
if: steps.setup.outputs.cache-hit != 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
|
||||
|
||||
- name: Build ci-uring superset
|
||||
if: steps.setup.outputs.cache-hit != 'true'
|
||||
run: cargo build -p rustfs-ecstore --all-targets
|
||||
|
||||
+116
-152
@@ -46,6 +46,11 @@ on:
|
||||
schedule:
|
||||
- cron: "11 0 * * *" # Nightly 00:11 UTC, after the 22:41 UTC cache-warm run
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rehearse_workspace_shards:
|
||||
description: Compare one full execution with at most two resource-safe shards of the same archive
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -83,16 +88,13 @@ jobs:
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
mode: ${{ steps.scope.outputs.mode }}
|
||||
plan: ${{ steps.scope.outputs.plan }}
|
||||
connect: ${{ steps.scope.outputs.connect || 'true' }}
|
||||
packages: ${{ steps.scope.outputs.packages }}
|
||||
full_validation: ${{ steps.scope.outputs.full_validation || 'true' }}
|
||||
gateway_smoke: ${{ steps.scope.outputs.gateway_smoke || 'true' }}
|
||||
rehearse_shards: ${{ steps.scope.outputs.rehearse_shards || 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
@@ -103,13 +105,8 @@ jobs:
|
||||
env:
|
||||
CI_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before }}
|
||||
CI_EVENT_ACTION: ${{ github.event.action }}
|
||||
# A push whose tree a successful merge-group run already passed skips
|
||||
# the validation jobs; the lookup answers null on any doubt.
|
||||
PROOF_TOKEN: ${{ github.event_name == 'push' && github.token || '' }}
|
||||
CI_REHEARSE_SHARDS: ${{ inputs.rehearse_workspace_shards && 'true' || 'false' }}
|
||||
run: |
|
||||
# Without a token (every event but push) the lookup answers null at once.
|
||||
CI_MERGE_GROUP_PROOF="$(GITHUB_TOKEN="$PROOF_TOKEN" python3 -I scripts/ci_merge_group_proof.py)" || CI_MERGE_GROUP_PROOF=null
|
||||
export CI_MERGE_GROUP_PROOF
|
||||
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||
python3 -I scripts/ci_gate.py select
|
||||
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
|
||||
@@ -118,8 +115,8 @@ jobs:
|
||||
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||
echo "Base CI policy unavailable; running the full matrix."
|
||||
fi
|
||||
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=8$' "$GITHUB_OUTPUT"; then
|
||||
printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' 'gateway_smoke=true' 'packages=' 'plan=' >> "$GITHUB_OUTPUT"
|
||||
if ! grep -q '^plan=' "$GITHUB_OUTPUT" || ! grep -q '^plan_contract=3$' "$GITHUB_OUTPUT"; then
|
||||
printf '%s\n' 'mode=full' 'connect=true' 'full_validation=true' 'rehearse_shards=false' 'packages=' 'plan=' >> "$GITHUB_OUTPUT"
|
||||
echo "Base policy does not cover this workflow contract; retaining the full matrix."
|
||||
fi
|
||||
- name: Retain verification plan
|
||||
@@ -173,67 +170,6 @@ jobs:
|
||||
- name: Run shared quick checks
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
# Clippy runs beside the test-binary build instead of in front of it. The two
|
||||
# share no compiler output: Clippy writes check-mode metadata, the archive
|
||||
# needs codegen and linked test binaries, and both restore the third-party
|
||||
# artifacts of their own mode from ci-dev. As one job the lint held the
|
||||
# archive, and every test that consumes it, back by its whole duration.
|
||||
# scripts/ci_gate.py keeps this job in the Test and Lint aggregate and out
|
||||
# of the archive consumer's dependency chain.
|
||||
workspace-clippy:
|
||||
name: Workspace Clippy
|
||||
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
# Reads the ci-dev cache that cache-warm.yml writes on this image, and the
|
||||
# all-targets check of the workspace does not fit a hosted runner's disk.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
CI_PACKAGES: ${{ needs.classify-changes.outputs.packages }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
# Checkout otherwise writes the token into .git/config, where a PR's
|
||||
# own build.rs or proc-macro could read it back out.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
# Read-only, like every other ci-dev reader; cache-warm.yml warms the
|
||||
# check artifacts of this exact invocation.
|
||||
cache-shared-key: ci-dev-ea7eec74fe14
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
# Clippy gates every event, pull requests included. When it ran only after
|
||||
# merge, a lint regression merged freely and then failed every main run
|
||||
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
|
||||
# writes the same invocation's check artifacts into ci-dev, so a pull
|
||||
# request re-checks only the crates its diff touches. Main keeps the step
|
||||
# because pull requests can merge without being up to date with main.
|
||||
- name: Run clippy lints
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start clippy
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
python3 scripts/ci_test_artifact.py cargo lint --configuration workspace
|
||||
|
||||
- name: Upload lint diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: workspace-clippy-diagnostics-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: artifacts/workspace
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
# Keeps its job ID and check name: it is the archive producer that
|
||||
# workspace-tests and scripts/check_test_wiring.py bind to. The lint half
|
||||
# is workspace-clippy above; this job builds the test archive, then runs
|
||||
# the doctests and the log-analyzer anchor check on the same build output.
|
||||
workspace-lint-and-build:
|
||||
name: Workspace Lint and Build
|
||||
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
@@ -258,7 +194,7 @@ jobs:
|
||||
# Every lane reads its cache; cache-warm.yml remains the sole writer
|
||||
# with the matching feature closure. Tests never compete to replace
|
||||
# shared caches, including when a PR attempt is cancelled.
|
||||
cache-shared-key: ci-dev-ea7eec74fe14
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
@@ -275,6 +211,18 @@ jobs:
|
||||
echo "started_at=$(date --utc --iso-8601=seconds)"
|
||||
} > artifacts/workspace/run-metadata.txt
|
||||
|
||||
# Clippy gates every event, pull requests included. When it ran only after
|
||||
# merge, a lint regression merged freely and then failed every main run
|
||||
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
|
||||
# writes the same invocation's check artifacts into ci-dev, so a pull
|
||||
# request re-checks only the crates its diff touches. Main keeps the step
|
||||
# because pull requests can merge without being up to date with main.
|
||||
- name: Run clippy lints
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start clippy
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
python3 scripts/ci_test_artifact.py cargo lint --configuration workspace
|
||||
|
||||
- name: Archive workspace test binaries
|
||||
env:
|
||||
# Workspace test compilation has high per-process memory use.
|
||||
@@ -325,7 +273,7 @@ jobs:
|
||||
# rustfs/backlog#1289: fail if a seed rule's log anchor no longer exists
|
||||
# verbatim in the source tree (log message drifted without updating the
|
||||
# rule). Placed here where the workspace — including the la-dump-anchors
|
||||
# bin's dependencies — is already built by the test archive step above.
|
||||
# bin — is already built by the clippy/test steps above.
|
||||
- name: Check log-analyzer rule anchors
|
||||
run: ./scripts/check_log_analyzer_rules.sh
|
||||
|
||||
@@ -336,6 +284,8 @@ jobs:
|
||||
# requires the same runner image until the hosted-runtime pilot passes.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
outputs:
|
||||
workspace_shards: ${{ steps.shards.outputs.workspace_shards || '[]' }}
|
||||
env:
|
||||
CI_PACKAGES: ${{ needs.classify-changes.outputs.packages }}
|
||||
steps:
|
||||
@@ -424,6 +374,89 @@ jobs:
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Prepare bounded shards from this candidate's observed durations
|
||||
id: shards
|
||||
if: needs.classify-changes.outputs.rehearse_shards == 'true'
|
||||
run: |
|
||||
python3 scripts/ci_test_shards.py prepare \
|
||||
--listing artifacts/workspace/core-test-listing.json \
|
||||
--junit target/nextest/ci/junit.xml --output artifacts/workspace-shard-plan
|
||||
- name: Upload same-run shard plan
|
||||
if: needs.classify-changes.outputs.rehearse_shards == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: workspace-shard-plan-${{ github.run_attempt }}
|
||||
path: artifacts/workspace-shard-plan
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
workspace-shards:
|
||||
name: Workspace shards (${{ matrix.shard }})
|
||||
if: needs.classify-changes.outputs.rehearse_shards == 'true'
|
||||
needs: [classify-changes, workspace-tests]
|
||||
# Reuse the producer image and checkout path for the full RustFS archive.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
shard: ${{ fromJSON(needs.workspace-tests.outputs.workspace_shards || '[]') }}
|
||||
env:
|
||||
CI_PACKAGES: ${{ needs.classify-changes.outputs.packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/test-runtime
|
||||
- name: Protect Connect test home
|
||||
run: chmod go-w "$(realpath "$HOME")"
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: workspace-test-binaries-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: workspace-shard-plan-${{ github.run_attempt }}
|
||||
path: artifacts/workspace-shard-plan
|
||||
- name: Verify and execute this shard without compilation
|
||||
env:
|
||||
SHARD_INDEX: ${{ matrix.shard }}
|
||||
run: |
|
||||
python3 scripts/ci_test_artifact.py verify --configuration workspace --archive "$RUNNER_TEMP/workspace-tests.tar.zst"
|
||||
python3 scripts/ci_test_shards.py execute --plan artifacts/workspace-shard-plan \
|
||||
--archive "$RUNNER_TEMP/workspace-tests.tar.zst" --index "$SHARD_INDEX" --output artifacts/workspace-shard-result
|
||||
- name: Retain shard evidence including failures
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: workspace-shard-result-${{ matrix.shard }}-${{ github.run_attempt }}
|
||||
path: artifacts/workspace-shard-result
|
||||
retention-days: 3
|
||||
if-no-files-found: error
|
||||
|
||||
workspace-shard-evidence:
|
||||
name: Workspace shard completeness
|
||||
if: always() && needs.classify-changes.outputs.rehearse_shards == 'true'
|
||||
needs: [classify-changes, workspace-tests, workspace-shards]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: workspace-shard-plan-${{ github.run_attempt }}
|
||||
path: artifacts/workspace-shard-plan
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: workspace-shard-result-*-${{ github.run_attempt }}
|
||||
path: artifacts/workspace-shards
|
||||
- name: Require the exact union of successful shard executions
|
||||
run: |
|
||||
python3 scripts/ci_test_shards.py verify --plan artifacts/workspace-shard-plan --evidence artifacts/workspace-shards
|
||||
./scripts/check_migration_gate_count.sh evidence artifacts/workspace-shard-plan/listing.json artifacts/workspace-shards/combined.xml
|
||||
|
||||
connect-feature-boundaries:
|
||||
name: Connect Feature Boundaries
|
||||
@@ -443,7 +476,7 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev-ea7eec74fe14
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
install-test-tools: 'false'
|
||||
@@ -469,7 +502,7 @@ jobs:
|
||||
# hold. `cargo check --release` is metadata-only and needs no
|
||||
# link-concurrency limit; it is cold because ci-dev holds no release
|
||||
# artifacts.
|
||||
- name: Reject debug-only features in release builds
|
||||
- name: Reject short credentials in release builds
|
||||
run: python3 scripts/check_connect_release_boundary.py
|
||||
|
||||
- name: Check offline enrollment E2E root boundary
|
||||
@@ -615,10 +648,6 @@ jobs:
|
||||
flags: "--features sftp"
|
||||
- name: http3
|
||||
flags: "--features http3,swift"
|
||||
- name: internode-transport
|
||||
flags: "--features rustfs/internode-transport"
|
||||
packages: "-p rustfs-ecstore -p rustfs-rio"
|
||||
tests: "--no-tests fail -E 'test(peer_transport_)'"
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -644,7 +673,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start clippy
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
cargo clippy -p rustfs ${{ matrix.features.packages || '-p rustfs-protocols' }} --all-targets ${{ matrix.features.flags }} -- -D warnings
|
||||
cargo clippy -p rustfs -p rustfs-protocols --all-targets ${{ matrix.features.flags }} -- -D warnings
|
||||
|
||||
- name: Run tests with ${{ matrix.features.name }}
|
||||
id: protocol-tests
|
||||
@@ -661,7 +690,7 @@ jobs:
|
||||
mkdir -p artifacts/protocol-tests
|
||||
./scripts/ci/resource_sampler.sh start nextest
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
cargo nextest run --profile ci -p rustfs ${{ matrix.features.packages || '-p rustfs-protocols' }} ${{ matrix.features.flags }} ${{ matrix.features.tests }} \
|
||||
cargo nextest run --profile ci -p rustfs -p rustfs-protocols ${{ matrix.features.flags }} \
|
||||
2>&1 | tee artifacts/protocol-tests/nextest.log
|
||||
|
||||
- name: Upload protocol test reports and diagnostics
|
||||
@@ -676,54 +705,6 @@ jobs:
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
test-and-lint-gateway-stack:
|
||||
name: Test and Lint (gateway-stack)
|
||||
# The gateway S3 stack (rustfs/backlog#2734) sits behind the rustfs
|
||||
# `gateway-stack` feature, which no other pull-request lane lints or tests.
|
||||
# Unlike the dormant feature lanes above, this one runs for every code
|
||||
# change: each operation slice puts its handler tests and its refusal-matrix
|
||||
# row behind the feature, and a lane that ran only on demand would let them
|
||||
# rot behind a green pull request.
|
||||
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
needs: [ quick-checks, classify-changes ]
|
||||
# Self-hosted for build scale and cache ownership: a workspace-scale Cargo
|
||||
# build that reads the ci-dev cache written on this image.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
# A feature that does not compile fails here; nothing below is skipped
|
||||
# into a pass.
|
||||
- name: Run clippy with gateway-stack
|
||||
run: cargo clippy --locked -p rustfs --all-targets --features gateway-stack -- -D warnings
|
||||
|
||||
# The gateway stack's own tests: the assembly, the operation table and
|
||||
# its completeness, the shared handler sequence, the stack selection, and
|
||||
# the embedded server driven through the whole middleware stack. The
|
||||
# filter names modules, never operations.
|
||||
- name: Run tests with gateway-stack
|
||||
env:
|
||||
# The same bounded link concurrency as the other feature lanes.
|
||||
CARGO_BUILD_JOBS: "2"
|
||||
run: |
|
||||
cargo nextest run --profile ci --locked -p rustfs --features gateway-stack --no-tests fail \
|
||||
--status-level all --final-status-level all \
|
||||
-E 'test(/gateway_stack|gateway_handlers|server::stack::/) | binary(embedded_gateway_stack_test)'
|
||||
|
||||
build-rustfs-debug-binary:
|
||||
name: Build RustFS Debug Binary
|
||||
if: contains(fromJSON('["full", "e2e"]'), needs.classify-changes.outputs.mode) && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
@@ -742,7 +723,7 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev-ea7eec74fe14
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
@@ -1113,24 +1094,9 @@ jobs:
|
||||
# Test and Lint aggregate and becomes required at T2.10. The test-binary
|
||||
# download, identity, membership, run and completeness steps must stay
|
||||
# identical to e2e-tests; scripts/ci_gate.py enforces that.
|
||||
#
|
||||
# When it runs: every push to main or gateway/integration, merge group,
|
||||
# nightly schedule and manual dispatch, and every pull request that changes a
|
||||
# gateway-stack input (GATEWAY_SMOKE_PATHS and the rustfs-gateway pins in
|
||||
# scripts/ci_gate.py, published by Select CI scope as gateway_smoke).
|
||||
# Why it is skipped on every other pull request: until T2.10 the gateway
|
||||
# stack does not serve the whole smoke suite, so the lane fails on any
|
||||
# revision. A pull request that touches none of its inputs can neither cause
|
||||
# nor fix that failure, and a red check there says nothing about the change.
|
||||
# Skipped is the honest state; a failure must never be rendered green, so no
|
||||
# step here may carry continue-on-error. A selection that is missing or
|
||||
# unreadable runs the lane. At T2.10, when the lane becomes required, it
|
||||
# must run on every pull request again: delete the selection, do not extend
|
||||
# it.
|
||||
e2e-tests-gateway:
|
||||
name: e2e-smoke (gateway, report-only until T2.10)
|
||||
needs: [ classify-changes, build-rustfs-debug-binary ]
|
||||
if: github.event_name != 'pull_request' || needs.classify-changes.outputs.gateway_smoke != 'false'
|
||||
needs: [ build-rustfs-debug-binary ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
@@ -1604,15 +1570,15 @@ jobs:
|
||||
- classify-changes
|
||||
- typos
|
||||
- quick-checks
|
||||
- workspace-clippy
|
||||
- workspace-lint-and-build
|
||||
- workspace-tests
|
||||
- workspace-shards
|
||||
- workspace-shard-evidence
|
||||
- core-contract
|
||||
- connect-feature-boundaries
|
||||
- fips-provider
|
||||
- test-and-lint-rio-v2
|
||||
- test-and-lint-protocols
|
||||
- test-and-lint-gateway-stack
|
||||
- build-rustfs-debug-binary
|
||||
- uring-integration
|
||||
- e2e-tests
|
||||
@@ -1642,13 +1608,11 @@ jobs:
|
||||
- required-checks
|
||||
- typos
|
||||
- quick-checks
|
||||
- workspace-clippy
|
||||
- workspace-lint-and-build
|
||||
- workspace-tests
|
||||
- core-contract
|
||||
- test-and-lint-rio-v2
|
||||
- test-and-lint-protocols
|
||||
- test-and-lint-gateway-stack
|
||||
- build-rustfs-debug-binary
|
||||
- build-rustfs-debug-binary-rio-v2
|
||||
- uring-integration
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Nightly both-stack diff of the two e2e profiles only ci.yml runs, e2e-smoke
|
||||
# and e2e-full (rustfs/backlog#2772, T3.1). The other profiles carry a `stack`
|
||||
# dimension in their own workflows, where the existing lane is the legacy leg.
|
||||
#
|
||||
# One build serves every leg. One preflight starts that binary with
|
||||
# RUSTFS_S3_STACK=gateway (scripts/dual_stack.py preflight); without a proven
|
||||
# gateway start the gateway legs do not run and the summary says `skipped:
|
||||
# gateway stack refuses startup: <why>`. Which stack a leg ran is read from the
|
||||
# servers' own start-up line. The gateway legs are report-only until T2.10.
|
||||
#
|
||||
# Left out of both stacks: `dual_stack_differential::` (servers with an explicit
|
||||
# stack), `fake_s3_target::`, the upgrade write-readiness cases and one deadline
|
||||
# case (no server), and `distributed_startup_regression_test::` (needs ci.yml's
|
||||
# hooks-binary manifest). e2e-full runs without the mc, Vault and iptables
|
||||
# tools ci.yml installs: their cases fail on both stacks.
|
||||
|
||||
name: dual-stack-nightly
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 03:13 UTC nightly, clear of e2e-nightly (04:29), the ODM lane (05:23) and
|
||||
# e2e-distributed (05:53).
|
||||
- cron: "13 3 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build once and probe the gateway stack
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
# The runner and cache of ci.yml's build-rustfs-debug-binary.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
stacks: ${{ steps.preflight.outputs.stacks }}
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build the rustfs binary
|
||||
run: python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
- name: Upload the binary
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dual-stack-binary
|
||||
path: |
|
||||
target/debug/rustfs
|
||||
target/debug/rustfs.e2e.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Without a proven gateway start there is no gateway leg to run.
|
||||
- name: Gateway preflight
|
||||
id: preflight
|
||||
run: |
|
||||
if python3 scripts/dual_stack.py preflight --binary target/debug/rustfs --out "$RUNNER_TEMP/preflight/stack.json"; then
|
||||
echo 'stacks=["legacy","gateway"]' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'stacks=["legacy"]' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# The evidence stands in for a gateway leg that is skipped; a leg that runs overwrites it.
|
||||
- name: Upload the preflight evidence for e2e-smoke
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dual-stack-leg-e2e-smoke-gateway
|
||||
path: ${{ runner.temp }}/preflight
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload the preflight evidence for e2e-full
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dual-stack-leg-e2e-full-gateway
|
||||
path: ${{ runner.temp }}/preflight
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
leg:
|
||||
name: ${{ matrix.profile }} (${{ matrix.stack }}${{ matrix.stack == 'gateway' && ', report-only until T2.10' || '' }})
|
||||
needs: [ build ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 120
|
||||
# Report-only until T2.10: the gateway stack cannot serve the suites yet.
|
||||
continue-on-error: ${{ matrix.stack == 'gateway' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile: [ e2e-smoke, e2e-full ]
|
||||
stack: ${{ fromJSON(needs.build.outputs.stacks) }}
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Download the binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: dual-stack-binary
|
||||
path: target/debug
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install awscurl
|
||||
run: |
|
||||
python3 -m pip install --user --upgrade pip "awscurl==0.44"
|
||||
echo "AWSCURL_PATH=$HOME/.local/bin/awscurl" >> "$GITHUB_ENV"
|
||||
test -x "$HOME/.local/bin/awscurl"
|
||||
|
||||
- name: Run the profile on the ${{ matrix.stack }} stack
|
||||
env:
|
||||
PROFILE: ${{ matrix.profile }}
|
||||
RUSTFS_S3_STACK: ${{ matrix.stack }}
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/server-logs
|
||||
run: |
|
||||
chmod +x target/debug/rustfs
|
||||
filter='not test(/^(dual_stack_differential|fake_s3_target|distributed_startup_regression_test)::/)'
|
||||
filter="$filter & not test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)"
|
||||
filter="$filter & not test(=list_objects_v2_pagination_test::tests::test_list_objects_v2_deadline_rejects_unresponsive_peer)"
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile "$PROFILE" -p e2e_test -E "$filter"
|
||||
|
||||
- name: Prove the stack and upload the leg
|
||||
if: always()
|
||||
uses: ./.github/actions/dual-stack-leg
|
||||
with: { profile: "${{ matrix.profile }}", stack: "${{ matrix.stack }}", logs: "${{ runner.temp }}/server-logs" }
|
||||
|
||||
summary:
|
||||
name: Both-stack diff summary
|
||||
needs: [ build, leg ]
|
||||
if: github.repository == 'rustfs/rustfs' && always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/dual-stack-summary
|
||||
with:
|
||||
profiles: e2e-smoke,e2e-full
|
||||
@@ -53,14 +53,11 @@ concurrency:
|
||||
jobs:
|
||||
distributed:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Distributed 4-node 4-disk e2e (${{ matrix.stack }}${{ matrix.stack == 'gateway' && ', report-only until T2.10' || '' }})
|
||||
name: Distributed 4-node 4-disk e2e
|
||||
# GitHub-hosted VM: loop and tmpfs mounts work here. sm-standard-4 is an
|
||||
# ARC pod and rejects both (`mount -o loop` ENOENT, tmpfs "read-only").
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
# The gateway leg is report-only until T2.10.
|
||||
continue-on-error: ${{ matrix.stack == 'gateway' }}
|
||||
strategy: { fail-fast: false, matrix: { stack: [legacy, gateway] } }
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
@@ -132,11 +129,6 @@ jobs:
|
||||
run: |
|
||||
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
# A gateway binary that refuses to start skips the suite; the summary says why.
|
||||
- name: Gateway preflight
|
||||
if: matrix.stack == 'gateway'
|
||||
run: python3 scripts/dual_stack.py preflight --binary target/debug/rustfs --out "$RUNNER_TEMP/leg/stack.json"
|
||||
|
||||
- name: Verify distributed e2e membership
|
||||
env:
|
||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||
@@ -148,7 +140,6 @@ jobs:
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
|
||||
FILTER: ${{ inputs.filter }}
|
||||
RUSTFS_S3_STACK: ${{ matrix.stack }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -f target/nextest/e2e-distributed/junit.xml
|
||||
@@ -159,19 +150,11 @@ jobs:
|
||||
fi
|
||||
test -s target/nextest/e2e-distributed/junit.xml
|
||||
|
||||
- name: Prove the stack and upload the leg
|
||||
if: always()
|
||||
uses: ./.github/actions/dual-stack-leg
|
||||
with:
|
||||
profile: e2e-distributed
|
||||
stack: ${{ matrix.stack }}
|
||||
logs: ${{ runner.temp }}/rustfs-e2e-distributed-logs
|
||||
|
||||
- name: Upload distributed e2e diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-distributed-${{ github.run_number }}-${{ matrix.stack }}
|
||||
name: e2e-distributed-${{ github.run_number }}
|
||||
path: |
|
||||
target/nextest/e2e-distributed/junit.xml
|
||||
${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||
@@ -191,20 +174,6 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
dual-stack-summary:
|
||||
name: Both-stack diff summary
|
||||
needs: [distributed]
|
||||
if: github.repository == 'rustfs/rustfs' && always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/dual-stack-summary
|
||||
with:
|
||||
profiles: e2e-distributed
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [distributed]
|
||||
|
||||
@@ -54,15 +54,12 @@ concurrency:
|
||||
jobs:
|
||||
repl-nightly:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Replication e2e (nightly, ${{ matrix.stack }}${{ matrix.stack == 'gateway' && ', report-only until T2.10' || '' }})
|
||||
name: Replication e2e (nightly)
|
||||
# dual-node tests spawn two full rustfs servers each; the extra headroom
|
||||
# over the per-PR sm-standard-2 e2e job keeps the parallel process fan-out
|
||||
# from starving the runner. Mirrors the ILM serial lane's runner class.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 45
|
||||
# The gateway leg is report-only until T2.10.
|
||||
continue-on-error: ${{ matrix.stack == 'gateway' }}
|
||||
strategy: { fail-fast: false, matrix: { stack: [legacy, gateway] } }
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -98,11 +95,6 @@ jobs:
|
||||
run: |
|
||||
python3 scripts/e2e_binary.py build --bins
|
||||
|
||||
# A gateway binary that refuses to start skips the suite; the summary says why.
|
||||
- name: Gateway preflight
|
||||
if: matrix.stack == 'gateway'
|
||||
run: python3 scripts/dual_stack.py preflight --binary target/debug/rustfs --out "$RUNNER_TEMP/leg/stack.json"
|
||||
|
||||
- name: Verify replication e2e membership
|
||||
env:
|
||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-repl-nightly-list.json
|
||||
@@ -113,22 +105,13 @@ jobs:
|
||||
- name: Run replication e2e nightly suite
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
||||
RUSTFS_S3_STACK: ${{ matrix.stack }}
|
||||
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
|
||||
- name: Prove the stack and upload the leg
|
||||
if: always()
|
||||
uses: ./.github/actions/dual-stack-leg
|
||||
with:
|
||||
profile: e2e-repl-nightly
|
||||
stack: ${{ matrix.stack }}
|
||||
logs: ${{ runner.temp }}/rustfs-e2e-repl-nightly-logs
|
||||
|
||||
- name: Upload nextest junit report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-replication-nightly-junit-${{ github.run_number }}-${{ matrix.stack }}
|
||||
name: e2e-replication-nightly-junit-${{ github.run_number }}
|
||||
path: |
|
||||
target/nextest/e2e-repl-nightly/junit.xml
|
||||
${{ runner.temp }}/rustfs-e2e-repl-nightly-list.json
|
||||
@@ -138,12 +121,9 @@ jobs:
|
||||
|
||||
cluster-nightly:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Cluster fault e2e (nightly, ${{ matrix.stack }}${{ matrix.stack == 'gateway' && ', report-only until T2.10' || '' }})
|
||||
name: Cluster fault e2e (nightly)
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
# The gateway leg is report-only until T2.10.
|
||||
continue-on-error: ${{ matrix.stack == 'gateway' }}
|
||||
strategy: { fail-fast: false, matrix: { stack: [legacy, gateway] } }
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -164,11 +144,6 @@ jobs:
|
||||
run: |
|
||||
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
# A gateway binary that refuses to start skips the suite; the summary says why.
|
||||
- name: Gateway preflight
|
||||
if: matrix.stack == 'gateway'
|
||||
run: python3 scripts/dual_stack.py preflight --binary target/debug/rustfs --out "$RUNNER_TEMP/leg/stack.json"
|
||||
|
||||
- name: Verify cluster fault e2e membership
|
||||
env:
|
||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-nightly-list.json
|
||||
@@ -180,22 +155,13 @@ jobs:
|
||||
env:
|
||||
CARGO_BIN_EXE_rustfs: ${{ github.workspace }}/target/debug/rustfs
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
||||
RUSTFS_S3_STACK: ${{ matrix.stack }}
|
||||
run: python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
|
||||
- name: Prove the stack and upload the leg
|
||||
if: always()
|
||||
uses: ./.github/actions/dual-stack-leg
|
||||
with:
|
||||
profile: e2e-nightly
|
||||
stack: ${{ matrix.stack }}
|
||||
logs: ${{ runner.temp }}/rustfs-e2e-nightly-logs
|
||||
|
||||
- name: Upload cluster fault diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-cluster-nightly-${{ github.run_number }}-${{ matrix.stack }}
|
||||
name: e2e-cluster-nightly-${{ github.run_number }}
|
||||
path: |
|
||||
target/nextest/e2e-nightly/junit.xml
|
||||
${{ runner.temp }}/rustfs-e2e-nightly-list.json
|
||||
@@ -205,12 +171,9 @@ jobs:
|
||||
|
||||
protocols-nightly:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Protocol e2e (nightly, ${{ matrix.stack }}${{ matrix.stack == 'gateway' && ', report-only until T2.10' || '' }})
|
||||
name: Protocol e2e (nightly)
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
# The gateway leg is report-only until T2.10.
|
||||
continue-on-error: ${{ matrix.stack == 'gateway' }}
|
||||
strategy: { fail-fast: false, matrix: { stack: [legacy, gateway] } }
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
RUSTFS_BUILD_FEATURES: ftps,webdav,sftp
|
||||
@@ -237,11 +200,6 @@ jobs:
|
||||
- name: Build protocol server
|
||||
run: python3 scripts/e2e_binary.py build --features "$RUSTFS_BUILD_FEATURES"
|
||||
|
||||
# A gateway binary that refuses to start skips the suite; the summary says why.
|
||||
- name: Gateway preflight
|
||||
if: matrix.stack == 'gateway'
|
||||
run: python3 scripts/dual_stack.py preflight --binary target/debug/rustfs --out "$RUNNER_TEMP/leg/stack.json"
|
||||
|
||||
# The suite owns fixed protocol ports and serializes its internal cases.
|
||||
- name: Verify protocol e2e membership
|
||||
env:
|
||||
@@ -253,28 +211,14 @@ jobs:
|
||||
- name: Run protocol e2e nightly suite
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
||||
RUSTFS_S3_STACK: ${{ matrix.stack }}
|
||||
# These servers set no log level of their own; the stack proof reads their start-up line.
|
||||
RUST_LOG: rustfs=info
|
||||
# The servers inherit nextest's stdout (--no-capture): keep it where the stack proof reads it.
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p "$RUSTFS_E2E_LOG_DIR"
|
||||
python3 scripts/e2e_binary.py run --features "$RUSTFS_BUILD_FEATURES" -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture 2>&1 | tee "$RUSTFS_E2E_LOG_DIR/nextest.log"
|
||||
|
||||
- name: Prove the stack and upload the leg
|
||||
if: always()
|
||||
uses: ./.github/actions/dual-stack-leg
|
||||
with:
|
||||
profile: e2e-protocols
|
||||
stack: ${{ matrix.stack }}
|
||||
logs: ${{ runner.temp }}/rustfs-protocol-e2e-logs
|
||||
run: >-
|
||||
python3 scripts/e2e_binary.py run --features "$RUSTFS_BUILD_FEATURES" -- cargo nextest run -j 1 --profile e2e-protocols -p e2e_test --no-capture
|
||||
|
||||
- name: Upload protocol diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-protocol-nightly-${{ github.run_number }}-${{ matrix.stack }}
|
||||
name: e2e-protocol-nightly-${{ github.run_number }}
|
||||
path: |
|
||||
target/nextest/e2e-protocols/junit.xml
|
||||
${{ runner.temp }}/rustfs-e2e-protocols-list.json
|
||||
@@ -282,20 +226,6 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
dual-stack-summary:
|
||||
name: Both-stack diff summary
|
||||
needs: [repl-nightly, cluster-nightly, protocols-nightly]
|
||||
if: github.repository == 'rustfs/rustfs' && always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/dual-stack-summary
|
||||
with:
|
||||
profiles: e2e-repl-nightly,e2e-nightly,e2e-protocols
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [repl-nightly, cluster-nightly, protocols-nightly]
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Manual Linux zip/deb/rpm packaging. Workflow artifacts only.
|
||||
# Dispatch uses this file from the selected branch. An empty ref packages
|
||||
# the tip of refs/heads/main. A non-empty ref must already exist; the
|
||||
# resolver fetches that tag, branch, or commit so checkout is not limited
|
||||
# to the default branch. Nothing is published.
|
||||
|
||||
name: Manual Linux packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
architecture:
|
||||
description: "Linux targets: x86 (x86_64 musl+gnu), arm (aarch64 musl+gnu), or all"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- x86
|
||||
- arm
|
||||
- all
|
||||
default: all
|
||||
ref:
|
||||
description: "Git tag to package. Leave empty to package the tip of origin/main. A branch name or full commit SHA is also accepted."
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
version:
|
||||
description: "Package version: MAJOR.MINOR.PATCH, or MAJOR.MINOR.PATCH-(alpha|beta|rc).N. No leading v. Required for origin/main. For a tag, leave empty to derive it from the tag; an explicit value is used as entered."
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Resolve source and version
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
source_sha: ${{ steps.source.outputs.source_sha }}
|
||||
source_kind: ${{ steps.source.outputs.source_kind }}
|
||||
source_name: ${{ steps.source.outputs.source_name }}
|
||||
version: ${{ steps.source.outputs.version }}
|
||||
packaging: ${{ steps.source.outputs.packaging }}
|
||||
tool_sha: ${{ steps.source.outputs.tool_sha }}
|
||||
confirm_from: ${{ steps.source.outputs.confirm_from }}
|
||||
toolchain: ${{ steps.inputs.outputs.toolchain }}
|
||||
console: ${{ steps.inputs.outputs.console }}
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
# Tooling checkout. An empty ref then detaches this tree to the tip of
|
||||
# origin/main. A tag or other ref is fetched and pinned without
|
||||
# replacing this tree, because console resolution needs candidate.py.
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve source and version
|
||||
id: source
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_REF: ${{ inputs.ref }}
|
||||
PACKAGE_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash scripts/release/package_manual_artifacts.sh resolve-source \
|
||||
--source "$SOURCE_REF" \
|
||||
--version "$PACKAGE_VERSION" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install stable Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Resolve console assets and toolchain
|
||||
id: inputs
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
toolchain=$(rustc --version | awk '{print $2}')
|
||||
[[ "$toolchain" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
gh api repos/rustfs/console/releases/latest > console-release.json
|
||||
TOOLCHAIN="$toolchain" python3 - <<'PY'
|
||||
import json, os, sys
|
||||
sys.path.insert(0, "scripts/release")
|
||||
import candidate
|
||||
console = candidate.console_asset(candidate.read_json("console-release.json"))
|
||||
candidate.emit_output("console", json.dumps(console, separators=(",", ":")))
|
||||
candidate.emit_output("toolchain", os.environ["TOOLCHAIN"])
|
||||
print(f"toolchain={os.environ['TOOLCHAIN']}")
|
||||
print(f"console={console['tag']} {console['name']}")
|
||||
PY
|
||||
|
||||
- name: Select Linux targets
|
||||
id: matrix
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURE: ${{ inputs.architecture }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
matrix=$(bash scripts/release/package_manual_artifacts.sh matrix "$ARCHITECTURE")
|
||||
count=$(python3 -c 'import json,sys; print(len(json.loads(sys.argv[1])["include"]))' "$matrix")
|
||||
[[ "$count" -gt 0 ]]
|
||||
printf 'matrix=%s\n' "$matrix" >> "$GITHUB_OUTPUT"
|
||||
printf 'Selected %s target(s) for %s\n' "$count" "$ARCHITECTURE"
|
||||
|
||||
package:
|
||||
name: Package RustFS (${{ matrix.target_id }})
|
||||
needs: prepare
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
# x86_64 uses sm-standard-4, the official release compile pool: the workspace
|
||||
# release build needs that disk (placement rule: disk) and reads build.yml's
|
||||
# cache without writing it. aarch64 uses ubicloud-standard-16-arm so the
|
||||
# packaged binary and console smoke execute natively (placement rule: hardware).
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 210
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
RUSTUP_TOOLCHAIN: ${{ needs.prepare.outputs.toolchain }}
|
||||
RUSTFLAGS: "${{ matrix.rustflags }}"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout the pinned source commit
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.source_sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
# Tags cut before the manual packaging script still compile from their
|
||||
# own tree. The official zip/deb/rpm recipe, and confirm-source when the
|
||||
# tagged script does not implement it, come from the prepare checkout.
|
||||
- name: Checkout packaging recipe
|
||||
if: ${{ needs.prepare.outputs.packaging == 'tool' || needs.prepare.outputs.confirm_from == 'tool' }}
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tool_sha }}
|
||||
path: packaging-tools
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Confirm the pinned source
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_KIND: ${{ needs.prepare.outputs.source_kind }}
|
||||
SOURCE_NAME: ${{ needs.prepare.outputs.source_name }}
|
||||
SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
helper=scripts/release/package_manual_artifacts.sh
|
||||
if [[ ! -f "$helper" ]] || ! grep -q 'confirm-source)' "$helper"; then
|
||||
helper=packaging-tools/scripts/release/package_manual_artifacts.sh
|
||||
fi
|
||||
if [[ ! -f "$helper" ]] || ! grep -q 'confirm-source)' "$helper"; then
|
||||
printf 'no packaging script implements confirm-source\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
bash "$helper" confirm-source \
|
||||
--kind "$SOURCE_KIND" \
|
||||
--name "$SOURCE_NAME" \
|
||||
--sha "$SOURCE_SHA"
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: ${{ needs.prepare.outputs.toolchain }}
|
||||
target: ${{ matrix.target }}
|
||||
cache-shared-key: build-${{ matrix.target }}${{ matrix.glibc && format('-glibc-{0}', matrix.glibc) || '' }}${{ matrix.os == 'ubicloud-standard-16-arm' && '-ubicloud-arm' || '' }}
|
||||
# build.yml owns this key. This job only restores it.
|
||||
cache-save-if: 'false'
|
||||
install-cross-tools: ${{ matrix.cross }}
|
||||
install-build-packaging-tools: 'true'
|
||||
install-test-tools: 'false'
|
||||
|
||||
- name: Download static console assets
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CONSOLE_IDENTITY: ${{ needs.prepare.outputs.console }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PYCONSOLE'
|
||||
import json, os, pathlib, subprocess, zipfile
|
||||
value = json.loads(os.environ["CONSOLE_IDENTITY"])
|
||||
subprocess.run(["python3", ".github/actions/setup/download_release_asset.py",
|
||||
"--repository", "rustfs/console", "--tag", value["tag"], "--asset", value["name"],
|
||||
"--sha256", value["sha256"], "--output", "console.zip"], check=True)
|
||||
destination = pathlib.Path("rustfs/static")
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile("console.zip") as archive:
|
||||
for member in archive.infolist():
|
||||
path = pathlib.PurePosixPath(member.filename)
|
||||
if path.is_absolute() or ".." in path.parts or "\\" in member.filename:
|
||||
raise SystemExit("unsafe Console archive member")
|
||||
archive.extractall(destination)
|
||||
if not (destination / "index.html").is_file():
|
||||
raise SystemExit("Console archive has no index.html")
|
||||
PYCONSOLE
|
||||
rm -f console.zip
|
||||
|
||||
- name: Build RustFS
|
||||
shell: bash
|
||||
env:
|
||||
RUSTFS_BUILD_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
BUILD_OS: ${{ matrix.build_os }}
|
||||
BUILD_FEATURES: ${{ matrix.features }}
|
||||
BUILD_GLIBC: ${{ matrix.glibc }}
|
||||
BUILD_CROSS: ${{ matrix.cross }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
touch rustfs/build.rs
|
||||
|
||||
feature_args=()
|
||||
if [[ -n "$BUILD_FEATURES" ]]; then
|
||||
feature_args=(--features "$BUILD_FEATURES")
|
||||
fi
|
||||
|
||||
# gnu targets pin glibc 2.31 through cargo-zigbuild (<target>.<glibc>).
|
||||
target_arg="$BUILD_TARGET"
|
||||
if [[ -n "$BUILD_GLIBC" ]]; then
|
||||
target_arg="${target_arg}.${BUILD_GLIBC}"
|
||||
fi
|
||||
|
||||
profile_args=()
|
||||
if [[ "$BUILD_TARGET" == *-linux-gnu && "$BUILD_FEATURES" == pyroscope ]]; then
|
||||
profile_args=(--config 'profile.release.strip="none"')
|
||||
fi
|
||||
|
||||
case "$BUILD_CROSS" in
|
||||
true)
|
||||
cargo zigbuild --release --locked --target "$target_arg" "${feature_args[@]}" "${profile_args[@]}" -p rustfs --bin rustfs
|
||||
;;
|
||||
false)
|
||||
cargo build --release --locked --target "$target_arg" "${feature_args[@]}" "${profile_args[@]}" -p rustfs --bin rustfs
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected cross flag: %q\n' "$BUILD_CROSS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
bin="target/${BUILD_TARGET}/release/rustfs"
|
||||
version_output=$("$bin" --version)
|
||||
first=${version_output%%$'\n'*}
|
||||
if [[ "$first" != "rustfs ${RUSTFS_BUILD_VERSION}" ]]; then
|
||||
printf 'binary version line is %q, expected %q\n' "$first" "rustfs ${RUSTFS_BUILD_VERSION}" >&2
|
||||
printf '%s\n' "$version_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fxq "build target : ${BUILD_TARGET}" <<< "$version_output" || \
|
||||
! grep -Fxq "build os : ${BUILD_OS}" <<< "$version_output" || \
|
||||
! grep -Fxq "rust channel : stable-${BUILD_TARGET}" <<< "$version_output"; then
|
||||
printf 'release binary reports an unexpected compile target\n' >&2
|
||||
printf '%s\n' "$version_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$BUILD_GLIBC" ]]; then
|
||||
glibc_check=scripts/release/check_glibc.py
|
||||
if [[ ! -f "$glibc_check" ]]; then
|
||||
glibc_check=packaging-tools/scripts/release/check_glibc.py
|
||||
fi
|
||||
if [[ ! -f "$glibc_check" ]]; then
|
||||
printf 'glibc check script not found\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 "$glibc_check" "$BUILD_GLIBC" "$bin"
|
||||
fi
|
||||
|
||||
- name: Generate the CPU symbol catalog
|
||||
if: endsWith(matrix.target, '-linux-gnu') && matrix.features == 'pyroscope'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo metadata --locked --format-version 1 --filter-platform "$BUILD_TARGET" \
|
||||
--features rustfs/pyroscope > "$RUNNER_TEMP/cpu-catalog-metadata.json"
|
||||
features=$(jq -ce '
|
||||
(.packages[] | select(.name == "rustfs") | .id) as $id |
|
||||
.resolve.nodes[] | select(.id == $id) | .features | map(select(. != "default")) | sort
|
||||
' "$RUNNER_TEMP/cpu-catalog-metadata.json")
|
||||
release_dir="target/${BUILD_TARGET}/release"
|
||||
rm -f "$release_dir/rustfs.cpu-symbol-catalog.json" "$release_dir/rustfs.cpu-symbol-catalog.sha256"
|
||||
cargo run --locked -p rustfs-connect-symbol-catalog -- \
|
||||
"$release_dir/rustfs" "$(git rev-parse HEAD)" "$BUILD_TARGET" "$features" \
|
||||
"$release_dir/rustfs.cpu-symbol-catalog.json" > "$release_dir/rustfs.cpu-symbol-catalog.sha256"
|
||||
|
||||
- name: Install DEB and RPM tools
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ruby ruby-dev build-essential rpm fakeroot cpio
|
||||
sudo gem install fpm --version 1.17.0 --no-document
|
||||
|
||||
- name: Package zip, deb, and rpm
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
LIBC: ${{ matrix.libc }}
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
BUILD_FEATURES: ${{ matrix.features }}
|
||||
SOURCE_KIND: ${{ needs.prepare.outputs.source_kind }}
|
||||
SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
|
||||
PACKAGING: ${{ needs.prepare.outputs.packaging }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$PACKAGING" in
|
||||
source) script=scripts/release/package_manual_artifacts.sh ;;
|
||||
tool) script=packaging-tools/scripts/release/package_manual_artifacts.sh ;;
|
||||
*)
|
||||
printf 'unexpected packaging recipe: %q\n' "$PACKAGING" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [[ ! -f "$script" ]]; then
|
||||
printf 'packaging script not found: %s\n' "$script" >&2
|
||||
exit 1
|
||||
fi
|
||||
binary="target/${BUILD_TARGET}/release/rustfs"
|
||||
extra=()
|
||||
if [[ "$BUILD_TARGET" == *-linux-gnu && "$BUILD_FEATURES" == pyroscope ]]; then
|
||||
extra=(
|
||||
--zip-extra "target/${BUILD_TARGET}/release/rustfs.cpu-symbol-catalog.json"
|
||||
--zip-extra "target/${BUILD_TARGET}/release/rustfs.cpu-symbol-catalog.sha256"
|
||||
)
|
||||
fi
|
||||
bash "$script" package \
|
||||
--version "$PACKAGE_VERSION" \
|
||||
--arch "$ARCH" \
|
||||
--libc "$LIBC" \
|
||||
--binary "$binary" \
|
||||
--output dist \
|
||||
"${extra[@]}"
|
||||
printf 'Packaged %s from %s %s\n' "$PACKAGE_VERSION" "$SOURCE_KIND" "$SOURCE_SHA"
|
||||
|
||||
- name: Verify the packaged binary
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
LIBC: ${{ matrix.libc }}
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
BUILD_OS: ${{ matrix.build_os }}
|
||||
BUILD_FEATURES: ${{ matrix.features }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
stem="rustfs-linux-${ARCH}-${LIBC}-v${PACKAGE_VERSION}"
|
||||
package_file="dist/${stem}.zip"
|
||||
[[ -f "$package_file" && -f "dist/${stem}.deb" && -f "dist/${stem}.rpm" ]]
|
||||
[[ "$(find dist -maxdepth 1 -type f | wc -l)" -eq 3 ]]
|
||||
archive_members=$(unzip -Z1 "$package_file" | sort)
|
||||
if [[ "$BUILD_TARGET" == *-linux-gnu && "$BUILD_FEATURES" == pyroscope ]]; then
|
||||
expected_members=$(printf '%s\n' rustfs rustfs.cpu-symbol-catalog.json rustfs.cpu-symbol-catalog.sha256 | sort)
|
||||
else
|
||||
expected_members=$(printf '%s\n' rustfs)
|
||||
fi
|
||||
if [[ "$archive_members" != "$expected_members" ]]; then
|
||||
printf 'Linux package does not contain the expected files\n' >&2
|
||||
printf '%s\n' "$archive_members" >&2
|
||||
exit 1
|
||||
fi
|
||||
binary_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$binary_dir"' EXIT
|
||||
unzip -q "$package_file" rustfs -d "$binary_dir"
|
||||
version_output=$("$binary_dir/rustfs" --version)
|
||||
first=${version_output%%$'\n'*}
|
||||
if [[ "$first" != "rustfs ${PACKAGE_VERSION}" ]]; then
|
||||
printf 'packaged binary version line is %q, expected %q\n' "$first" "rustfs ${PACKAGE_VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fxq "build target : ${BUILD_TARGET}" <<< "$version_output" || \
|
||||
! grep -Fxq "build os : ${BUILD_OS}" <<< "$version_output"; then
|
||||
printf 'packaged binary reports an unexpected compile target\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify packaged console
|
||||
if: matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
LIBC: ${{ matrix.libc }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_file="dist/rustfs-linux-${ARCH}-${LIBC}-v${PACKAGE_VERSION}.zip"
|
||||
|
||||
find_free_port() {
|
||||
python3 - <<'PY'
|
||||
import socket
|
||||
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print(sock.getsockname()[1])
|
||||
PY
|
||||
}
|
||||
|
||||
package_dir=$(mktemp -d)
|
||||
data_dir=$(mktemp -d)
|
||||
log_file="${RUNNER_TEMP}/rustfs-console-smoke.log"
|
||||
server_pid=""
|
||||
trap '
|
||||
if [[ -n "${server_pid:-}" ]]; then
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$package_dir" "$data_dir"
|
||||
' EXIT
|
||||
|
||||
unzip -q "$package_file" -d "$package_dir"
|
||||
api_port=$(find_free_port)
|
||||
console_port=$(find_free_port)
|
||||
console_url="http://127.0.0.1:${console_port}/rustfs/console/"
|
||||
|
||||
"$package_dir/rustfs" server \
|
||||
--address "127.0.0.1:${api_port}" \
|
||||
--console-enable \
|
||||
--console-address "127.0.0.1:${console_port}" \
|
||||
--access-key console-smoke \
|
||||
--secret-key console-smoke-secret \
|
||||
"$data_dir" >"$log_file" 2>&1 &
|
||||
server_pid=$!
|
||||
|
||||
for _ in {1..40}; do
|
||||
response=$(curl --silent --output /dev/null --write-out '%{http_code} %{content_type}' "$console_url" || true)
|
||||
if [[ "$response" == 200\ text/html* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
|
||||
printf 'Console endpoint did not return 200 text/html: %s\n' "$response" >&2
|
||||
cat "$log_file"
|
||||
exit 1
|
||||
|
||||
- name: Upload packages
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packages-${{ matrix.target_id }}-v${{ needs.prepare.outputs.version }}
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 30
|
||||
@@ -44,13 +44,6 @@
|
||||
# well inside the 120-minute budget and buys deterministic infra. The
|
||||
# docker-capable self-hosted `dind-sm-standard-2` label was the alternative but
|
||||
# has fewer cores and reintroduces fleet-state risk for no reliability gain.
|
||||
#
|
||||
# Stack dimension (rustfs/backlog#2774, T3.3): one image, run with RUSTFS_S3_STACK=legacy and
|
||||
# =gateway. `compare` (scripts/mint_stack_diff.py) holds the gate that every case legacy passes
|
||||
# passes on gateway with no new failure. The gateway leg and `compare` are report-only until
|
||||
# T2.10. A gateway server that refuses to start stops its leg at "Wait for RustFS ready", before
|
||||
# mint runs; the summary says `skipped: gateway stack refuses startup: <why>`. The stack is read
|
||||
# from the server's own start-up line (scripts/dual_stack.py stack).
|
||||
|
||||
name: mint
|
||||
|
||||
@@ -115,9 +108,13 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
image:
|
||||
name: Build the RustFS image once
|
||||
runs-on: ubuntu-latest
|
||||
mint:
|
||||
# GitHub-hosted: reliably provides a running Docker daemon + buildx +
|
||||
# python3. See the header note (ci-2) for why the self-hosted sm-standard-4
|
||||
# label was abandoned (no docker.sock on the pod its only run landed on).
|
||||
# TODO(ci-8): scheduled-failure alerting (auto-open issue on a red weekly
|
||||
# run) is added by the ci-8 composite action; do not implement it here.
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
@@ -135,48 +132,6 @@ jobs:
|
||||
--cache-to "type=gha,mode=max,scope=${BUILDX_CACHE_SCOPE}" \
|
||||
-t rustfs-ci \
|
||||
-f Dockerfile.source .
|
||||
docker save rustfs-ci --output "${RUNNER_TEMP}/rustfs-ci.tar"
|
||||
|
||||
- name: Upload the image
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mint-image
|
||||
path: ${{ runner.temp }}/rustfs-ci.tar
|
||||
retention-days: 1
|
||||
|
||||
mint:
|
||||
name: mint (${{ matrix.stack }}${{ matrix.stack == 'gateway' && ', report-only until T2.10' || '' }})
|
||||
needs: [image]
|
||||
# Report-only until T2.10: the gateway stack cannot serve the suites yet.
|
||||
continue-on-error: ${{ matrix.stack == 'gateway' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
stack: [legacy, gateway]
|
||||
env:
|
||||
STACK: ${{ matrix.stack }}
|
||||
# GitHub-hosted: reliably provides a running Docker daemon + buildx +
|
||||
# python3. See the header note (ci-2) for why the self-hosted sm-standard-4
|
||||
# label was abandoned (no docker.sock on the pod its only run landed on).
|
||||
# sm-standard-2 has no docker.sock either (weekly runs failed at "Enable
|
||||
# buildx" from 2026-09-27 to 2026-11-08), so keep this job on ubuntu-latest.
|
||||
# TODO(ci-8): scheduled-failure alerting (auto-open issue on a red weekly
|
||||
# run) is added by the ci-8 composite action; do not implement it here.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download the image
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: mint-image
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Load the image
|
||||
run: docker load --input "${RUNNER_TEMP}/rustfs-ci.tar"
|
||||
|
||||
- name: Start RustFS
|
||||
run: |
|
||||
@@ -194,8 +149,6 @@ jobs:
|
||||
-e RUSTFS_SECRET_KEY="${S3_SECRET_KEY}" \
|
||||
-e RUSTFS_VOLUMES="/data/rustfs{0...3}" \
|
||||
-e RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true \
|
||||
-e RUSTFS_S3_STACK="${STACK}" \
|
||||
-e RUST_LOG=rustfs=info \
|
||||
-v /tmp/rustfs-mint:/data \
|
||||
rustfs-ci
|
||||
|
||||
@@ -291,50 +244,16 @@ jobs:
|
||||
mkdir -p artifacts/rustfs-mint
|
||||
docker logs rustfs-mint > artifacts/rustfs-mint/rustfs.log 2>&1 || true
|
||||
|
||||
- name: Prove which stack the server selected
|
||||
if: always()
|
||||
run: python3 scripts/dual_stack.py stack --expect "${STACK}" --out artifacts/stack.json artifacts/rustfs-mint
|
||||
|
||||
- name: Upload artifacts
|
||||
if: always() && env.ACT != 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mint-${{ matrix.stack }}
|
||||
name: mint
|
||||
path: artifacts/**
|
||||
|
||||
compare:
|
||||
name: Compare stacks (report-only until T2.10)
|
||||
needs: [mint]
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download both legs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: mint-{legacy,gateway}
|
||||
path: legs
|
||||
|
||||
# A leg that never uploaded leaves no directory: create both so that the
|
||||
# comparison reports "no result" for it instead of failing to find it.
|
||||
- name: Compare the stacks
|
||||
run: |
|
||||
# The shell runs with -e; the exit status is reported after the summary is written.
|
||||
set +e -u -o pipefail
|
||||
mkdir -p legs/mint-legacy legs/mint-gateway
|
||||
python3 scripts/mint_stack_diff.py legs/mint-legacy legs/mint-gateway | tee report.txt
|
||||
status="${PIPESTATUS[0]}"
|
||||
{ echo '## Mint: gateway versus legacy'; echo; echo '```'; cat report.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
|
||||
exit "$status"
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [image, mint]
|
||||
needs: [mint]
|
||||
# `always()` is required: without it this job is skipped when a needed
|
||||
# job fails. Alerts only for scheduled runs (backlog#1149 ci-8) — manual
|
||||
# dispatch failures are already watched by a human.
|
||||
|
||||
@@ -68,14 +68,11 @@ env:
|
||||
|
||||
jobs:
|
||||
minio-source:
|
||||
name: MinIO source (read-through, list-through, backfill, ${{ matrix.stack }}${{ matrix.stack == 'gateway' && ', report-only until T2.10' || '' }})
|
||||
name: MinIO source (read-through, list-through, backfill)
|
||||
# Keep this scheduled interoperability lane scoped to the upstream repo.
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
# The gateway leg is report-only until T2.10.
|
||||
continue-on-error: ${{ matrix.stack == 'gateway' }}
|
||||
strategy: { fail-fast: false, matrix: { stack: [legacy, gateway] } }
|
||||
env:
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
# Fixed credentials of the container this job starts and throws away;
|
||||
@@ -140,11 +137,6 @@ jobs:
|
||||
- name: Build the RustFS binary under test
|
||||
run: python3 scripts/e2e_binary.py build --bins
|
||||
|
||||
# A gateway binary that refuses to start skips the suite; the summary says why.
|
||||
- name: Gateway preflight
|
||||
if: matrix.stack == 'gateway'
|
||||
run: python3 scripts/dual_stack.py preflight --binary target/debug/rustfs --out "$RUNNER_TEMP/leg/stack.json"
|
||||
|
||||
# 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.
|
||||
@@ -154,19 +146,8 @@ jobs:
|
||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
||||
|
||||
- name: Run the interop cases against MinIO
|
||||
env:
|
||||
RUSTFS_S3_STACK: ${{ matrix.stack }}
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-logs
|
||||
run: python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
||||
|
||||
- name: Prove the stack and upload the leg
|
||||
if: always()
|
||||
uses: ./.github/actions/dual-stack-leg
|
||||
with:
|
||||
profile: e2e-odm-interop
|
||||
stack: ${{ matrix.stack }}
|
||||
logs: ${{ runner.temp }}/rustfs-e2e-logs
|
||||
|
||||
- name: Build the MinIO interop report
|
||||
if: always()
|
||||
uses: ./.github/actions/odm-interop-report
|
||||
@@ -190,7 +171,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: odm-interop-minio-${{ github.run_number }}-${{ github.run_attempt }}-${{ matrix.stack }}
|
||||
name: odm-interop-minio-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/odm-interop/minio
|
||||
target/nextest/e2e-odm-interop/junit.xml
|
||||
@@ -316,20 +297,6 @@ jobs:
|
||||
target/nextest/e2e-odm-interop/junit.xml
|
||||
retention-days: 14
|
||||
|
||||
dual-stack-summary:
|
||||
name: Both-stack diff summary
|
||||
needs: [minio-source]
|
||||
if: github.repository == 'rustfs/rustfs' && always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/dual-stack-summary
|
||||
with:
|
||||
profiles: e2e-odm-interop
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [minio-source, cloud-source]
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# s3-tests reference parity lane (rustfs/backlog#2773).
|
||||
#
|
||||
# Runs the ceph/s3-tests implemented list (scripts/s3-tests/implemented_tests.txt)
|
||||
# against every target in .config/reference-parity-targets.toml and fails on
|
||||
# any per-node-id difference between a candidate and the reference:
|
||||
#
|
||||
# - reference: the release pinned in .config/reference-binary.toml, downloaded
|
||||
# and sha256-verified by .github/actions/reference-binary;
|
||||
# - candidate targets: the debug binary built from this checkout exactly as
|
||||
# ci.yml's build-rustfs-debug-binary job builds it for S3 Implemented Tests.
|
||||
#
|
||||
# Every target runs the same harness (scripts/s3-tests/run.sh from this
|
||||
# checkout), test list and server configuration on its own hosted runner. A
|
||||
# target's failures get one rerun against a fresh server; a case that passes it
|
||||
# is flaky, not a difference. scripts/s3-tests/reference_parity.py judges the
|
||||
# result and honours .config/reference-parity-tolerated.txt, whose entries must
|
||||
# each match a real difference.
|
||||
#
|
||||
# The `differential` job adds the 73-operation dual-stack differential
|
||||
# (crates/e2e_test/src/dual_stack_differential, rustfs/backlog#2740) against the
|
||||
# same pinned reference: side A is the released binary (RUSTFS_E2E_REFERENCE_BINARY,
|
||||
# started without RUSTFS_S3_STACK), side B is the candidate built above on its
|
||||
# legacy stack. It reuses the candidate build artifact, never builds the candidate
|
||||
# twice, and prints reference_side, version_a and version_b from the report the test
|
||||
# wrote; it fails when side A was not the reference or both sides report the same
|
||||
# version. Every (op, field, a, b) difference goes to the job summary. Report-only
|
||||
# until the first result has been reviewed.
|
||||
#
|
||||
# Report-only: not part of the Test and Lint aggregate. Scheduled failures are
|
||||
# owned by the scheduled-validation watchdog (.github/scheduled-validations.json).
|
||||
|
||||
name: Reference Parity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/reference-parity.yml"
|
||||
- ".github/actions/reference-binary/**"
|
||||
- ".config/reference-binary.toml"
|
||||
- ".config/reference-parity-targets.toml"
|
||||
- ".config/reference-parity-tolerated.txt"
|
||||
- "scripts/s3-tests/reference_parity.py"
|
||||
- "scripts/junit_diff.py"
|
||||
- "crates/e2e_test/src/dual_stack_differential/**"
|
||||
schedule:
|
||||
- cron: "37 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# Matches ci.yml: these variables are hashed into the rust-cache key, so the
|
||||
# candidate build reads the same ci-dev cache as build-rustfs-debug-binary.
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
name: Plan parity targets
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
targets: ${{ steps.targets.outputs.targets }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate target list
|
||||
id: targets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
targets="$(python3 scripts/s3-tests/reference_parity.py targets --config .config/reference-parity-targets.toml)"
|
||||
echo "targets=${targets}" >> "$GITHUB_OUTPUT"
|
||||
echo "Parity targets: ${targets}"
|
||||
|
||||
build-candidate:
|
||||
name: Build candidate binary
|
||||
# Critical-path CPU and cache ownership: the same debug build as ci.yml's
|
||||
# build-rustfs-debug-binary, reading the ci-dev cache written on sm-standard-4.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev-ea7eec74fe14
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build debug binary
|
||||
run: python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
|
||||
|
||||
- name: Upload candidate binary
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reference-parity-candidate-binary
|
||||
overwrite: true
|
||||
path: |
|
||||
target/debug/rustfs
|
||||
target/debug/rustfs.e2e.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
s3tests:
|
||||
name: s3-tests (${{ matrix.target.name }})
|
||||
needs: [ plan, build-candidate ]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: ${{ fromJSON(needs.plan.outputs.targets) }}
|
||||
env:
|
||||
TARGET_NAME: ${{ matrix.target.name }}
|
||||
TARGET_KIND: ${{ matrix.target.binary }}
|
||||
# Above this many first-run failures the target is systemically broken;
|
||||
# a rerun would only repeat it, so the first run is judged as is.
|
||||
RERUN_CAP: "50"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fetch pinned reference binary
|
||||
id: reference
|
||||
if: matrix.target.binary == 'reference'
|
||||
uses: ./.github/actions/reference-binary
|
||||
|
||||
- name: Download candidate binary
|
||||
if: matrix.target.binary == 'candidate'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: reference-parity-candidate-binary
|
||||
path: target/debug
|
||||
|
||||
- name: Select target binary
|
||||
env:
|
||||
REFERENCE_PATH: ${{ steps.reference.outputs.path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TARGET_KIND" in
|
||||
reference) binary="$REFERENCE_PATH" ;;
|
||||
candidate) chmod +x target/debug/rustfs; binary="$PWD/target/debug/rustfs" ;;
|
||||
*) echo "unknown target binary kind: $TARGET_KIND" >&2; exit 1 ;;
|
||||
esac
|
||||
test -x "$binary"
|
||||
echo "TARGET_BINARY=$binary" >> "$GITHUB_ENV"
|
||||
mkdir -p "parity/$TARGET_NAME"
|
||||
{
|
||||
echo "target=$TARGET_NAME"
|
||||
echo "binary=$TARGET_KIND"
|
||||
echo "sha256=$(sha256sum "$binary" | cut -d' ' -f1)"
|
||||
echo "version=$("$binary" --version | tr '\n' ' ')"
|
||||
} | tee "parity/$TARGET_NAME/target.txt"
|
||||
|
||||
- name: Apply target environment
|
||||
env:
|
||||
TARGET_JSON: ${{ toJSON(matrix.target) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 scripts/s3-tests/reference_parity.py export-env --target-json "$TARGET_JSON" \
|
||||
| tee -a "$GITHUB_ENV" "parity/$TARGET_NAME/target-env.txt"
|
||||
|
||||
- name: Run implemented s3-tests
|
||||
run: |
|
||||
set -uo pipefail
|
||||
report="parity/$TARGET_NAME"
|
||||
harness="artifacts/s3tests-single"
|
||||
|
||||
# Same invocation as ci.yml's S3 Implemented Tests, with a fresh data
|
||||
# root and port per run. $1 optionally replaces the test list.
|
||||
run_suite() {
|
||||
local list="$1" label="$2" root port
|
||||
root="${RUNNER_TEMP}/rustfs-s3tests-${label}"
|
||||
rm -rf "$harness" "$root"
|
||||
mkdir -p "$root"
|
||||
port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
|
||||
env DEPLOY_MODE=binary \
|
||||
RUSTFS_BINARY="$TARGET_BINARY" \
|
||||
TEST_MODE=single \
|
||||
MAXFAIL=0 \
|
||||
XDIST=4 \
|
||||
S3_PORT="$port" \
|
||||
DATA_ROOT="$root" \
|
||||
S3TESTS_CONF="$harness/s3tests.conf" \
|
||||
${list:+IMPLEMENTED_TESTS_FILE="$list"} \
|
||||
./scripts/s3-tests/run.sh
|
||||
}
|
||||
|
||||
# A run is usable only if it produced a JUnit report; test failures
|
||||
# are data for the comparison, a missing report is a lane failure.
|
||||
keep() {
|
||||
local prefix="$1"
|
||||
for file in pytest.log compat-report.md; do
|
||||
if [[ -f "$harness/$file" ]]; then cp "$harness/$file" "$report/${prefix}${file}"; fi
|
||||
done
|
||||
if [[ -f "$harness/rustfs-single/rustfs.log" ]]; then cp "$harness/rustfs-single/rustfs.log" "$report/${prefix}rustfs.log"; fi
|
||||
test -s "$harness/junit.xml" || { echo "::error::$TARGET_NAME produced no JUnit report (${prefix:-first} run)"; return 1; }
|
||||
cp "$harness/junit.xml" "$report/${prefix}junit.xml"
|
||||
}
|
||||
|
||||
run_suite "" first
|
||||
echo "first_run_exit=$?" >> "$report/target.txt"
|
||||
keep "" || exit 1
|
||||
cp "$harness/selected-nodeids.txt" "$report/selected-nodeids.txt" || exit 1
|
||||
|
||||
failures="$(python3 scripts/s3-tests/reference_parity.py failed --report "$report")" || exit 1
|
||||
count="$(printf '%s' "$failures" | grep -c . || true)"
|
||||
echo "first_run_failures=$count" >> "$report/target.txt"
|
||||
if (( count == 0 )); then
|
||||
exit 0
|
||||
fi
|
||||
if (( count > RERUN_CAP )); then
|
||||
echo "::warning::$TARGET_NAME has $count failures (cap $RERUN_CAP); judging the first run without a rerun"
|
||||
echo "rerun=skipped-over-cap" >> "$report/target.txt"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$failures" > "$RUNNER_TEMP/rerun-tests.txt"
|
||||
run_suite "$RUNNER_TEMP/rerun-tests.txt" rerun
|
||||
echo "rerun_exit=$?" >> "$report/target.txt"
|
||||
keep rerun- || exit 1
|
||||
|
||||
- name: Upload target report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reference-parity-report-${{ matrix.target.name }}
|
||||
overwrite: true
|
||||
path: parity/${{ matrix.target.name }}/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
differential:
|
||||
name: Dual-stack differential (reference vs candidate)
|
||||
needs: [ build-candidate ]
|
||||
# Compiles the e2e_test harness against the ci-dev cache like the other
|
||||
# sm-standard-4 jobs; the candidate server itself is the downloaded artifact.
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NO_PROXY: 127.0.0.1,localhost,::1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev-ea7eec74fe14
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Fetch pinned reference binary
|
||||
id: reference
|
||||
uses: ./.github/actions/reference-binary
|
||||
|
||||
- name: Download candidate binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: reference-parity-candidate-binary
|
||||
path: target/debug
|
||||
|
||||
- name: Run dual-stack differential
|
||||
id: differential
|
||||
continue-on-error: true
|
||||
env:
|
||||
REFERENCE_PATH: ${{ steps.reference.outputs.path }}
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-differential-logs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export no_proxy="$NO_PROXY"
|
||||
chmod +x target/debug/rustfs
|
||||
test -x "$REFERENCE_PATH"
|
||||
mkdir -p "$RUSTFS_E2E_LOG_DIR"
|
||||
# Side A: the released binary. Side B: the candidate on its legacy stack.
|
||||
export RUSTFS_E2E_REFERENCE_BINARY="$REFERENCE_PATH"
|
||||
test_name='dual_stack_differential::tests::both_stacks_answer_every_operation_identically'
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- \
|
||||
cargo test --locked -p e2e_test --lib "$test_name" -- --exact --nocapture \
|
||||
2>&1 | tee "$RUNNER_TEMP/differential-test.log"
|
||||
|
||||
- name: Summarize report and verify the reference side
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import glob, json, os, sys
|
||||
|
||||
summary = os.environ["GITHUB_STEP_SUMMARY"]
|
||||
reports = sorted(glob.glob("target/dual-stack/self-comparison-*.json"))
|
||||
lines = ["## Dual-stack differential: reference vs candidate", ""]
|
||||
failures = []
|
||||
if len(reports) != 1:
|
||||
failures.append(f"expected exactly one self-comparison report, found {len(reports)}: {reports}")
|
||||
else:
|
||||
report = json.load(open(reports[0]))
|
||||
side = report.get("reference_side")
|
||||
version_a = report.get("version_a")
|
||||
version_b = report.get("version_b")
|
||||
lines += [
|
||||
f"- report: `{reports[0]}`",
|
||||
f"- reference_side: `{side}`",
|
||||
f"- version_a: `{(version_a or '').splitlines()[0] if version_a else version_a}`",
|
||||
f"- version_b: `{(version_b or '').splitlines()[0] if version_b else version_b}`",
|
||||
f"- stack_a: `{report.get('stack_a')}`, stack_b: `{report.get('stack_b')}`",
|
||||
f"- compared_ops: {report.get('compared_ops')}, compared_cases: {report.get('compared_cases')}",
|
||||
"",
|
||||
]
|
||||
print(f"reference_side={side}\nversion_a={version_a}\nversion_b={version_b}")
|
||||
lines += ["```", "version_a:", version_a or "", "", "version_b:", version_b or "", "```", ""]
|
||||
if side != "a":
|
||||
failures.append(f"reference_side is {side!r}, expected 'a'")
|
||||
if not version_a or not version_b:
|
||||
failures.append("version_a or version_b is missing")
|
||||
elif version_a == version_b:
|
||||
failures.append("version_a equals version_b: the reference was replaced by the candidate")
|
||||
diffs = report.get("differences") or []
|
||||
lines.append(f"### Differences: {len(diffs)}")
|
||||
by_op = {}
|
||||
for d in diffs:
|
||||
by_op.setdefault(d["op"], []).append(d)
|
||||
for op in sorted(by_op):
|
||||
lines += ["", f"#### {op}", "", "| case | field | a (reference) | b (candidate) |", "| --- | --- | --- | --- |"]
|
||||
for d in by_op[op]:
|
||||
cell = lambda v: "`" + str(v).replace("|", "\\|").replace("`", "'") + "`"
|
||||
lines.append(f"| {d['case']} | {d['field']} | {cell(d['a'])} | {cell(d['b'])} |")
|
||||
if failures:
|
||||
lines += ["", "### Observation check failed", ""] + [f"- {f}" for f in failures]
|
||||
open(summary, "a").write("\n".join(lines) + "\n")
|
||||
print("\n".join(lines))
|
||||
for f in failures:
|
||||
print(f"::error::{f}")
|
||||
sys.exit(1 if failures else 0)
|
||||
PY
|
||||
|
||||
- name: Upload differential reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reference-differential-report
|
||||
overwrite: true
|
||||
path: |
|
||||
target/dual-stack/*.json
|
||||
${{ runner.temp }}/differential-test.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Fail when the differential found differences
|
||||
if: steps.differential.outcome == 'failure'
|
||||
run: |
|
||||
echo "::error::the dual-stack differential failed; see the job summary for every (op, field, a, b)"
|
||||
exit 1
|
||||
|
||||
compare:
|
||||
name: Compare targets
|
||||
needs: [ plan, s3tests ]
|
||||
if: always() && needs.plan.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download target reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: reference-parity-report-*
|
||||
path: parity-download
|
||||
|
||||
- name: Compare per node id
|
||||
env:
|
||||
TARGETS: ${{ needs.plan.outputs.targets }}
|
||||
S3TESTS_RESULT: ${{ needs.s3tests.result }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
mkdir -p parity
|
||||
for dir in parity-download/reference-parity-report-*; do
|
||||
if [[ -d "$dir" ]]; then mv "$dir" "parity/${dir#parity-download/reference-parity-report-}"; fi
|
||||
done
|
||||
python3 scripts/s3-tests/reference_parity.py compare \
|
||||
--targets-json "$TARGETS" \
|
||||
--reports parity \
|
||||
--tolerated .config/reference-parity-tolerated.txt \
|
||||
--markdown "$GITHUB_STEP_SUMMARY" \
|
||||
--json parity/diff.json > parity/diff.md
|
||||
status=$?
|
||||
cat parity/diff.md
|
||||
if [[ "$S3TESTS_RESULT" != "success" ]]; then
|
||||
echo "::error::s3-tests target jobs finished with '$S3TESTS_RESULT'"
|
||||
status=1
|
||||
fi
|
||||
exit "$status"
|
||||
|
||||
- name: Upload parity diff
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: reference-parity-diff
|
||||
overwrite: true
|
||||
path: |
|
||||
parity/diff.md
|
||||
parity/diff.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
@@ -7,27 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Notifications and audit
|
||||
|
||||
- Notify and audit targets that keep the default `queue_dir` now work on native Windows: the defaults `/opt/rustfs/events` and `/opt/rustfs/audit` resolve per node, to `%ProgramData%\RustFS\events` and `%ProgramData%\RustFS\audit` on Windows, instead of failing validation as non-absolute paths. The new `RUSTFS_TARGET_STORE_DIR` (Helm `config.rustfs.target_store_dir`) relocates both default queues on any platform. Linux and macOS behavior is unchanged when it is unset, persisted configuration is unchanged, and pending entries are not migrated when the root changes. See `docs/operations/target-queue-storage.md`.
|
||||
|
||||
### Deployment
|
||||
|
||||
- Before upgrading, verify that the configured root access key is at least 3 bytes and the root secret key is at least 8 bytes. RustFS now rejects shorter values at startup and identifies the setting to update. Version 1.0.5 accepted these settings at startup and returned HTTP 500 when root requested STS credentials. Coordinate root credential changes across all nodes and clients. See [root credential rotation considerations](docs/operations/two-factor-auth.md#root-credentials-cannot-be-changed-at-runtime).
|
||||
|
||||
- Existing volumes created by older root-running container images must be writable by the non-root runtime identity before upgrading. The default container UID/GID is `10001:10001`; stop the affected instances, verify host mount paths, and for ordinary bind mounts run `sudo chown -R 10001:10001 /actual/data /actual/logs` on each RustFS data/log mount. Account for ACLs, NFS, Kubernetes, and rootless UID mappings. Image-build ownership changes do not migrate mounted data. See the Docker Quick Start in [README.md](README.md).
|
||||
- Local disk initialization now checks file creation, writing, and deletion in the data root and fixed internal metadata directories. Unwritable disks are rejected using existing startup/quorum rules, with filesystem errors and Unix runtime/owner IDs logged for permission failures, instead of admitting those disks and discovering the mismatch on a later write. Existing object trees are not scanned and volume ownership is never changed automatically.
|
||||
|
||||
### Security
|
||||
|
||||
- **Key-scoped KMS policy statements now hold for every spelling of an AWS key**: a statement such as `Deny kms:Decrypt` on `arn:aws:kms:::key/<key-id>` was matched against the key identifier exactly as the request spelled it, so naming the same key by its AWS ARN (`arn:aws:kms:<region>:<account>:key/<key-id>`) was evaluated as a different key and the `Deny` did not apply. With `RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY=true` and the AWS KMS backend, an identity allowed on all keys but denied one could still write under that key, and read objects stored under its ARN spelling; the per-key admin endpoints were affected the same way. A key ARN is now evaluated as its key id, on the SSE-KMS data path and the admin endpoints alike. A request that names a key by alias, which only the backend can resolve, now fails closed against key-scoped statements: every key-scoped `Deny` applies and only an `Allow` on every key covers it. The KMS deletion impact report recognizes the default key and bucket default-encryption references across the id and ARN spellings as well. Deployments without key-scoped KMS statements, and the other KMS backends, whose key ids have a single spelling, are unaffected.
|
||||
|
||||
- **Header-signed SigV4 requests honour only signed headers** (GHSA-xm99-m3gq-83g8): a request authenticated with a SigV4 `Authorization` header that carries an `x-amz-*` request header not listed in its `SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3 and the presigned rule from GHSA-g8w9-qw9q-fghr. Previously anyone holding one header-signed `PutObject` request could add an unsigned `x-amz-copy-source` and turn it into a `CopyObject` that ran with the signer's permissions, copying any object the signer could read into the target. An `Authorization` header whose algorithm token is not `AWS4-HMAC-SHA256` is now rejected instead of being verified as SigV4. The request-envelope headers `x-amz-content-sha256`, `x-amz-decoded-content-length`, `x-amz-trailer` and `x-amz-checksum-algorithm` (the same set the upstream `s3s` fix exempts) and `x-amz-cf-id` (CloudFront) remain tolerated unsigned; AWS SDKs and RustFS's own signers already sign every other `x-amz-*` header. SigV2, JWT and anonymous requests are unchanged.
|
||||
|
||||
### Replication
|
||||
|
||||
- Site replication now seals an SSE-KMS replica under the peer's key of the same name as the source object's key. The replica write used to carry only the `aws:kms` intent, so an object written under a key named in the request was sealed under the peer's bucket default or site default instead, and with `RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY=true` a caller scoped to the named key could read the object on the site that took the write but got `AccessDenied` for its replica. This changes the default outbound request to site-replication peers: every key name in use, including each site's default key name, must exist on every site, or the affected SSE-KMS objects stay `FAILED` until the key is created. Existing replicas are not rewritten. Bucket replication through a remote target still sends the intent only.
|
||||
- Site replication now replicates SSE-KMS objects when the receiving site runs with `RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY=true`. The receiving site authorizes each replica write against the KMS key it resolves to, and the managed `site-replicator-0` account held no `kms` grant, so every SSE-KMS object stayed `FAILED` with `AccessDenied` while SSE-S3 and SSE-C objects replicated. The account's embedded policy now grants `kms:GenerateDataKey` on every key; existing accounts are refreshed by the reconcile tick and the failed objects catch up afterwards. It is deliberately not granted `kms:Decrypt`, so reads proxied to a peer for an SSE-KMS object are still refused under enforcement. Bucket replication through a remote target needs the same grant on the identity whose credentials the target uses. Sites that do not enable enforcement are unaffected.
|
||||
- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
|
||||
- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
|
||||
- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
|
||||
@@ -43,11 +33,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **KMS keys could not be described by ARN through the path route**: `GET /rustfs/admin/v3/kms/keys/{key_id}` read the path segment verbatim, so a key id that has to be percent-encoded to fit in one segment — an AWS key ARN, with its `:` and `/` — was looked up, authorized and audited under its encoded spelling and answered `404`. The segment is now percent-decoded first; a segment that is not valid UTF-8 after decoding is refused with `400 InvalidArgument`. The `describe-key?keyId=` form was unaffected.
|
||||
|
||||
- **SSE-KMS objects written through the AWS KMS backend could not be read**: `GetObject`, `CopyObject` and every other read of an `aws:kms` object failed with `400 InvalidRequest` ("SSE-S3 requires RUSTFS_SSE_S3_MASTER_KEY to be set ...") on a deployment using `RUSTFS_KMS_BACKEND=aws`, although the write and `HeadObject` succeeded. The AWS backend stored the `GenerateDataKey` blob as it came from AWS, the read path took those unrecognized bytes for a locally wrapped key, and AWS was never asked to decrypt. The backend now stores the blob inside the same data-key envelope the other backends use, and a blob stored without one is routed to AWS when the AWS backend is active. Objects written by 1.0.4 and 1.0.5 are intact and become readable after the upgrade, with no rewrite. Setting `RUSTFS_SSE_S3_MASTER_KEY` was never a workaround. An object written by this release is not readable by an older one; the same was already true of every object the older release wrote itself.
|
||||
- **Immediate KMS key deletion no longer reports success without destroying the key** (behavior change): with `RUSTFS_KMS_ALLOW_IMMEDIATE_DELETION=true`, a `DELETE /rustfs/admin/v3/kms/keys/delete` request with `force_immediate` against an `Enabled` or `Disabled` key on the Vault KV2 or Vault Transit backend returned success but only moved the key to `PendingDeletion`; the key material stayed in Vault and objects encrypted under it remained readable, while the Local backend destroyed the key in that one request. Key destruction is now the same two explicit steps on every backend: schedule the deletion, then either let the waiting window elapse or send `force_immediate` to destroy the pending key early. `force_immediate` against a key that is not `PendingDeletion` is refused with `400` and changes nothing, so a success response always means the key is gone and no single request can destroy a key in use. Automation that destroyed Local keys with one `force_immediate` request must schedule the deletion first. On Vault Transit, a read under a key that no longer exists in Vault now fails with `KMS.NotFoundException` instead of `503 ServiceUnavailable`. No persisted format changes. See `docs/operations/kms-key-destruction.md`.
|
||||
- **`mc admin info` and `mc admin replicate resync` work again**: since the cluster snapshot view (#3803), `GET /v3/info` and `GET /v3/storageinfo` returned the madmin `InfoMessage` / `StorageInfo` nested under an `info` key next to `admin_discovery` (and, for `/info`, `bitrotSelftest`). madmin decodes those bodies directly, so `mc admin info` printed empty values and `mc admin replicate resync start/status` rejected every alias with "alias provided is not part of cluster replication" because the local `deploymentID` read as empty. Requests on the MinIO-compatible `/minio/admin/v3` prefix now receive the bare madmin body again; `/rustfs/admin/v3`, which rc and the console use, keeps the envelope unchanged.
|
||||
- **Replication status writebacks no longer invalidate scanner cycles**: every replication status writeback (`persist_replication_state_if_current` in `Update` mode, including the heals the scanner queues during its own walk) marked the bucket dirty on the namespace path, advanced the dirty-usage generation and woke the scanner, so a site-replication resync or any steadily replicated bucket ended every cycle `superseded`, the authoritative usage baseline was never certified, and the console reported usage freshness as stale for as long as replication ran (#8375). Those writebacks are now status class: they do not touch the dirty-usage generation, the activity snapshot, the dirty-usage wake or the durable dirty journal, and only the process-local producer coverage is recorded. They need no rescan queue, because a deployment with an active replication rule already walks every bucket on every cycle and that walk refreshes `replication_stats`. Tier-expiration cleanup, which removes data, stays on the namespace path. No persisted format, protocol, admin API or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
|
||||
- **A bucket written during a scanner walk no longer supersedes the whole cycle**: the post-walk cluster activity proof required the entire activity snapshot and the dirty-usage generation to be unchanged, so a single-node deployment with one bucket written every few seconds never reached a `Complete` cycle, `usage_snapshot_converged` never became true and the authoritative usage baseline was never certified (#8494). Dirty usage is now certified per bucket: a structurally complete walk that the guardrail did not veto is `Complete`, buckets whose dirty generation still matches the start snapshot are cleared, and buckets written during the walk stay dirty and are published as the pending set, so `usage_snapshot_converged` means "no bucket pending" and the next cycle is scoped to the pending buckets. Such a cycle writes the authoritative usage object; the observed object is now selected by an explicit observational mark that vetoed cycles carry. The guardrail itself vetoes only on the fields that change which namespace the walk described (peer set, process instance, protocol version, topology, maintenance generation, data movement, movement generation, publication blocked) and reports the field and host in the scanner log and in the new `usage_freshness` fields `guardrail_veto_total`, `last_guardrail_veto_unix_secs` and `last_guardrail_veto_reason`. In a distributed deployment a remote peer whose dirty usage moved during the walk still supersedes the cycle until scoped acknowledgements can name its buckets. `.usage.v2.json` gains the additive, default-when-absent fields `usage_snapshot_pending_buckets`, `usage_snapshot_pending_bucket_count` and `usage_snapshot_observational`; older readers ignore them and treat the snapshot as an authoritative but unconverged publication. Quota is unchanged: an uncertified bucket keeps its last usage value as the floor and live writes do not advance it. No protocol or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
|
||||
@@ -75,7 +60,6 @@ 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
|
||||
- **Multiple bucket regions**: `RUSTFS_REGION` / `--region` accepts a comma-separated list. The first entry stays the server's own region (request signing and `GetBucketLocation` are unchanged); every entry is accepted as a `CreateBucket` `LocationConstraint`, so one deployment can stand in for buckets that live in several AWS regions (local development and test rigs) now that location constraints are validated (#8739). Regions outside the list are still rejected with `InvalidLocationConstraint`. A single region behaves exactly as before; a comma was never a valid region, so no existing value changes meaning.
|
||||
- **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`
|
||||
|
||||
Generated
+244
-394
File diff suppressed because it is too large
Load Diff
+28
-36
@@ -26,7 +26,6 @@ members = [
|
||||
"crates/ecstore", # Erasure coding storage implementation
|
||||
"crates/e2e_test", # End-to-end test suite
|
||||
"crates/filemeta", # File metadata management
|
||||
"crates/gateway-adapter", # RustFS implementations of the gateway extension traits
|
||||
"crates/heal", # Erasure set and object healing
|
||||
"crates/heal-contracts", # Heal request/response channel contracts
|
||||
"crates/iam", # Identity and Access Management
|
||||
@@ -94,7 +93,6 @@ redundant_clone = "warn"
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.5" }
|
||||
rustfs-gateway-adapter = { path = "crates/gateway-adapter", version = "1.0.5" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.5" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.5" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.5" }
|
||||
@@ -149,14 +147,8 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.5" }
|
||||
# engine crates move onto from s3s (rustfs/backlog#2745). The one pin of the
|
||||
# gateway in this workspace; no crate may enable a `compat-s3s*` feature except
|
||||
# the s3s-facing edge of the rustfs binary.
|
||||
rustfs-gateway-types = { git = "https://github.com/rustfs/gateway.git", rev = "7915d06950f88611d883f6ab3d95efc90c65dec0", version = "0.40.0", default-features = false }
|
||||
rustfs-gateway-stream = { git = "https://github.com/rustfs/gateway.git", rev = "7915d06950f88611d883f6ab3d95efc90c65dec0", version = "0.2.0", default-features = false }
|
||||
# The gateway framework itself, consumed for its extension traits by the ring-2
|
||||
# adapter. `default-features = false` keeps the `server` feature, and with it the
|
||||
# server crate, out of the build.
|
||||
rustfs-gateway = { git = "https://github.com/rustfs/gateway.git", rev = "7915d06950f88611d883f6ab3d95efc90c65dec0", version = "0.62.0", default-features = false }
|
||||
# Test-only: builds the `EffectiveHost` a host-resolution test queries with.
|
||||
rustfs-gateway-http = { git = "https://github.com/rustfs/gateway.git", rev = "7915d06950f88611d883f6ab3d95efc90c65dec0", default-features = false }
|
||||
rustfs-gateway-types = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.32.0", default-features = false }
|
||||
rustfs-gateway-stream = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.2.0", default-features = false }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -211,7 +203,7 @@ bytesize = "2.7.0"
|
||||
byteorder = "1.5.0"
|
||||
flatbuffers = "25.12.19"
|
||||
form_urlencoded = "1.2.2"
|
||||
prost = "0.14.5"
|
||||
prost = "0.14.4"
|
||||
quick-xml = "0.42.0"
|
||||
rmp = { version = "0.8.15" }
|
||||
rmp-serde = { version = "1.3.1" }
|
||||
@@ -240,7 +232,7 @@ p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
|
||||
rsa = { version = "=0.10.0-rc.18" }
|
||||
rustls = { default-features = false, version = "0.23.46" }
|
||||
rustls-native-certs = "0.8"
|
||||
rustls-pki-types = "1.16.0"
|
||||
rustls-pki-types = "1.15.1"
|
||||
x509-parser = "0.18.1"
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.1"
|
||||
@@ -251,7 +243,7 @@ proptest = "1"
|
||||
# Time and Date
|
||||
chrono = { version = "0.4.45" }
|
||||
humantime = "2.4.0"
|
||||
jiff = { version = "0.2.43" }
|
||||
jiff = { version = "0.2.39" }
|
||||
time = { version = "0.3.55" }
|
||||
|
||||
# Database
|
||||
@@ -261,22 +253,22 @@ tokio-postgres-rustls = "0.14.0"
|
||||
|
||||
# Utilities and Tools
|
||||
anyhow = "1.0.104"
|
||||
arc-swap = "1.9.5"
|
||||
arc-swap = "1.9.3"
|
||||
rustfs-tokio-tar = { version = "0.7.1" }
|
||||
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
|
||||
tar-codec = "0.0.16"
|
||||
tar-framing = "0.0.16"
|
||||
atoi = "3.1.1"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.13.2" }
|
||||
aws-config = { version = "1.13.0" }
|
||||
aws-credential-types = { version = "1.3.1" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.131.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.159.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.126.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.129.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.157.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.124.0" }
|
||||
aws-smithy-async = { version = "1.3.1" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.5.2" }
|
||||
aws-smithy-runtime-api = { version = "1.21.0" }
|
||||
aws-smithy-types = { version = "1.9.4" }
|
||||
aws-smithy-runtime-api = { version = "1.19.2" }
|
||||
aws-smithy-types = { version = "1.9.1" }
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "9.1.1"
|
||||
clap = { version = "4.6.7" }
|
||||
@@ -292,10 +284,10 @@ derive_builder = "0.20.2"
|
||||
ed25519-dalek = "3.0.0"
|
||||
enumset = "1.1.15"
|
||||
faster-hex = "1.0.0"
|
||||
flate2 = "1.1.11"
|
||||
flate2 = "1.1.10"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.22.0"
|
||||
google-cloud-auth = "1.19.0"
|
||||
google-cloud-storage = "1.21.0"
|
||||
google-cloud-auth = "1.18.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
|
||||
# every authenticator app expects). Already in the graph transitively.
|
||||
@@ -319,7 +311,7 @@ moka = { version = "0.12.16" }
|
||||
netif = "0.1.6"
|
||||
num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.13.0"
|
||||
object = { version = "=0.41.0", default-features = false }
|
||||
object = { version = "=0.40.0", default-features = false }
|
||||
parking_lot = "0.13.0"
|
||||
same-file = "1.0.6"
|
||||
path-absolutize = "4.0.1"
|
||||
@@ -340,7 +332,7 @@ rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||
redis = { version = "1.8.1" }
|
||||
rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.6" }
|
||||
rust-embed = { version = "8.14.0" }
|
||||
rust-embed = { version = "8.13.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
|
||||
s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0" }
|
||||
@@ -351,13 +343,13 @@ legacy-s3s = { package = "s3s", git = "https://github.com/s3s-project/s3s", rev
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.4"
|
||||
smallvec = { version = "1.16.5" }
|
||||
smallvec = { version = "1.16.4" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.4.1" }
|
||||
starshard = { version = "2.4.0" }
|
||||
strum = { version = "0.28.0" }
|
||||
sync_wrapper = "1.0.2"
|
||||
sysinfo = "0.40.3"
|
||||
sysinfo = "0.40.1"
|
||||
temp-env = "0.3.6"
|
||||
tempfile = "3.27.0"
|
||||
test-case = "3.4.1"
|
||||
@@ -371,7 +363,7 @@ tracing-subscriber = { version = "0.3.23" }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.29.2" }
|
||||
uuid = { version = "1.29.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
@@ -379,13 +371,13 @@ winapi-util = "0.1.11"
|
||||
windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.19" }
|
||||
zip = "9.5.1"
|
||||
zstd = "0.14.4"
|
||||
zip = "9.3.0"
|
||||
zstd = "0.14.3"
|
||||
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
metrics-util = "0.20"
|
||||
dial9 = { version = "0.5.8", default-features = false }
|
||||
dial9 = { version = "0.5.7", default-features = false }
|
||||
opentelemetry = { version = "0.33.1" }
|
||||
opentelemetry-appender-tracing = { version = "0.33.1" }
|
||||
opentelemetry-otlp = { version = "0.33.1" }
|
||||
@@ -397,19 +389,19 @@ pyroscope = { version = "2.1.5" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.24.0" }
|
||||
unftp-core = "0.1.1"
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "12.2.1" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.65.1" }
|
||||
russh-sftp = "3.0.1"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.12.0"
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.7.4" }
|
||||
rustfs-mimalloc = { version = "0.7.3" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.31.2", default-features = false }
|
||||
hotpath = { version = "0.31.1", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.49" }
|
||||
|
||||
|
||||
+4
-22
@@ -24,10 +24,9 @@
|
||||
#
|
||||
# Supports cross-compilation for amd64 and arm64 via TARGETPLATFORM.
|
||||
|
||||
# Do not declare TARGETPLATFORM, BUILDPLATFORM or TARGETARCH here: a global
|
||||
# declaration without a value shadows BuildKit's automatic values, so the
|
||||
# stage-level re-declarations below see empty strings. They are only
|
||||
# declared inside the stage that uses them.
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETARCH
|
||||
ARG RUSTFS_BUILD_FEATURES=""
|
||||
ARG RC_RELEASE=latest
|
||||
|
||||
@@ -170,24 +169,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
;; \
|
||||
esac
|
||||
|
||||
# TARGETARCH is only provided by BuildKit. Fall back to TARGETPLATFORM (for
|
||||
# example set by docker-compose) and then to the build host, like the cargo
|
||||
# step above, so every path that builds this file passes a real architecture.
|
||||
RUN set -eu; \
|
||||
rc_arch="${TARGETARCH:-}"; \
|
||||
if [ -z "${rc_arch}" ]; then \
|
||||
case "${TARGETPLATFORM:-}" in \
|
||||
linux/amd64) rc_arch=amd64 ;; \
|
||||
linux/arm64) rc_arch=arm64 ;; \
|
||||
*) \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) rc_arch=amd64 ;; \
|
||||
aarch64|arm64) rc_arch=arm64 ;; \
|
||||
*) echo "TARGETARCH is empty and TARGETPLATFORM='${TARGETPLATFORM:-}' is not linux/amd64 or linux/arm64; cannot choose an rc architecture on $(uname -m)" >&2; exit 1 ;; \
|
||||
esac ;; \
|
||||
esac; \
|
||||
fi; \
|
||||
sh /usr/src/rustfs/scripts/download_rc.sh "${rc_arch}" musl "${RC_RELEASE}" /usr/local/bin/rc
|
||||
RUN sh /usr/src/rustfs/scripts/download_rc.sh "$TARGETARCH" musl "$RC_RELEASE" /usr/local/bin/rc
|
||||
|
||||
# -----------------------------
|
||||
# Development stage (keeps toolchain)
|
||||
|
||||
@@ -199,7 +199,7 @@ docker run -d --name rustfs -p 9000:9000 \
|
||||
Notes:
|
||||
- `RUSTFS_NOTIFY_ENABLE=true` enables the global notify module switch.
|
||||
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
|
||||
- If queue dir is omitted, the official image maps `/opt/rustfs/events` into the persistent `/data` volume so it is writable by the runtime user and pending events survive container recreation. Override `queue_dir` when using another deployment layout, or set `RUSTFS_TARGET_STORE_DIR` to move every default queue; native Windows defaults to `%ProgramData%\RustFS`. See [Target queue storage](docs/operations/target-queue-storage.md).
|
||||
- If queue dir is omitted, the official image maps `/opt/rustfs/events` into the persistent `/data` volume so it is writable by the runtime user and pending events survive container recreation. Override `queue_dir` when using another deployment layout.
|
||||
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
|
||||
- Since `1.0.0-beta.11`, webhook endpoints on private or container networks
|
||||
(`Docker Compose service names`, `host.docker.internal`, RFC 1918 addresses) are
|
||||
|
||||
@@ -34,6 +34,9 @@ async fn test_audit_system_startup_performance() {
|
||||
|
||||
println!("Audit system startup took: {elapsed:?}");
|
||||
|
||||
// Should complete within 5 seconds
|
||||
assert!(elapsed < Duration::from_secs(5), "Startup took too long: {elapsed:?}");
|
||||
|
||||
// Clean up
|
||||
let _ = system.close().await;
|
||||
}
|
||||
@@ -65,6 +68,9 @@ async fn test_concurrent_target_creation() {
|
||||
|
||||
println!("Concurrent target creation took: {elapsed:?}");
|
||||
|
||||
// Should complete quickly even with multiple targets
|
||||
assert!(elapsed < Duration::from_secs(10), "Target creation took too long: {elapsed:?}");
|
||||
|
||||
// Verify it fails with expected error (server not initialized)
|
||||
match result {
|
||||
Err(AuditError::StorageNotAvailable(_)) => {
|
||||
@@ -160,6 +166,9 @@ async fn test_audit_log_dispatch_performance() {
|
||||
|
||||
println!("Audit log dispatch took: {elapsed:?}");
|
||||
|
||||
// Should be very fast (sub-millisecond for no targets)
|
||||
assert!(elapsed < Duration::from_millis(100), "Dispatch took too long: {elapsed:?}");
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(AuditError::NotInitialized(_))),
|
||||
"Dispatch on a stopped system should return NotInitialized, got: {result:?}"
|
||||
@@ -198,12 +207,11 @@ async fn test_system_state_transitions() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_name_mask_matches_expansion() {
|
||||
fn test_event_name_mask_performance() {
|
||||
use rustfs_targets::EventName;
|
||||
|
||||
// A compound event's mask is exactly the union of its leaves' masks, and a
|
||||
// leaf owns exactly one bit. Timing is printed for information only.
|
||||
let events = [
|
||||
// Test that event name mask calculation is efficient
|
||||
let events = vec![
|
||||
EventName::ObjectCreatedPut,
|
||||
EventName::ObjectAccessedGet,
|
||||
EventName::ObjectRemovedDelete,
|
||||
@@ -212,59 +220,47 @@ fn test_event_name_mask_matches_expansion() {
|
||||
];
|
||||
|
||||
let start = Instant::now();
|
||||
for event in events {
|
||||
let union = event.expand().iter().fold(0u64, |acc, leaf| acc | leaf.mask());
|
||||
assert_eq!(event.mask(), union, "{event:?} mask must equal the union of its expansion");
|
||||
}
|
||||
println!("Event mask check took: {:?}", start.elapsed());
|
||||
|
||||
assert_eq!(EventName::ObjectCreatedPut.mask().count_ones(), 1);
|
||||
assert_eq!(EventName::ObjectCreatedAll.mask().count_ones(), 4);
|
||||
// Calculate masks for many events
|
||||
for _ in 0..1000 {
|
||||
for event in &events {
|
||||
let _mask = event.mask();
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
println!("Event mask calculation (5000 ops) took: {elapsed:?}");
|
||||
|
||||
// Should be very fast
|
||||
assert!(elapsed < Duration::from_millis(100), "Mask calculation too slow: {elapsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_name_expansion_performance() {
|
||||
use rustfs_targets::EventName;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Expansion is a pure lookup. Wall-clock bounds flake on shared runners, so
|
||||
// this asserts what the time was a proxy for: each compound event yields a
|
||||
// fixed-size set of distinct leaf events and nothing else (no duplicates,
|
||||
// no compound left to expand again). Timing is printed for information only.
|
||||
let cases = [
|
||||
(EventName::ObjectCreatedAll, 4),
|
||||
(EventName::ObjectAccessedAll, 5),
|
||||
(EventName::ObjectRemovedAll, 3),
|
||||
// Test that event name expansion is efficient
|
||||
let compound_events = vec![
|
||||
EventName::ObjectCreatedAll,
|
||||
EventName::ObjectAccessedAll,
|
||||
EventName::ObjectRemovedAll,
|
||||
EventName::Everything,
|
||||
];
|
||||
|
||||
let start = Instant::now();
|
||||
for (event, expected_len) in cases {
|
||||
let expanded = event.expand();
|
||||
assert_eq!(expanded.len(), expected_len, "{event:?} expansion size");
|
||||
assert_eq!(
|
||||
expanded.iter().collect::<HashSet<_>>().len(),
|
||||
expanded.len(),
|
||||
"{event:?} expansion has duplicates"
|
||||
);
|
||||
assert_eq!(expanded, event.expand(), "{event:?} expansion must be deterministic");
|
||||
for leaf in &expanded {
|
||||
assert_eq!(leaf.expand(), vec![*leaf], "{event:?} expanded to non-leaf {leaf:?}");
|
||||
|
||||
// Expand events many times
|
||||
for _ in 0..1000 {
|
||||
for event in &compound_events {
|
||||
let _expanded = event.expand();
|
||||
}
|
||||
}
|
||||
|
||||
let everything = EventName::Everything.expand();
|
||||
assert!(everything.len() > 12, "Everything must cover at least every compound's leaves");
|
||||
assert_eq!(
|
||||
everything.iter().collect::<HashSet<_>>().len(),
|
||||
everything.len(),
|
||||
"Everything has duplicates"
|
||||
);
|
||||
for (event, _) in cases {
|
||||
for leaf in event.expand() {
|
||||
assert!(everything.contains(&leaf), "Everything is missing {leaf:?}");
|
||||
}
|
||||
}
|
||||
println!("Event expansion check took: {:?}", start.elapsed());
|
||||
let elapsed = start.elapsed();
|
||||
println!("Event expansion (4000 ops) took: {elapsed:?}");
|
||||
|
||||
// Should be very fast
|
||||
assert!(elapsed < Duration::from_millis(100), "Expansion too slow: {elapsed:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -282,22 +278,46 @@ async fn test_registry_operations_performance() {
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
println!("Registry operations (2000 ops) took: {elapsed:?}");
|
||||
|
||||
// Should be very fast for empty registry
|
||||
assert!(elapsed < Duration::from_millis(100), "Registry ops too slow: {elapsed:?}");
|
||||
}
|
||||
|
||||
// The former 3k EPS / latency assertions measured the CI host, not the code
|
||||
// (the loop only formats strings and reads the clock), so they are replaced by
|
||||
// a count of what the loop produced. Throughput is printed for information.
|
||||
// Performance requirements validation
|
||||
#[test]
|
||||
fn test_performance_requirements() {
|
||||
use std::collections::HashSet;
|
||||
// According to requirements: ≥ 3k EPS/node; P99 < 30ms (default)
|
||||
|
||||
// These are synthetic tests since we can't actually achieve 3k EPS
|
||||
// without real server storage and network targets, but we can validate
|
||||
// that our core algorithms are efficient enough
|
||||
|
||||
let start = Instant::now();
|
||||
let ids: HashSet<String> = (0..3000).map(|i| format!("s3:ObjectCreated:Put_{i}")).collect();
|
||||
let timestamps = (0..3000).filter(|_| !jiff::Timestamp::now().to_string().is_empty()).count();
|
||||
|
||||
// Simulate processing 3000 events worth of operations
|
||||
for i in 0..3000 {
|
||||
// Simulate event name parsing and processing
|
||||
let _event_id = format!("s3:ObjectCreated:Put_{i}");
|
||||
let _timestamp = jiff::Timestamp::now().to_string();
|
||||
|
||||
// Simulate basic audit entry creation overhead
|
||||
let _entry_size = 512; // bytes
|
||||
let _processing_time = std::time::Duration::from_nanos(100); // simulated
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
let eps = 3000.0 / elapsed.as_secs_f64();
|
||||
|
||||
println!("Simulated 3000 events in {elapsed:?}");
|
||||
println!("Simulated 3000 events in {elapsed:?} ({eps:.0} EPS)");
|
||||
|
||||
assert_eq!(ids.len(), 3000, "every simulated event id must be distinct");
|
||||
assert_eq!(timestamps, 3000, "every simulated event must get a timestamp");
|
||||
// Our core processing should easily handle 3k EPS worth of CPU overhead
|
||||
// The actual EPS limit will be determined by network I/O to targets
|
||||
assert!(eps > 10000.0, "Core processing too slow for 3k EPS target: {eps} EPS");
|
||||
|
||||
// P99 latency requirement: < 30ms
|
||||
// For core processing, we should be much faster than this
|
||||
let avg_latency = elapsed / 3000;
|
||||
println!("Average processing latency: {avg_latency:?}");
|
||||
|
||||
assert!(avg_latency < Duration::from_millis(1), "Processing latency too high: {avg_latency:?}");
|
||||
}
|
||||
|
||||
@@ -276,13 +276,6 @@ Drive timeout profile preset:
|
||||
- values must be positive; `0` or an invalid value falls back to the default, and values above `60` are clamped to `60`.
|
||||
- the setting is read by the aggregating node only; it does not change the internode RPC wire contract. Any retry shares one round deadline rather than receiving a fresh timeout.
|
||||
|
||||
## Target queue directory
|
||||
|
||||
- `RUSTFS_TARGET_STORE_DIR`
|
||||
- absolute root for notify and audit target queues whose `queue_dir` is the default `/opt/rustfs/events` or `/opt/rustfs/audit`; they open `<root>/events` and `<root>/audit` on this node.
|
||||
- unset keeps those paths on Linux and macOS; native Windows uses `%ProgramData%\RustFS\events` and `%ProgramData%\RustFS\audit`.
|
||||
- explicit `queue_dir` values are never relocated, and the persisted target configuration is unchanged. Changing the root does not migrate pending entries. See `docs/operations/target-queue-storage.md`.
|
||||
|
||||
## Startup filesystem boundary policy
|
||||
|
||||
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
|
||||
|
||||
@@ -50,8 +50,10 @@ const _: () = assert!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS <= MAX_ADMIN_PEER_PR
|
||||
|
||||
// ── Client-side internode gRPC channel tuning (P0) ──
|
||||
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
|
||||
// on the *client* `tonic` `Endpoint` used for internode RPCs. Explicit values make
|
||||
// the operator's receive-credit policy independent of changes to library defaults.
|
||||
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
|
||||
// this, the client channel only set timeouts/keepalive, leaving Nagle enabled and the
|
||||
// default 64KiB HTTP/2 window in place — hurting small lock-RPC latency and large
|
||||
// metadata-response throughput respectively.
|
||||
|
||||
/// Disable Nagle's algorithm on internode gRPC client sockets.
|
||||
///
|
||||
@@ -67,8 +69,8 @@ const _: () = assert!(DEFAULT_INTERNODE_RPC_TCP_NODELAY);
|
||||
|
||||
/// HTTP/2 initial stream window size (bytes) for internode gRPC client channels.
|
||||
///
|
||||
/// Size this for the bandwidth-delay product and concurrent response budget of
|
||||
/// unary responses such as `ReadMultiple` and `BatchReadVersion`. Set to 0 to use the
|
||||
/// The library default (64KiB) throttles larger unary responses (e.g. `ReadMultiple`,
|
||||
/// `BatchReadVersion`) by the bandwidth-delay product. Set to 0 to fall back to the
|
||||
/// `tonic`/`hyper` default. Defaults to 1MiB.
|
||||
pub const ENV_INTERNODE_RPC_HTTP2_STREAM_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_RPC_HTTP2_STREAM_WINDOW_SIZE";
|
||||
pub const DEFAULT_INTERNODE_RPC_HTTP2_STREAM_WINDOW_SIZE: u32 = 1024 * 1024;
|
||||
@@ -282,18 +284,6 @@ const _: () = assert!(!DEFAULT_INTERNODE_CHANNEL_ISOLATION);
|
||||
pub const ENV_INTERNODE_BULK_CHANNELS: &str = "RUSTFS_INTERNODE_BULK_CHANNELS";
|
||||
pub const DEFAULT_INTERNODE_BULK_CHANNELS: usize = 2;
|
||||
|
||||
/// Optional receive-window overrides for isolated bulk channels. When unset, inherit the
|
||||
/// corresponding `RUSTFS_INTERNODE_RPC_HTTP2_*` setting. Zero selects the library default.
|
||||
pub const ENV_INTERNODE_BULK_HTTP2_STREAM_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_BULK_HTTP2_STREAM_WINDOW_SIZE";
|
||||
pub const ENV_INTERNODE_BULK_HTTP2_CONN_WINDOW_SIZE: &str = "RUSTFS_INTERNODE_BULK_HTTP2_CONN_WINDOW_SIZE";
|
||||
|
||||
/// Transport admission and pending-buffer limits for isolated bulk channels. These limits
|
||||
/// apply to the tonic service; complete unary bodies are observed separately by RPC metrics.
|
||||
pub const ENV_INTERNODE_BULK_CONCURRENCY_LIMIT: &str = "RUSTFS_INTERNODE_BULK_CONCURRENCY_LIMIT";
|
||||
pub const DEFAULT_INTERNODE_BULK_CONCURRENCY_LIMIT: usize = 64;
|
||||
pub const ENV_INTERNODE_BULK_BUFFER_SIZE: &str = "RUSTFS_INTERNODE_BULK_BUFFER_SIZE";
|
||||
pub const DEFAULT_INTERNODE_BULK_BUFFER_SIZE: usize = 1024;
|
||||
|
||||
/// Profile selector for conservative internode HTTP data-plane client tuning.
|
||||
pub const ENV_INTERNODE_HTTP_TUNING_PROFILE: &str = "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE";
|
||||
pub const DEFAULT_INTERNODE_HTTP_TUNING_PROFILE: &str = "legacy";
|
||||
|
||||
@@ -117,30 +117,6 @@ pub const ENV_OBJECT_GET_SKIP_BITROT_VERIFY: &str = "RUSTFS_OBJECT_GET_SKIP_BITR
|
||||
/// Default: bitrot verification is enabled on GetObject reads (do not skip).
|
||||
pub const DEFAULT_OBJECT_GET_SKIP_BITROT_VERIFY: bool = false;
|
||||
|
||||
/// Comma-separated buckets that hold a content-addressed cache (an LLM KV
|
||||
/// cache) and serve PutObject, GetObject and HeadObject without distributed
|
||||
/// locks or authoritative bucket-metadata reads. See the ecstore
|
||||
/// `bucket::lockless_cache` module for what such a bucket gives up.
|
||||
///
|
||||
/// Default is empty: every bucket keeps the locked paths.
|
||||
pub const ENV_LOCKLESS_CACHE_BUCKETS: &str = "RUSTFS_LOCKLESS_CACHE_BUCKETS";
|
||||
|
||||
/// Smallest declared body, in bytes, whose ETag MD5 is computed on a
|
||||
/// blocking-pool thread while the body streams into the erasure encoder,
|
||||
/// instead of inline on the request's task. The ETag and Content-MD5
|
||||
/// checks are unchanged; `0` keeps every MD5 inline.
|
||||
///
|
||||
/// Each offloaded upload holds one blocking-pool thread until its body ends,
|
||||
/// and that pool also serves disk I/O, so size the threshold against the
|
||||
/// expected number of concurrent large uploads before enabling it.
|
||||
pub const ENV_OBJECT_ETAG_MD5_OFFLOAD_MIN_BYTES: &str = "RUSTFS_OBJECT_ETAG_MD5_OFFLOAD_MIN_BYTES";
|
||||
|
||||
/// Default: disabled; every ETag MD5 is computed inline. Set
|
||||
/// `RUSTFS_OBJECT_ETAG_MD5_OFFLOAD_MIN_BYTES` (for example `8388608`) to opt in.
|
||||
pub const DEFAULT_OBJECT_ETAG_MD5_OFFLOAD_MIN_BYTES: u64 = 0;
|
||||
|
||||
const _: () = assert!(DEFAULT_OBJECT_ETAG_MD5_OFFLOAD_MIN_BYTES == 0);
|
||||
|
||||
/// Create independent shard commitments for new writes after the fleet is upgraded.
|
||||
/// Existing protected objects and multipart uploads retain their protection.
|
||||
pub const ENV_SHARD_INTEGRITY_WRITE: &str = "RUSTFS_SHARD_INTEGRITY_WRITE";
|
||||
@@ -322,37 +298,6 @@ pub const ENV_OBJECT_DISK_READ_TIMEOUT: &str = "RUSTFS_OBJECT_DISK_READ_TIMEOUT"
|
||||
/// Default disk read timeout in seconds.
|
||||
pub const DEFAULT_OBJECT_DISK_READ_TIMEOUT: u64 = 10;
|
||||
|
||||
/// Environment variable for the smallest bitrot frame (hash + shard bytes)
|
||||
/// that is verified on a blocking thread instead of inline on the decode task
|
||||
/// (`0` disables off-thread verification).
|
||||
///
|
||||
/// The shard reads of one stripe complete on one task, so with large shards
|
||||
/// their hashes were computed one after another on one core: at 4 MiB shards
|
||||
/// a 12+4 stripe spent about 10 ms hashing serially. Frames at or above this
|
||||
/// size are hashed on the blocking pool so the shards of a stripe verify in
|
||||
/// parallel; small frames stay inline because a thread hop would cost more
|
||||
/// than the hash.
|
||||
pub const ENV_BITROT_VERIFY_OFFLOAD_MIN_BYTES: &str = "RUSTFS_BITROT_VERIFY_OFFLOAD_MIN_BYTES";
|
||||
/// Default smallest off-thread bitrot frame: 2 MiB (frames of the legacy 1 MiB
|
||||
/// block, and 1 MiB shards, stay inline).
|
||||
pub const DEFAULT_BITROT_VERIFY_OFFLOAD_MIN_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// Environment variable for the erasure shard size, in bytes, of newly written
|
||||
/// objects (`0`, the default, keeps the historical 1 MiB erasure block).
|
||||
///
|
||||
/// When set, the erasure block of a new object is `data shards x shard bytes`,
|
||||
/// so every shard read and write is exactly this large and aligned: 4 MiB on a
|
||||
/// 12+4 layout gives a 48 MiB block, 48x fewer stripes per object than today.
|
||||
/// Existing objects keep the block recorded in their `xl.meta`. System
|
||||
/// metadata under `.rustfs.sys` always uses the 1 MiB block. The value must be
|
||||
/// a multiple of 4 KiB and at most `MAX_ERASURE_SHARD_BYTES`; invalid values
|
||||
/// are ignored with a warning.
|
||||
pub const ENV_ERASURE_SHARD_BYTES: &str = "RUSTFS_ERASURE_SHARD_BYTES";
|
||||
/// Default erasure shard size override (`0` = derive from the 1 MiB block).
|
||||
pub const DEFAULT_ERASURE_SHARD_BYTES: usize = 0;
|
||||
/// Largest accepted erasure shard size override (64 MiB).
|
||||
pub const MAX_ERASURE_SHARD_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Environment variable for the per-shard erasure write stall timeout (seconds).
|
||||
///
|
||||
/// A single shard write (or shard-writer shutdown) that makes no forward
|
||||
|
||||
@@ -114,14 +114,6 @@ pub const ENV_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE: &str = "RUSTFS_OBJECT_FILE_
|
||||
pub const ENV_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD: &str = "RUSTFS_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD";
|
||||
pub const DEFAULT_OBJECT_FILE_CACHE_RECLAIM_WRITE_ENABLE: bool = false;
|
||||
pub const DEFAULT_OBJECT_FILE_CACHE_RECLAIM_READ_ENABLE: bool = true;
|
||||
|
||||
/// Bytes of page-cache read-ahead a ranged file stream keeps queued in front
|
||||
/// of its read position (`posix_fadvise(WILLNEED)`, Linux only; `0` disables).
|
||||
/// A shard stream served to a peer reads 4 MiB chunks one after another; the
|
||||
/// kernel's own read-ahead window ramps up slowly for large reads, so without
|
||||
/// this each chunk waits for its own device round trips.
|
||||
pub const ENV_OBJECT_STREAM_READ_AHEAD_BYTES: &str = "RUSTFS_OBJECT_STREAM_READ_AHEAD_BYTES";
|
||||
pub const DEFAULT_OBJECT_STREAM_READ_AHEAD_BYTES: usize = 16 * 1024 * 1024;
|
||||
pub const DEFAULT_OBJECT_FILE_CACHE_RECLAIM_THRESHOLD: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Threshold for small object seek support in bytes.
|
||||
@@ -166,10 +158,3 @@ pub const ENV_OBJECT_DATA_CACHE_MIN_FREE_MEMORY_PERCENT: &str = "RUSTFS_OBJECT_D
|
||||
pub const ENV_OBJECT_DATA_CACHE_FILL_CONCURRENCY_PER_CPU: &str = "RUSTFS_OBJECT_DATA_CACHE_FILL_CONCURRENCY_PER_CPU";
|
||||
pub const ENV_OBJECT_DATA_CACHE_FILL_CONCURRENCY_MAX: &str = "RUSTFS_OBJECT_DATA_CACHE_FILL_CONCURRENCY_MAX";
|
||||
pub const ENV_OBJECT_DATA_CACHE_IDENTITY_KEYS_MAX: &str = "RUSTFS_OBJECT_DATA_CACHE_IDENTITY_KEYS_MAX";
|
||||
/// Fill the object data cache from the PUT body as it streams in, so the first
|
||||
/// GET after a write is a hit (write-once KV-cache chunks). Only objects that
|
||||
/// fit the cache entry limit and take the plain streaming PUT path (no
|
||||
/// compression, no SSE) are filled; the body is kept in memory once more
|
||||
/// during the PUT.
|
||||
pub const ENV_OBJECT_DATA_CACHE_FILL_ON_PUT: &str = "RUSTFS_OBJECT_DATA_CACHE_FILL_ON_PUT";
|
||||
pub const DEFAULT_OBJECT_DATA_CACHE_FILL_ON_PUT: bool = false;
|
||||
|
||||
@@ -144,12 +144,6 @@ pub const POSTGRES_QUEUE_LIMIT: &str = "queue_limit";
|
||||
/// Applies to both notify and audit target queue stores.
|
||||
pub const ENV_TARGET_STORE_COMPRESS: &str = "RUSTFS_TARGET_STORE_COMPRESS";
|
||||
|
||||
/// Environment variable that relocates the root of the default notify and audit queue directories.
|
||||
///
|
||||
/// It applies only to targets whose `queue_dir` is the logical default (`EVENT_DEFAULT_DIR` or
|
||||
/// `AUDIT_DEFAULT_DIR`); their queues move to `<root>/events` and `<root>/audit` on this node.
|
||||
pub const ENV_TARGET_STORE_DIR: &str = "RUSTFS_TARGET_STORE_DIR";
|
||||
|
||||
/// Queue-store compression is enabled by default to reduce disk footprint.
|
||||
pub const DEFAULT_TARGET_STORE_COMPRESS: bool = true;
|
||||
|
||||
|
||||
@@ -55,9 +55,6 @@ pub const ENV_OBS_PUT_STAGE_METRICS_ENABLED: &str = "RUSTFS_OBS_PUT_STAGE_METRIC
|
||||
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_ENABLED";
|
||||
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_SAMPLE_RATE";
|
||||
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS";
|
||||
/// Path of the sampled GetObject stage summary file (defaults to `/var/log/rustfs/get-stage-summary.log`).
|
||||
pub const ENV_OBS_GET_STAGE_LOCAL_SUMMARY_FILE: &str = "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_FILE";
|
||||
pub const DEFAULT_OBS_GET_STAGE_LOCAL_SUMMARY_FILE: &str = "/var/log/rustfs/get-stage-summary.log";
|
||||
|
||||
pub const ENV_OBS_LOGGER_LEVEL: &str = "RUSTFS_OBS_LOGGER_LEVEL";
|
||||
pub const ENV_OBS_LOG_STDOUT_ENABLED: &str = "RUSTFS_OBS_LOG_STDOUT_ENABLED";
|
||||
@@ -94,12 +91,7 @@ pub const ENV_OBS_LOG_MATCH_MODE: &str = "RUSTFS_OBS_LOG_MATCH_MODE";
|
||||
|
||||
/// Default values for log cleanup
|
||||
pub const DEFAULT_OBS_LOG_MAX_TOTAL_SIZE_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB
|
||||
pub const DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES: u64 = 0; // No per-file trim by the cleaner
|
||||
/// Size at which the active log file is rolled when
|
||||
/// `RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES` is unset. Without a size roll
|
||||
/// the active file is bounded only by the rotation period, and the cleaner
|
||||
/// never touches the active file. An explicit `0` keeps size rolling off.
|
||||
pub const DEFAULT_OBS_LOG_ACTIVE_FILE_ROLL_SIZE_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
|
||||
pub const DEFAULT_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES: u64 = 0; // No single file limit
|
||||
pub const DEFAULT_OBS_LOG_COMPRESS_OLD_FILES: bool = true;
|
||||
pub const DEFAULT_OBS_LOG_GZIP_COMPRESSION_LEVEL: u32 = 6;
|
||||
pub const DEFAULT_OBS_LOG_COMPRESSION_ALGORITHM_GZIP: &str = "gzip";
|
||||
@@ -181,7 +173,6 @@ mod tests {
|
||||
ENV_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS,
|
||||
"RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_INTERVAL_SECS"
|
||||
);
|
||||
assert_eq!(ENV_OBS_GET_STAGE_LOCAL_SUMMARY_FILE, "RUSTFS_OBS_GET_STAGE_LOCAL_SUMMARY_FILE");
|
||||
// Test log cleanup related env keys
|
||||
assert_eq!(ENV_OBS_LOG_MAX_TOTAL_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_TOTAL_SIZE_BYTES");
|
||||
assert_eq!(ENV_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES, "RUSTFS_OBS_LOG_MAX_SINGLE_FILE_SIZE_BYTES");
|
||||
|
||||
@@ -1764,7 +1764,12 @@ impl DataUsageCache {
|
||||
let mut found = HashSet::new();
|
||||
found.insert(top);
|
||||
mark(self, &top_e, &mut found);
|
||||
self.cache.retain(|k, _| found.contains(k));
|
||||
self.cache.retain(|k, _| {
|
||||
if !found.contains(k) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
pub fn reduce_children_of(&mut self, path: &DataUsageHash, limit: usize, compact_self: bool) {
|
||||
|
||||
@@ -135,72 +135,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A comma-separated region list widens the regions `CreateBucket`
|
||||
/// accepts without changing the server's own region (the first entry): a
|
||||
/// listed region creates the bucket, an unlisted one is still rejected,
|
||||
/// and `GetBucketLocation` keeps reporting the site region.
|
||||
#[tokio::test]
|
||||
async fn test_create_bucket_accepts_listed_regions() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec!["--region", "us-east-1,ap-southeast-2,us-west-2"])
|
||||
.await?;
|
||||
|
||||
for region in ["us-east-1", "ap-southeast-2", "us-west-2"] {
|
||||
let client = Client::from_conf(
|
||||
env.create_s3_client()
|
||||
.config()
|
||||
.to_builder()
|
||||
.region(Region::new(region))
|
||||
.build(),
|
||||
);
|
||||
let bucket = format!("additional-region-{region}");
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(&bucket)
|
||||
.create_bucket_configuration(
|
||||
CreateBucketConfiguration::builder()
|
||||
.location_constraint(BucketLocationConstraint::from(region))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("a listed region must create the bucket");
|
||||
let location = client.get_bucket_location().bucket(&bucket).send().await?;
|
||||
assert_eq!(
|
||||
location.location_constraint().map(|value| value.as_str()),
|
||||
Some(""),
|
||||
"GetBucketLocation keeps reporting the site region (us-east-1)"
|
||||
);
|
||||
}
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let error = client
|
||||
.create_bucket()
|
||||
.bucket("unlisted-region")
|
||||
.create_bucket_configuration(
|
||||
CreateBucketConfiguration::builder()
|
||||
.location_constraint(BucketLocationConstraint::from("eu-west-1"))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a region missing from the region list must be rejected");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("InvalidLocationConstraint")
|
||||
);
|
||||
let missing = client
|
||||
.head_bucket()
|
||||
.bucket("unlisted-region")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("rejected bucket must not exist");
|
||||
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_bucket_uses_configured_region() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
@@ -1285,9 +1285,7 @@ async fn list_parts_disk_loss_after_metadata_read(
|
||||
let error = result.expect_err("one missing directory and three unavailable participants cannot prove an empty upload");
|
||||
let response = error.raw_response().expect("ListParts must return an S3 service response");
|
||||
assert_eq!(response.status().as_u16(), 503);
|
||||
// Since #9149, SlowDownRead is reserved for GET/HEAD object reads; a
|
||||
// ListParts read-quorum failure is reported as ServiceUnavailable.
|
||||
assert_eq!(error.as_service_error().and_then(|err| err.meta().code()), Some("ServiceUnavailable"));
|
||||
assert_eq!(error.as_service_error().and_then(|err| err.meta().code()), Some("SlowDownRead"));
|
||||
assert!(response.headers().get("x-rustfs-error-cause").is_none());
|
||||
assert!(response.headers().get("retry-after").is_none());
|
||||
assert!(
|
||||
|
||||
@@ -766,10 +766,9 @@ impl Run {
|
||||
|| async {
|
||||
let mut ready = true;
|
||||
let mut observations = Vec::with_capacity(4);
|
||||
assert!(self.dist.cluster.nodes.len() >= 4);
|
||||
for (node, cluster_node) in self.dist.cluster.nodes.iter().enumerate().take(4) {
|
||||
for node in 0..4 {
|
||||
match http
|
||||
.get(format!("{}/health/ready", cluster_node.url))
|
||||
.get(format!("{}/health/ready", self.dist.cluster.nodes[node].url))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, cluster_admin, enable_versioning, put_bucket_replication, put_object,
|
||||
DistCluster, DistLayout, TestResult, cluster_admin_ok, enable_versioning, put_bucket_replication, put_object,
|
||||
quota_stats_current_usage, quota_stats_on_every_node, retrying_put, set_bucket_quota, set_remote_target, unique_bucket,
|
||||
wait_for_ready, wait_for_replicated_bytes, wait_until,
|
||||
};
|
||||
@@ -119,7 +119,6 @@ async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
|
||||
let native_path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||
let compat_set = format!("/rustfs/admin/v3/set-bucket-quota?bucket={bucket}");
|
||||
let compat_get = format!("/rustfs/admin/v3/get-bucket-quota?bucket={bucket}");
|
||||
let complete_reload = serde_json::json!({"status": "complete", "unconfirmed_peers": []});
|
||||
|
||||
// Warm every node's no-quota cache. The compatibility GET does not
|
||||
// depend on a scanner usage snapshot for this newly created bucket.
|
||||
@@ -134,12 +133,7 @@ async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(response["quota"], 0);
|
||||
assert!(
|
||||
response.get("metadata_reload").is_none(),
|
||||
"GET must not report a mutation outcome: {body}"
|
||||
);
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
|
||||
}
|
||||
wait_until(
|
||||
Duration::from_secs(30),
|
||||
@@ -157,8 +151,6 @@ async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
|
||||
return Ok(false);
|
||||
}
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(response["metadata_reload"], complete_reload, "{body}");
|
||||
Ok(true)
|
||||
},
|
||||
"quota fleet capability ready",
|
||||
@@ -188,14 +180,7 @@ async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
|
||||
),
|
||||
] {
|
||||
if method != Method::GET {
|
||||
let (status, body) = cluster_admin(&dist.cluster, method, path, payload.map(|value| value.to_string())).await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
if path == compat_set {
|
||||
assert!(body.is_empty(), "compatible quota mutations must preserve their empty response: {body}");
|
||||
} else {
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(response["metadata_reload"], complete_reload, "{body}");
|
||||
}
|
||||
cluster_admin_ok(&dist.cluster, method, path, payload.map(|value| value.to_string())).await?;
|
||||
}
|
||||
// No polling: a successful mutation waits for healthy peers to reload.
|
||||
for (index, node) in dist.cluster.nodes.iter().enumerate() {
|
||||
@@ -209,11 +194,10 @@ async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(response["quota"], expected.unwrap_or(0), "node {index} kept stale quota metadata",);
|
||||
assert!(
|
||||
response.get("metadata_reload").is_none(),
|
||||
"GET must not report a mutation outcome: {body}"
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&body)?["quota"],
|
||||
expected.unwrap_or(0),
|
||||
"node {index} kept stale quota metadata",
|
||||
);
|
||||
if expected.is_some() {
|
||||
let (status, body) = admin_request(
|
||||
@@ -226,37 +210,21 @@ async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(response["quota"].as_u64(), expected);
|
||||
assert!(
|
||||
response.get("metadata_reload").is_none(),
|
||||
"GET must not report a mutation outcome: {body}"
|
||||
);
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"].as_u64(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (status, body) = cluster_admin(
|
||||
cluster_admin_ok(
|
||||
&dist.cluster,
|
||||
Method::PUT,
|
||||
&native_path,
|
||||
Some(serde_json::json!({"quota": 8192, "quota_type": "HARD"}).to_string()),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(response["metadata_reload"], complete_reload, "{body}");
|
||||
let offline_host = dist.cluster.nodes[3].address.clone();
|
||||
dist.cluster.stop_node(3)?;
|
||||
// An unreachable peer cannot turn an already committed clear into a failure.
|
||||
let (status, body) = cluster_admin(&dist.cluster, Method::DELETE, &native_path, None).await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(
|
||||
response["metadata_reload"],
|
||||
serde_json::json!({"status": "incomplete", "unconfirmed_peers": [offline_host]}),
|
||||
"the response must identify exactly the peer that did not acknowledge the reload: {body}",
|
||||
);
|
||||
cluster_admin_ok(&dist.cluster, Method::DELETE, &native_path, None).await?;
|
||||
for node in dist.cluster.nodes.iter().take(3) {
|
||||
let (status, body) = admin_request(
|
||||
&node.url,
|
||||
@@ -268,12 +236,7 @@ async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let response: serde_json::Value = serde_json::from_str(&body)?;
|
||||
assert_eq!(response["quota"], 0);
|
||||
assert!(
|
||||
response.get("metadata_reload").is_none(),
|
||||
"GET must not report a mutation outcome: {body}"
|
||||
);
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -493,12 +493,7 @@ async fn compare_degraded_legacy_list_parts(dist: &mut DistCluster, seed: &Upgra
|
||||
assert!(legacy.parts().is_empty());
|
||||
let current = current.expect_err("current coordinator must not infer empty from missing directories and offline disks");
|
||||
assert_eq!(current.raw_response().expect("current S3 response").status().as_u16(), 503);
|
||||
// Since #9149, SlowDownRead is reserved for GET/HEAD object reads; a
|
||||
// ListParts read-quorum failure is reported as ServiceUnavailable.
|
||||
assert_eq!(
|
||||
current.as_service_error().and_then(|error| error.meta().code()),
|
||||
Some("ServiceUnavailable")
|
||||
);
|
||||
assert_eq!(current.as_service_error().and_then(|error| error.meta().code()), Some("SlowDownRead"));
|
||||
dist.cluster.start_node_from_binary(3, previous).await?;
|
||||
wait_for_upgrade_write_readiness(&clients, &seed.history_bucket, "mixed-list-restored", CREDENTIAL_TIMEOUT).await?;
|
||||
for (path, before) in original_files {
|
||||
|
||||
@@ -1332,15 +1332,14 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(confirmed.len(), 1);
|
||||
let mut receivers = Vec::new();
|
||||
let receiver_endpoints = phase.disks.get(1).map_or(&[][..], |disks| &phase.endpoints[1][..disks.len()]);
|
||||
for endpoint in receiver_endpoints {
|
||||
for drive in 0..phase.disks.get(1).map_or(0, Vec::len) {
|
||||
for cas in [prepares[1], commits[1]] {
|
||||
let matching: Vec<_> = events[1]
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|event| {
|
||||
event["kind"] == "receiver"
|
||||
&& event["disk"] == *endpoint
|
||||
&& event["disk"] == phase.endpoints[1][drive]
|
||||
&& event["dst_volume"] == ".rustfs.sys"
|
||||
&& event["dst_path"] == "pool.bin"
|
||||
&& event["etag"] == cas["etag"]
|
||||
|
||||
@@ -45,6 +45,9 @@ impl SideA {
|
||||
return Ok(Self::SameBinary);
|
||||
};
|
||||
let path = PathBuf::from(value);
|
||||
if !path.exists() {
|
||||
return Ok(Self::SameBinary);
|
||||
}
|
||||
validate_executable(&path)?;
|
||||
Ok(Self::Reference(path))
|
||||
}
|
||||
@@ -79,7 +82,8 @@ fn is_executable(_metadata: &std::fs::Metadata) -> bool {
|
||||
/// What `<binary> --version` printed, trimmed. Fails when the binary does not run, exits
|
||||
/// non-zero, or prints nothing, because an empty version would report nothing as observed.
|
||||
pub(super) async fn binary_version(binary: &Path) -> Result<String, BoxError> {
|
||||
let output = tokio::process::Command::new(binary).arg("--version").output().await?;
|
||||
let _ = binary;
|
||||
let output = tokio::process::Command::new("echo").arg("mutated").output().await?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("{} --version failed with {}", binary.display(), output.status).into());
|
||||
}
|
||||
|
||||
-3
@@ -20,8 +20,6 @@
|
||||
//! sends is left out of the comparison. Values minted per process (upload IDs, timestamps) are
|
||||
//! never recorded, and the comparator reduces a version ID to whether it is `null`.
|
||||
|
||||
mod extra;
|
||||
|
||||
use super::compare::Observation;
|
||||
use crate::common::{RustFSTestEnvironment, build_test_s3_config};
|
||||
use aws_sdk_s3::Client;
|
||||
@@ -365,7 +363,6 @@ pub(crate) async fn run(env: &RustFSTestEnvironment) -> Vec<Observation> {
|
||||
Box::pin(multipart(&mut s, &c)).await;
|
||||
Box::pin(object_extras(&mut s, &c)).await;
|
||||
Box::pin(deletes_and_listings(&mut s, &c)).await;
|
||||
Box::pin(extra::cases(&mut s, &c)).await;
|
||||
s.observations
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `bucket_access` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `bucket_configs` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `bucket_core` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `bucket_replication_lifecycle` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `listing_multipart` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Cases the operation batches of rustfs/backlog#2765 add to the script, one file per batch.
|
||||
//!
|
||||
//! They run after the base script in `super`, in the fixed order below, so the base script's
|
||||
//! ordered `(op, case)` list never moves. A batch adds its cases to its own file only, on buckets
|
||||
//! and keys of its own; it does not edit this file or the base script.
|
||||
|
||||
use super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
mod bucket_access;
|
||||
mod bucket_configs;
|
||||
mod bucket_core;
|
||||
mod bucket_replication_lifecycle;
|
||||
mod listing_multipart;
|
||||
mod object_commit;
|
||||
mod object_meta;
|
||||
mod object_misc;
|
||||
mod object_read;
|
||||
mod object_write;
|
||||
|
||||
pub(super) async fn cases(s: &mut Recorder, c: &Client) {
|
||||
Box::pin(bucket_core::cases(s, c)).await;
|
||||
Box::pin(bucket_configs::cases(s, c)).await;
|
||||
Box::pin(bucket_access::cases(s, c)).await;
|
||||
Box::pin(bucket_replication_lifecycle::cases(s, c)).await;
|
||||
Box::pin(object_meta::cases(s, c)).await;
|
||||
Box::pin(listing_multipart::cases(s, c)).await;
|
||||
Box::pin(object_read::cases(s, c)).await;
|
||||
Box::pin(object_write::cases(s, c)).await;
|
||||
Box::pin(object_commit::cases(s, c)).await;
|
||||
Box::pin(object_misc::cases(s, c)).await;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `object_commit` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `object_meta` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `object_misc` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `object_read` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Differential cases of the `object_write` batch (see `super`).
|
||||
|
||||
use super::super::Recorder;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
pub(super) async fn cases(_s: &mut Recorder, _c: &Client) {}
|
||||
@@ -586,18 +586,7 @@ async fn test_reads_under_a_deleted_kms_key_report_key_not_found() -> Result<(),
|
||||
.await?;
|
||||
assert_eq!(put.ssekms_key_id(), Some(doomed_key_id.as_str()));
|
||||
|
||||
// Destruction is two requests: schedule the deletion, then destroy the
|
||||
// pending key without waiting out its window.
|
||||
info!("🗑️ scheduling {doomed_key_id} for deletion, then destroying it");
|
||||
kms_admin_request(
|
||||
&base.url,
|
||||
http::Method::DELETE,
|
||||
"/rustfs/admin/v3/kms/keys/delete",
|
||||
Some(&serde_json::json!({ "key_id": doomed_key_id }).to_string()),
|
||||
&base.access_key,
|
||||
&base.secret_key,
|
||||
)
|
||||
.await?;
|
||||
info!("🗑️ deleting {doomed_key_id} immediately");
|
||||
kms_admin_request(
|
||||
&base.url,
|
||||
http::Method::DELETE,
|
||||
|
||||
@@ -395,64 +395,11 @@ impl Drop for OpaMock {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sts_session_policy_id_limits_object_access() -> TestResult {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
let admin = env.create_s3_client();
|
||||
let bucket = "session-policy-id";
|
||||
admin.create_bucket().bucket(bucket).send().await?;
|
||||
for key in ["allowed", "denied"] {
|
||||
admin
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(Bytes::from_static(b"data").into())
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
|
||||
let policy = serde_json::json!({
|
||||
"Id": "read-one-object",
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": format!("arn:aws:s3:::{bucket}/allowed")}]
|
||||
});
|
||||
let response = sts_client(&env.url, &env.access_key, &env.secret_key, None)
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/test")
|
||||
.role_session_name("session-policy-id")
|
||||
.policy(policy.to_string())
|
||||
.send()
|
||||
.await?;
|
||||
let credentials = response.credentials().ok_or("AssumeRole returned no credentials")?;
|
||||
let session = aws_sdk_s3::Client::from_conf(build_test_s3_config(
|
||||
&env.url,
|
||||
credentials.access_key_id(),
|
||||
credentials.secret_access_key(),
|
||||
Some(credentials.session_token()),
|
||||
"e2e-session-policy-id",
|
||||
));
|
||||
let allowed = session.get_object().bucket(bucket).key("allowed").send().await?;
|
||||
assert_eq!(allowed.body.collect().await?.into_bytes().as_ref(), b"data");
|
||||
let error = session
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key("denied")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("object outside session policy");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sts_query_responses_are_aws_sdk_compatible() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
// Exercise the minimum root secret length accepted at startup.
|
||||
env.secret_key.truncate(8);
|
||||
assert_eq!(env.secret_key.len(), 8);
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let assumed = sts_client(&env.url, &env.access_key, &env.secret_key, None)
|
||||
|
||||
@@ -29,12 +29,7 @@ documentation = "https://docs.rs/rustfs-ecstore/latest/rustfs_ecstore/"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[example]]
|
||||
name = "internode_rpc_bench"
|
||||
test = true
|
||||
|
||||
[features]
|
||||
internode-transport = []
|
||||
default = []
|
||||
fips = ["rustfs-s3-client/fips", "rustfs-utils/fips", "rustfs-tls-runtime/fips"]
|
||||
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
|
||||
|
||||
@@ -1,866 +0,0 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Read-only authenticated ReadAll/Ping probe against explicitly prepared test disks.
|
||||
//! Usage: internode_rpc_bench manifest.json output.json
|
||||
//! Fixtures live under each test disk's .rustfs.sys/tmp/<fixture_id>/ directory.
|
||||
|
||||
use rustfs_ecstore::api::{
|
||||
disk::{DiskAPI, DiskOption, DiskStore, endpoint::Endpoint, new_disk},
|
||||
rpc::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client},
|
||||
};
|
||||
use rustfs_protos::proto_gen::node_service::PingRequest;
|
||||
use rustfs_tls_runtime::{OutboundTlsMaterial, TlsGeneration, ensure_crypto_provider, publish_global_outbound_tls_state};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::{Error, ErrorKind, Result, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio::time::{Instant, sleep, sleep_until, timeout};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_PAYLOAD: usize = 8 * 1024 * 1024;
|
||||
const MAX_BULK_ROWS: usize = 4_000_000;
|
||||
const MAX_CONTROL_ROWS: u32 = 1_000_000;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Config {
|
||||
namespace: String,
|
||||
targets: Vec<String>,
|
||||
disk_ids: Option<Vec<Uuid>>,
|
||||
fixture_id: Uuid,
|
||||
fixture_file: PathBuf,
|
||||
duration_secs: u32,
|
||||
bulk_workers: usize,
|
||||
control_rps: u32,
|
||||
rpc_timeout_ms: u32,
|
||||
tls_ca: Option<PathBuf>,
|
||||
start_marker: Option<PathBuf>,
|
||||
end_marker: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn endpoints(&self) -> Result<Vec<Endpoint>> {
|
||||
let valid_namespace = self.namespace.starts_with("grpc-bench-")
|
||||
&& (12..=64).contains(&self.namespace.len())
|
||||
&& self
|
||||
.namespace
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
|
||||
if !valid_namespace || self.fixture_id.is_nil() || !(1..=64).contains(&self.targets.len()) {
|
||||
return Err(Error::new(ErrorKind::InvalidInput, "invalid test namespace, fixture ID or target count"));
|
||||
}
|
||||
if self
|
||||
.disk_ids
|
||||
.as_ref()
|
||||
.is_some_and(|ids| ids.len() != self.targets.len() || ids.iter().any(Uuid::is_nil))
|
||||
{
|
||||
return Err(Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"disk IDs must match every declared target and be non-nil",
|
||||
));
|
||||
}
|
||||
if !(1..=1800).contains(&self.duration_secs)
|
||||
|| !(1..=128).contains(&self.bulk_workers)
|
||||
|| !(1..=10_000).contains(&self.control_rps)
|
||||
|| self
|
||||
.duration_secs
|
||||
.checked_mul(self.control_rps)
|
||||
.is_none_or(|count| count > MAX_CONTROL_ROWS)
|
||||
|| !(1..=30_000).contains(&self.rpc_timeout_ms)
|
||||
{
|
||||
return Err(Error::new(ErrorKind::InvalidInput, "workload exceeds the probe's resource bounds"));
|
||||
}
|
||||
if self.start_marker.is_some() != self.end_marker.is_some()
|
||||
|| self.start_marker == self.end_marker && self.start_marker.is_some()
|
||||
{
|
||||
return Err(Error::new(ErrorKind::InvalidInput, "provide distinct start/end markers or neither"));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
self.targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
let mut endpoint = Endpoint::try_from(target.as_str())
|
||||
.map_err(|_| Error::new(ErrorKind::InvalidInput, "invalid test endpoint URL"))?;
|
||||
let url = &endpoint.url;
|
||||
if target.len() > 1024
|
||||
|| !matches!(url.scheme(), "http" | "https")
|
||||
|| url.host_str().is_none()
|
||||
|| url.port().is_none_or(|port| port < 1024)
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| url.path().contains('%')
|
||||
|| !url.path().ends_with(&format!("/{}", self.namespace))
|
||||
|| !seen.insert(url.to_string())
|
||||
|| url.scheme() == "https" && self.tls_ca.is_none()
|
||||
{
|
||||
return Err(Error::new(ErrorKind::InvalidInput, "target must name an explicit test-disk URL"));
|
||||
}
|
||||
// This probe never constructs a local disk or invokes its startup cleanup.
|
||||
endpoint.is_local = false;
|
||||
Ok(endpoint)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Marker {
|
||||
time_ns: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Sample {
|
||||
operation: &'static str,
|
||||
peer: usize,
|
||||
scheduled_ns: u64,
|
||||
dispatched_ns: Option<u64>,
|
||||
completed_ns: u64,
|
||||
outcome: &'static str,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Report {
|
||||
complete: bool,
|
||||
errors: usize,
|
||||
manifest_sha256: String,
|
||||
fixture_sha256: String,
|
||||
start_unix_ns: String,
|
||||
observation_end_unix_ns: String,
|
||||
offered_duration_secs: u32,
|
||||
control_rps: u32,
|
||||
bulk_bytes_completed_during_offering: u64,
|
||||
bulk_coverage_verified: bool,
|
||||
bulk_workers: usize,
|
||||
control_expected: u32,
|
||||
targets: usize,
|
||||
peers: usize,
|
||||
samples: Vec<Sample>,
|
||||
external_markers: bool,
|
||||
scope: &'static str,
|
||||
}
|
||||
|
||||
fn now_ns() -> Result<u64> {
|
||||
u64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).map_err(Error::other)?.as_nanos()).map_err(Error::other)
|
||||
}
|
||||
|
||||
fn elapsed_ns(origin: Instant) -> Result<u64> {
|
||||
u64::try_from(origin.elapsed().as_nanos()).map_err(Error::other)
|
||||
}
|
||||
|
||||
fn measurement_origin(start_ns: u64, current_ns: u64, monotonic_now: Instant) -> Result<Instant> {
|
||||
let lag = current_ns
|
||||
.checked_sub(start_ns)
|
||||
.filter(|lag| *lag <= 1_000_000_000)
|
||||
.ok_or_else(|| Error::other("measurement start must be within the preceding second"))?;
|
||||
monotonic_now
|
||||
.checked_sub(Duration::from_nanos(lag))
|
||||
.ok_or_else(|| Error::other("invalid monotonic origin"))
|
||||
}
|
||||
|
||||
fn validate_observation_end(start_ns: u64, end_ns: u64, finished_ns: u64, current_ns: u64, elapsed: u64) -> Result<()> {
|
||||
let clock_end = start_ns
|
||||
.checked_add(elapsed)
|
||||
.ok_or_else(|| Error::other("observation window overflow"))?;
|
||||
if end_ns < finished_ns || end_ns > current_ns || clock_end.abs_diff(current_ns) > 100_000_000 {
|
||||
return Err(Error::other("invalid measurement-end acknowledgement or clock drift"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn bounded_file(path: &Path, limit: usize) -> Result<Vec<u8>> {
|
||||
let file = tokio::fs::File::open(path).await?;
|
||||
if !file.metadata().await?.is_file() {
|
||||
return Err(Error::new(ErrorKind::InvalidInput, "probe inputs must be regular files"));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
file.take(u64::try_from(limit).map_err(Error::other)? + 1)
|
||||
.read_to_end(&mut data)
|
||||
.await?;
|
||||
if data.len() > limit {
|
||||
return Err(Error::new(ErrorKind::InvalidData, "probe input exceeds its size bound"));
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
async fn marker(path: &Path) -> Result<u64> {
|
||||
timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
match bounded_file(path, 512).await {
|
||||
Ok(data) => {
|
||||
return serde_json::from_slice::<Marker>(&data)
|
||||
.map(|value| value.time_ns)
|
||||
.map_err(Error::other);
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => sleep(Duration::from_millis(5)).await,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::new(ErrorKind::TimedOut, "measurement marker did not arrive"))?
|
||||
}
|
||||
|
||||
fn phase(name: &str, time_ns: u64) -> Result<()> {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"phase":name,"time_ns":time_ns.to_string(),"pid":std::process::id()})
|
||||
);
|
||||
std::io::stdout().flush()
|
||||
}
|
||||
|
||||
async fn ping(peer: &str) -> Result<(u64, bytes::Bytes)> {
|
||||
let mut client = node_service_time_out_client(peer, TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
||||
.await
|
||||
.map_err(|_| Error::other("authenticated control client acquisition failed"))?
|
||||
.max_decoding_message_size(4096);
|
||||
let response = client
|
||||
.ping(PingRequest {
|
||||
version: 1,
|
||||
body: bytes::Bytes::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::other("authenticated Ping failed"))?
|
||||
.into_inner();
|
||||
Ok((response.version, response.body))
|
||||
}
|
||||
|
||||
struct Context {
|
||||
config: Config,
|
||||
disks: Vec<DiskStore>,
|
||||
peers: Vec<String>,
|
||||
echoes: Vec<bytes::Bytes>,
|
||||
payload: Vec<u8>,
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn bulk(context: Arc<Context>, worker: usize, origin: Instant, end: Instant) -> Result<Vec<Sample>> {
|
||||
let mut rows = Vec::with_capacity(256);
|
||||
let limit = MAX_BULK_ROWS / context.config.bulk_workers;
|
||||
for sequence in 0..limit {
|
||||
if Instant::now() >= end {
|
||||
return Ok(rows);
|
||||
}
|
||||
let peer = (worker + sequence) % context.disks.len();
|
||||
let dispatched = elapsed_ns(origin)?;
|
||||
let result = timeout(
|
||||
Duration::from_millis(u64::from(context.config.rpc_timeout_ms)),
|
||||
context.disks[peer].read_all(".rustfs.sys", &context.path),
|
||||
)
|
||||
.await;
|
||||
let completed = elapsed_ns(origin)?;
|
||||
let outcome = match result {
|
||||
Ok(Ok(data)) if data.as_ref() == context.payload.as_slice() => "ok",
|
||||
Ok(Ok(_)) => "payload_mismatch",
|
||||
Ok(Err(_)) => "rpc_error",
|
||||
Err(_) => "deadline",
|
||||
};
|
||||
rows.push(Sample {
|
||||
operation: "ReadAll",
|
||||
peer,
|
||||
scheduled_ns: dispatched,
|
||||
dispatched_ns: Some(dispatched),
|
||||
completed_ns: completed,
|
||||
outcome,
|
||||
bytes: if outcome == "ok" { context.payload.len() } else { 0 },
|
||||
});
|
||||
}
|
||||
if Instant::now() >= end {
|
||||
Ok(rows)
|
||||
} else {
|
||||
Err(Error::other("bulk raw-sample limit reached; attribution is invalid"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn control(context: Arc<Context>, origin: Instant) -> Result<Vec<Sample>> {
|
||||
let count = context.config.duration_secs * context.config.control_rps;
|
||||
let capacity = usize::try_from(count).map_err(Error::other)?;
|
||||
let mut rows = Vec::with_capacity(capacity);
|
||||
let permits = Arc::new(Semaphore::new(128));
|
||||
let end = origin + Duration::from_secs(u64::from(context.config.duration_secs));
|
||||
let mut tasks = JoinSet::new();
|
||||
for sequence in 0..count {
|
||||
// Configuration bounds count to one million, so this product fits u64.
|
||||
let scheduled = u64::from(sequence) * 1_000_000_000 / u64::from(context.config.control_rps);
|
||||
sleep_until(origin + Duration::from_nanos(scheduled)).await;
|
||||
while let Some(result) = tasks.try_join_next() {
|
||||
rows.push(result.map_err(Error::other)??);
|
||||
}
|
||||
let peer = usize::try_from(sequence).map_err(Error::other)? % context.peers.len();
|
||||
let permit = (Instant::now() < end)
|
||||
.then(|| permits.clone().try_acquire_owned())
|
||||
.and_then(std::result::Result::ok);
|
||||
let Some(permit) = permit else {
|
||||
rows.push(Sample {
|
||||
operation: "Ping",
|
||||
peer,
|
||||
scheduled_ns: scheduled,
|
||||
dispatched_ns: None,
|
||||
completed_ns: elapsed_ns(origin)?,
|
||||
outcome: "schedule_drop",
|
||||
bytes: 0,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let context = context.clone();
|
||||
tasks.spawn(async move {
|
||||
let _permit = permit;
|
||||
let dispatched = elapsed_ns(origin)?;
|
||||
let result = timeout(
|
||||
Duration::from_millis(u64::from(context.config.rpc_timeout_ms)),
|
||||
ping(&context.peers[peer]),
|
||||
)
|
||||
.await;
|
||||
let completed = elapsed_ns(origin)?;
|
||||
let outcome = match result {
|
||||
Ok(Ok((1, body))) if body == context.echoes[peer] => "ok",
|
||||
Ok(Ok(_)) => "payload_mismatch",
|
||||
Ok(Err(_)) => "rpc_error",
|
||||
Err(_) => "deadline",
|
||||
};
|
||||
Ok::<_, Error>(Sample {
|
||||
operation: "Ping",
|
||||
peer,
|
||||
scheduled_ns: scheduled,
|
||||
dispatched_ns: Some(dispatched),
|
||||
completed_ns: completed,
|
||||
outcome,
|
||||
bytes: 0,
|
||||
})
|
||||
});
|
||||
}
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
rows.push(result.map_err(Error::other)??);
|
||||
}
|
||||
if rows.len() != capacity {
|
||||
return Err(Error::other("control arrival accounting is incomplete"));
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn run(config_bytes: &[u8], base: &Path, secret: String) -> Result<Report> {
|
||||
let mut config: Config = serde_json::from_slice(config_bytes).map_err(|_| Error::other("invalid probe manifest"))?;
|
||||
if config.fixture_file.is_relative() {
|
||||
config.fixture_file = base.join(&config.fixture_file);
|
||||
}
|
||||
for path in [&mut config.tls_ca, &mut config.start_marker, &mut config.end_marker]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if path.is_relative() {
|
||||
*path = base.join(&*path);
|
||||
}
|
||||
}
|
||||
let endpoints = config.endpoints()?;
|
||||
for path in [&config.start_marker, &config.end_marker].into_iter().flatten() {
|
||||
if tokio::fs::try_exists(path).await? {
|
||||
return Err(Error::other("measurement markers must be fresh"));
|
||||
}
|
||||
}
|
||||
// Require the test deployment's explicit secret; never use a default credential.
|
||||
if secret.trim().is_empty() {
|
||||
return Err(Error::other("RUSTFS_RPC_SECRET is required"));
|
||||
}
|
||||
rustfs_credentials::set_global_rpc_secret(secret).map_err(|_| Error::other("RPC secret already initialized"))?;
|
||||
ensure_crypto_provider().map_err(Error::other)?;
|
||||
if let Some(path) = &config.tls_ca {
|
||||
let ca = bounded_file(path, 1024 * 1024).await?;
|
||||
if ca.is_empty() {
|
||||
return Err(Error::other("the explicit test CA must not be empty"));
|
||||
}
|
||||
publish_global_outbound_tls_state(
|
||||
TlsGeneration(1),
|
||||
&OutboundTlsMaterial {
|
||||
root_ca_pem: ca,
|
||||
mtls_identity: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let payload = bounded_file(&config.fixture_file, MAX_PAYLOAD).await?;
|
||||
if payload.is_empty()
|
||||
|| payload
|
||||
.len()
|
||||
.checked_mul(config.bulk_workers)
|
||||
.is_none_or(|bytes| bytes > 512 * 1024 * 1024)
|
||||
{
|
||||
return Err(Error::other("fixture and worker count exceed the payload memory budget"));
|
||||
}
|
||||
let path = format!("tmp/{}/grpc-bench-payload.bin", config.fixture_id);
|
||||
let mut disks = Vec::with_capacity(endpoints.len());
|
||||
let mut peers = BTreeSet::new();
|
||||
for (index, endpoint) in endpoints.into_iter().enumerate() {
|
||||
peers.insert(endpoint.grid_host());
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(Error::other)?;
|
||||
if let Some(ids) = &config.disk_ids {
|
||||
disk.set_disk_id(Some(ids[index])).await.map_err(Error::other)?;
|
||||
}
|
||||
let data = timeout(Duration::from_secs(30), disk.read_all(".rustfs.sys", &path))
|
||||
.await
|
||||
.map_err(Error::other)?
|
||||
.map_err(|error| Error::other(format!("ReadAll fixture preflight failed: disk_error_code={}", error.to_u32())))?;
|
||||
if data.as_ref() != payload.as_slice() {
|
||||
return Err(Error::other("ReadAll fixture preflight mismatch"));
|
||||
}
|
||||
disks.push(disk);
|
||||
}
|
||||
let peers: Vec<_> = peers.into_iter().collect();
|
||||
let mut echoes = Vec::with_capacity(peers.len());
|
||||
for peer in &peers {
|
||||
let (version, body) = timeout(Duration::from_secs(30), ping(peer)).await.map_err(Error::other)??;
|
||||
if version != 1 || body.is_empty() {
|
||||
return Err(Error::other("Ping preflight mismatch"));
|
||||
}
|
||||
echoes.push(body);
|
||||
}
|
||||
let context = Arc::new(Context {
|
||||
config,
|
||||
disks,
|
||||
peers,
|
||||
echoes,
|
||||
payload,
|
||||
path,
|
||||
});
|
||||
phase("ready", now_ns()?)?;
|
||||
let start_ns = match &context.config.start_marker {
|
||||
Some(path) => marker(path).await?,
|
||||
None => now_ns()?,
|
||||
};
|
||||
let current_ns = now_ns()?;
|
||||
let origin = measurement_origin(start_ns, current_ns, Instant::now())?;
|
||||
let offered = Duration::from_secs(u64::from(context.config.duration_secs));
|
||||
let end = origin + offered;
|
||||
phase("measure_start", start_ns)?;
|
||||
let mut tasks = JoinSet::new();
|
||||
for worker in 0..context.config.bulk_workers {
|
||||
tasks.spawn(bulk(context.clone(), worker, origin, end));
|
||||
}
|
||||
tasks.spawn(control(context.clone(), origin));
|
||||
let mut samples = Vec::new();
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
samples.extend(result.map_err(Error::other)??);
|
||||
}
|
||||
let finished_ns = now_ns()?;
|
||||
phase("load_finished", finished_ns)?;
|
||||
let end_ns = match &context.config.end_marker {
|
||||
Some(path) => marker(path).await?,
|
||||
None => now_ns()?,
|
||||
};
|
||||
let current_ns = now_ns()?;
|
||||
validate_observation_end(start_ns, end_ns, finished_ns, current_ns, elapsed_ns(origin)?)?;
|
||||
let offered_ns = u64::try_from(offered.as_nanos()).map_err(Error::other)?;
|
||||
let errors = samples.iter().filter(|sample| sample.outcome != "ok").count();
|
||||
let measured_bytes = samples
|
||||
.iter()
|
||||
.filter(|sample| sample.operation == "ReadAll" && sample.outcome == "ok" && sample.completed_ns <= offered_ns)
|
||||
.try_fold(0_u64, |total, sample| {
|
||||
total
|
||||
.checked_add(u64::try_from(sample.bytes).map_err(Error::other)?)
|
||||
.ok_or_else(|| Error::other("measured payload byte counter overflow"))
|
||||
})?;
|
||||
let report = Report {
|
||||
complete: true,
|
||||
errors,
|
||||
manifest_sha256: hex_simd::encode_to_string(Sha256::digest(config_bytes), hex_simd::AsciiCase::Lower),
|
||||
fixture_sha256: hex_simd::encode_to_string(Sha256::digest(&context.payload), hex_simd::AsciiCase::Lower),
|
||||
start_unix_ns: start_ns.to_string(),
|
||||
observation_end_unix_ns: end_ns.to_string(),
|
||||
offered_duration_secs: context.config.duration_secs,
|
||||
control_rps: context.config.control_rps,
|
||||
bulk_bytes_completed_during_offering: measured_bytes,
|
||||
bulk_coverage_verified: measured_bytes > 0,
|
||||
bulk_workers: context.config.bulk_workers,
|
||||
control_expected: context.config.duration_secs * context.config.control_rps,
|
||||
targets: context.disks.len(),
|
||||
peers: context.peers.len(),
|
||||
samples,
|
||||
external_markers: context.config.start_marker.is_some(),
|
||||
scope: "Authenticated production RPC clients and ReadAll temporary fixtures; client telemetry export is disabled. Storage S3/durability, mTLS/rotation and ABBA qualification remain separate.",
|
||||
};
|
||||
for disk in &context.disks {
|
||||
disk.close().await.map_err(Error::other)?;
|
||||
}
|
||||
phase("measure_end", end_ns)?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[tokio::main(worker_threads = 8)]
|
||||
async fn main() -> Result<()> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let config = args
|
||||
.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| Error::other("usage: internode_rpc_bench manifest.json output.json"))?;
|
||||
let output = args
|
||||
.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| Error::other("output path is required"))?;
|
||||
if args.next().is_some() {
|
||||
return Err(Error::other("unexpected probe arguments"));
|
||||
}
|
||||
let input = bounded_file(&config, 64 * 1024).await?;
|
||||
let file = tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(output)
|
||||
.await?
|
||||
.into_std()
|
||||
.await;
|
||||
let secret = std::env::var("RUSTFS_RPC_SECRET").map_err(|_| Error::other("RUSTFS_RPC_SECRET is required"))?;
|
||||
let report = run(&input, config.parent().unwrap_or_else(|| Path::new(".")), secret).await?;
|
||||
let success = report.errors == 0 && report.bulk_coverage_verified;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut output = std::io::BufWriter::new(file);
|
||||
serde_json::to_writer(&mut output, &report).map_err(Error::other)?;
|
||||
output.flush()
|
||||
})
|
||||
.await
|
||||
.map_err(Error::other)??;
|
||||
if success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::other(
|
||||
"RPC failures or missing bulk coverage invalidate attribution; see raw samples",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustfs_protos::proto_gen::node_service::{PingResponse, ReadAllRequest, ReadAllResponse};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::task::{Context as TaskContext, Poll};
|
||||
use tonic::codegen::{Body, BoxFuture, Service, StdError, http};
|
||||
|
||||
fn config() -> Config {
|
||||
Config {
|
||||
namespace: "grpc-bench-test".into(),
|
||||
targets: vec!["http://127.0.0.1:19000/data/grpc-bench-test".into()],
|
||||
disk_ids: None,
|
||||
fixture_id: Uuid::from_u128(1),
|
||||
fixture_file: PathBuf::from("fixture.bin"),
|
||||
duration_secs: 1,
|
||||
bulk_workers: 2,
|
||||
control_rps: 20,
|
||||
rpc_timeout_ms: 1000,
|
||||
tls_ca: None,
|
||||
start_marker: None,
|
||||
end_marker: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_rejects_non_test_and_credential_bearing_targets() {
|
||||
for target in [
|
||||
"file:///data/grpc-bench-test",
|
||||
"http://user:private@127.0.0.1:19000/data/grpc-bench-test",
|
||||
"http://127.0.0.1:19000/data/mnmd",
|
||||
"http://127.0.0.1:80/data/grpc-bench-test",
|
||||
"http://127.0.0.1:19000/data/grpc-bench-test?token=private",
|
||||
"http://127.0.0.1:19000/data/%67rpc-bench-test",
|
||||
"https://127.0.0.1:19000/data/grpc-bench-test",
|
||||
] {
|
||||
let mut config = config();
|
||||
config.targets = vec![target.into()];
|
||||
assert!(config.endpoints().is_err(), "unsafe target was accepted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_forces_remote_client_and_rejects_duplicate_disks() {
|
||||
let mut config = config();
|
||||
assert!(!config.endpoints().expect("valid loopback test URL")[0].is_local);
|
||||
config.targets.push(config.targets[0].clone());
|
||||
assert!(config.endpoints().is_err(), "duplicate target must not inflate the inventory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_requires_a_complete_non_nil_disk_id_mapping() {
|
||||
let mut config = config();
|
||||
config.disk_ids = Some(vec![]);
|
||||
assert!(config.endpoints().is_err());
|
||||
config.disk_ids = Some(vec![Uuid::nil()]);
|
||||
assert!(config.endpoints().is_err());
|
||||
config.disk_ids = Some(vec![Uuid::from_u128(42)]);
|
||||
assert!(config.endpoints().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_bounds_work_and_requires_distinct_measurement_markers() {
|
||||
for (seconds, workers, rate, deadline) in [
|
||||
(0, 2, 20, 1000),
|
||||
(1, 0, 20, 1000),
|
||||
(1, 129, 20, 1000),
|
||||
(1800, 2, 10_000, 1000),
|
||||
(1, 2, 0, 1000),
|
||||
(1, 2, 20, 0),
|
||||
] {
|
||||
let mut config = config();
|
||||
config.duration_secs = seconds;
|
||||
config.bulk_workers = workers;
|
||||
config.control_rps = rate;
|
||||
config.rpc_timeout_ms = deadline;
|
||||
assert!(config.endpoints().is_err());
|
||||
}
|
||||
let mut config = config();
|
||||
config.start_marker = Some("marker.json".into());
|
||||
assert!(config.endpoints().is_err());
|
||||
config.end_marker = config.start_marker.clone();
|
||||
assert!(config.endpoints().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_rejects_stale_future_or_truncated_measurement_markers() {
|
||||
let now = Instant::now();
|
||||
assert_eq!(
|
||||
measurement_origin(10_000_000_000, 11_000_000_000, now).expect("one-second lag bound"),
|
||||
now - Duration::from_secs(1)
|
||||
);
|
||||
assert!(measurement_origin(10_000_000_000, 11_000_000_001, now).is_err());
|
||||
assert!(measurement_origin(11_000_000_001, 11_000_000_000, now).is_err());
|
||||
assert!(validate_observation_end(100, 200, 200, 250, 150).is_ok());
|
||||
assert!(validate_observation_end(100, 199, 200, 250, 150).is_err());
|
||||
assert!(validate_observation_end(100, 251, 200, 250, 150).is_err());
|
||||
assert!(validate_observation_end(100, 200, 200, 250_000_000, 150).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_marker_input_has_an_explicit_schema_and_size_bound() {
|
||||
let directory = tempfile::tempdir().expect("marker directory");
|
||||
let path = directory.path().join("marker.json");
|
||||
tokio::fs::write(&path, br#"{"time_ns":100}"#).await.expect("marker");
|
||||
assert_eq!(marker(&path).await.expect("valid marker"), 100);
|
||||
tokio::fs::write(&path, br#"{"time_ns":100,"unknown":true}"#)
|
||||
.await
|
||||
.expect("invalid marker");
|
||||
assert!(marker(&path).await.is_err());
|
||||
tokio::fs::write(&path, vec![b' '; 513]).await.expect("oversized marker");
|
||||
assert!(marker(&path).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_bounds_fixture_reads_and_rejects_directories() {
|
||||
let directory = tempfile::tempdir().expect("temporary fixture directory");
|
||||
let path = directory.path().join("fixture.bin");
|
||||
tokio::fs::write(&path, [7_u8; 32]).await.expect("fixture write");
|
||||
assert_eq!(bounded_file(&path, 32).await.expect("exact-size fixture"), vec![7_u8; 32]);
|
||||
tokio::fs::write(&path, [7_u8; 33]).await.expect("oversized fixture write");
|
||||
assert!(bounded_file(&path, 32).await.is_err());
|
||||
assert!(bounded_file(directory.path(), 32).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_accounts_for_expired_control_arrivals_without_sending_them() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("owned control socket");
|
||||
let context = Arc::new(super::Context {
|
||||
config: config(),
|
||||
disks: vec![],
|
||||
peers: vec![format!("http://{}", listener.local_addr().expect("owned address"))],
|
||||
echoes: vec![bytes::Bytes::from_static(b"hello")],
|
||||
payload: vec![],
|
||||
path: String::new(),
|
||||
});
|
||||
let rows = control(context, Instant::now() - Duration::from_secs(2))
|
||||
.await
|
||||
.expect("arrival accounting");
|
||||
assert_eq!(rows.len(), 20);
|
||||
assert!(
|
||||
rows.iter()
|
||||
.all(|row| row.outcome == "schedule_drop" && row.dispatched_ns.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ReadFixture(bytes::Bytes, Arc<AtomicBool>);
|
||||
|
||||
impl tonic::server::UnaryService<ReadAllRequest> for ReadFixture {
|
||||
type Response = ReadAllResponse;
|
||||
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
|
||||
fn call(&mut self, request: tonic::Request<ReadAllRequest>) -> Self::Future {
|
||||
assert_eq!(request.get_ref().volume, ".rustfs.sys");
|
||||
assert_eq!(request.get_ref().disk, Uuid::from_u128(42).to_string());
|
||||
assert!(request.get_ref().path.ends_with("/grpc-bench-payload.bin"));
|
||||
let data = if self.1.load(Ordering::SeqCst) {
|
||||
bytes::Bytes::from_static(b"corrupt fixture")
|
||||
} else {
|
||||
self.0.clone()
|
||||
};
|
||||
Box::pin(async move {
|
||||
Ok(tonic::Response::new(ReadAllResponse {
|
||||
success: true,
|
||||
data,
|
||||
error: None,
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ReplyPing;
|
||||
|
||||
impl tonic::server::UnaryService<PingRequest> for ReplyPing {
|
||||
type Response = PingResponse;
|
||||
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
|
||||
fn call(&mut self, _: tonic::Request<PingRequest>) -> Self::Future {
|
||||
Box::pin(async {
|
||||
Ok(tonic::Response::new(PingResponse {
|
||||
version: 1,
|
||||
body: bytes::Bytes::from_static(b"hello, caller"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FixtureService {
|
||||
data: bytes::Bytes,
|
||||
signed: Arc<AtomicUsize>,
|
||||
corrupt: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl tonic::server::NamedService for FixtureService {
|
||||
const NAME: &'static str = "node_service.NodeService";
|
||||
}
|
||||
|
||||
impl<B> Service<http::Request<B>> for FixtureService
|
||||
where
|
||||
B: Body + Send + 'static,
|
||||
B::Error: Into<StdError> + Send + 'static,
|
||||
{
|
||||
type Response = http::Response<tonic::body::Body>;
|
||||
type Error = Infallible;
|
||||
type Future = BoxFuture<Self::Response, Self::Error>;
|
||||
fn poll_ready(&mut self, _: &mut TaskContext<'_>) -> Poll<std::result::Result<(), Self::Error>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
fn call(&mut self, request: http::Request<B>) -> Self::Future {
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get("x-rustfs-rpc-auth-version")
|
||||
.expect("signed production client"),
|
||||
"2"
|
||||
);
|
||||
assert!(request.headers().contains_key("x-rustfs-rpc-signature-v2"));
|
||||
self.signed.fetch_add(1, Ordering::Relaxed);
|
||||
let data = self.data.clone();
|
||||
let corrupt = self.corrupt.clone();
|
||||
Box::pin(async move {
|
||||
if request.uri().path().ends_with("/ReadAll") {
|
||||
Ok(tonic::server::Grpc::new(tonic_prost::ProstCodec::default())
|
||||
.unary(ReadFixture(data, corrupt), request)
|
||||
.await)
|
||||
} else {
|
||||
assert!(request.uri().path().ends_with("/Ping"));
|
||||
Ok(tonic::server::Grpc::new(tonic_prost::ProstCodec::default())
|
||||
.unary(ReplyPing, request)
|
||||
.await)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_exercises_production_readall_and_fixed_rate_ping_clients() {
|
||||
let directory = tempfile::tempdir().expect("probe fixtures");
|
||||
let payload = vec![17_u8; 4096];
|
||||
let file = directory.path().join("fixture.bin");
|
||||
tokio::fs::write(&file, &payload).await.expect("fixture bytes");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("owned RPC listener");
|
||||
let address = listener.local_addr().expect("owned RPC address");
|
||||
let incoming = futures_util::stream::unfold(listener, |listener| async move {
|
||||
Some((listener.accept().await.map(|(stream, _)| stream), listener))
|
||||
});
|
||||
let (stop, stopped) = tokio::sync::oneshot::channel();
|
||||
let signed = Arc::new(AtomicUsize::new(0));
|
||||
let corrupt = Arc::new(AtomicBool::new(false));
|
||||
let service = FixtureService {
|
||||
data: payload.into(),
|
||||
signed: signed.clone(),
|
||||
corrupt: corrupt.clone(),
|
||||
};
|
||||
let mut server = tokio::spawn(
|
||||
tonic::transport::Server::builder()
|
||||
.add_service(service)
|
||||
.serve_with_incoming_shutdown(Box::pin(incoming), async {
|
||||
let _ = stopped.await;
|
||||
}),
|
||||
);
|
||||
let mut config = config();
|
||||
config.targets = vec![format!("http://{address}/data/grpc-bench-test")];
|
||||
config.disk_ids = Some(vec![Uuid::from_u128(42)]);
|
||||
config.fixture_file = file;
|
||||
let input = serde_json::to_vec(&config).expect("probe manifest");
|
||||
let result = run(&input, directory.path(), "test-bench-rpc-secret".into()).await;
|
||||
let negative = async {
|
||||
let endpoint = config.endpoints()?.remove(0);
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(Error::other)?;
|
||||
disk.set_disk_id(Some(Uuid::from_u128(42))).await.map_err(Error::other)?;
|
||||
corrupt.store(true, Ordering::SeqCst);
|
||||
let context = Arc::new(super::Context {
|
||||
path: format!("tmp/{}/grpc-bench-payload.bin", config.fixture_id),
|
||||
config: config.clone(),
|
||||
disks: vec![disk.clone()],
|
||||
peers: vec![],
|
||||
echoes: vec![],
|
||||
payload: tokio::fs::read(&config.fixture_file).await?,
|
||||
});
|
||||
let origin = Instant::now();
|
||||
let rows = bulk(context, 0, origin, origin + Duration::from_millis(50)).await?;
|
||||
disk.close().await.map_err(Error::other)?;
|
||||
Ok::<_, Error>(rows)
|
||||
}
|
||||
.await;
|
||||
let _ = stop.send(());
|
||||
let retirement = timeout(Duration::from_secs(5), &mut server).await;
|
||||
if retirement.is_err() {
|
||||
server.abort();
|
||||
let _ = server.await;
|
||||
}
|
||||
assert!(matches!(retirement, Ok(Ok(Ok(())))), "owned RPC fixture must retire successfully");
|
||||
let result = result.expect("production client probe");
|
||||
assert!(result.bulk_coverage_verified);
|
||||
let report = serde_json::to_value(result).expect("raw sample report");
|
||||
assert_eq!(report["errors"], 0);
|
||||
assert_eq!(report["control_expected"], 20);
|
||||
let rows = report["samples"].as_array().expect("raw rows");
|
||||
assert_eq!(rows.iter().filter(|row| row["operation"] == "Ping").count(), 20);
|
||||
assert!(rows.iter().any(|row| row["operation"] == "ReadAll"));
|
||||
assert!(signed.load(Ordering::Relaxed) > 20);
|
||||
let rejected = negative.expect("corrupted-response probe");
|
||||
assert!(!rejected.is_empty());
|
||||
assert!(rejected.iter().all(|row| row.outcome == "payload_mismatch" && row.bytes == 0));
|
||||
}
|
||||
}
|
||||
@@ -194,8 +194,8 @@ pub mod bucket {
|
||||
table_catalog_path_hash, unreadable_config_refusal,
|
||||
};
|
||||
pub use crate::bucket::metadata::{
|
||||
BUCKET_DURABILITY_CONFIG, BUCKET_ETAG_CONFIG, BUCKET_ON_DEMAND_MIGRATION_CONFIG, MAX_SWIFT_METADATA_COUNT,
|
||||
MAX_SWIFT_METADATA_VALUE_SIZE, SWIFT_ACCOUNT_META_TAG_PREFIX, SWIFT_CONTAINER_META_TAG_PREFIX, SWIFT_TAGGING_MARKER,
|
||||
BUCKET_DURABILITY_CONFIG, BUCKET_ON_DEMAND_MIGRATION_CONFIG, MAX_SWIFT_METADATA_COUNT, MAX_SWIFT_METADATA_VALUE_SIZE,
|
||||
SWIFT_ACCOUNT_META_TAG_PREFIX, SWIFT_CONTAINER_META_TAG_PREFIX, SWIFT_TAGGING_MARKER,
|
||||
};
|
||||
pub use crate::bucket::metadata::{MAX_BUCKET_TAGS, is_swift_bucket_metadata_tag_key, validate_tag_set_entries};
|
||||
}
|
||||
@@ -212,16 +212,6 @@ pub mod bucket {
|
||||
};
|
||||
}
|
||||
|
||||
pub mod etag {
|
||||
pub use crate::bucket::etag::{
|
||||
BUCKET_ETAG_ALGORITHM_MD5, BUCKET_ETAG_ALGORITHM_XXH3, BucketEtagConfig, bucket_uses_xxh3_etag,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod lockless_cache {
|
||||
pub use crate::bucket::lockless_cache::is_lockless_cache_bucket;
|
||||
}
|
||||
|
||||
pub mod metadata_sys {
|
||||
pub use crate::bucket::metadata_sys::{
|
||||
BUCKET_CONFIG_PUBLISH_HOOK, BucketConfigPublishHook, BucketMetadataMutationGuard, BucketMetadataSys,
|
||||
@@ -230,7 +220,7 @@ pub mod bucket {
|
||||
acquire_scanner_bucket_incarnation_read_fence, capture_bucket_metadata_incarnation, delete, delete_if_incarnation,
|
||||
delete_if_incarnation_at, delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy,
|
||||
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
||||
get_etag_config, get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
||||
get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
|
||||
get_on_demand_migration_config_in, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||
get_request_payment_config, get_s3_tagging_config, get_sse_config, get_tagging_config, get_versioning_config,
|
||||
@@ -604,14 +594,9 @@ pub mod integrity {
|
||||
}
|
||||
|
||||
pub mod object {
|
||||
pub use crate::erasure::codec::shard_buf::{
|
||||
ArenaBlock, RegisteredSlabArena, ShardArena, ShardBuf, install_shard_arena, shard_arena,
|
||||
};
|
||||
pub use crate::erasure::codec::shard_emit::ShardEmitter;
|
||||
pub use crate::object_api::{
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReadCondition, GetObjectReadMetadata, GetObjectReader,
|
||||
GetObjectSink, GetObjectSinkSegmentFactory, GetObjectSinkSegments, GetObjectSinkStream, GetObjectSinkWriter,
|
||||
NamespaceLockFence, ObjectEncryptionResolver, ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions,
|
||||
PutObjReader, QuotaAdmission, RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
|
||||
@@ -647,20 +632,13 @@ pub mod rebalance {
|
||||
|
||||
pub mod rio {
|
||||
pub use crate::io_support::rio::{
|
||||
DecryptReader, DynReader, EncryptReader, EtagAlgorithm, HardLimitReader, HashReader, ReadStream, Reader, WriteEncryption,
|
||||
WritePlan, boxed_reader, compression_metadata_value, validate_compression_scheme, wrap_reader,
|
||||
DecryptReader, DynReader, EncryptReader, HardLimitReader, HashReader, ReadStream, Reader, WriteEncryption, WritePlan,
|
||||
boxed_reader, compression_metadata_value, validate_compression_scheme, wrap_reader,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod rpc {
|
||||
#[cfg(feature = "internode-transport")]
|
||||
pub use crate::cluster::rpc::http_auth::ExternalStreamAuth;
|
||||
pub use crate::cluster::rpc::http_auth::with_rpc_background_request;
|
||||
#[cfg(feature = "internode-transport")]
|
||||
pub use crate::cluster::rpc::internode_data_transport::{
|
||||
InternodeDataTransport, InternodeDataTransportCapabilities, NsScannerCapabilityRequest, NsScannerStreamRequest,
|
||||
ReadStreamRequest, TcpHttpInternodeDataTransport, WalkDirStreamRequest, WriteStreamRequest,
|
||||
};
|
||||
pub use crate::cluster::rpc::{
|
||||
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, MAX_NETWORK_PROBE_BYTES, MAX_NETWORK_PROBE_DURATION,
|
||||
NetworkPeerProbeClient, NetworkPeerProbeError, NetworkPeerProbeMeasurement, NetworkPeerTarget, PEER_RESTDRY_RUN,
|
||||
@@ -721,8 +699,6 @@ pub mod store_list {
|
||||
pub mod storage {
|
||||
pub use crate::core::pools::{HealLifecycleExpiryContext, POOL_META_NAME};
|
||||
pub use crate::store::HealWalkVersion;
|
||||
#[cfg(feature = "internode-transport")]
|
||||
pub use crate::store::external_rpc_disk;
|
||||
pub use crate::store::{
|
||||
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk,
|
||||
all_local_disk_path, find_local_disk_by_ref, find_local_disk_by_ref_for_rpc, init_local_disks,
|
||||
|
||||
@@ -1189,7 +1189,6 @@ impl BucketTargetSys {
|
||||
disable_proxy: target.disable_proxy,
|
||||
arn: target.arn.clone(),
|
||||
reset_id: target.reset_id.clone(),
|
||||
site_peer_deployment_id: target.deployment_id.trim().to_string(),
|
||||
secure: target.secure,
|
||||
health_check_duration: target.health_check_duration,
|
||||
replicate_sync: target.replication_sync,
|
||||
@@ -2012,9 +2011,6 @@ pub struct TargetClient {
|
||||
pub disable_proxy: bool,
|
||||
pub arn: String,
|
||||
pub reset_id: String,
|
||||
/// Deployment id of the site-replication peer this target was derived
|
||||
/// for; empty for an operator-configured bucket-replication target.
|
||||
pub site_peer_deployment_id: String,
|
||||
pub secure: bool,
|
||||
pub health_check_duration: Duration,
|
||||
pub replicate_sync: bool,
|
||||
@@ -2023,13 +2019,6 @@ pub struct TargetClient {
|
||||
}
|
||||
|
||||
impl TargetClient {
|
||||
/// Whether the target is a site-replication peer. Peers share bucket
|
||||
/// configuration and IAM, and are required to hold KMS keys under the
|
||||
/// same names; an operator-configured target promises neither.
|
||||
pub fn is_site_replication_peer(&self) -> bool {
|
||||
!self.site_peer_deployment_id.is_empty()
|
||||
}
|
||||
|
||||
fn credentials_expired_at(&self, now: jiff::Timestamp) -> bool {
|
||||
self.credentials
|
||||
.as_ref()
|
||||
@@ -3061,7 +3050,6 @@ mod tests {
|
||||
disable_proxy: false,
|
||||
arn: "arn:rustfs:replication:us-east-1:target:bucket".to_string(),
|
||||
reset_id: String::new(),
|
||||
site_peer_deployment_id: String::new(),
|
||||
secure: true,
|
||||
health_check_duration: Duration::from_secs(5),
|
||||
replicate_sync: false,
|
||||
@@ -3605,7 +3593,6 @@ mod tests {
|
||||
disable_proxy: false,
|
||||
arn: "arn:rustfs:replication:us-east-1:target:bucket".to_string(),
|
||||
reset_id: String::new(),
|
||||
site_peer_deployment_id: String::new(),
|
||||
secure: true,
|
||||
health_check_duration: Duration::from_secs(5),
|
||||
replicate_sync: false,
|
||||
@@ -3919,7 +3906,6 @@ mod tests {
|
||||
disable_proxy: false,
|
||||
arn: arn.to_string(),
|
||||
reset_id: String::new(),
|
||||
site_peer_deployment_id: String::new(),
|
||||
secure: true,
|
||||
health_check_duration: Duration::from_secs(5),
|
||||
replicate_sync: false,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Per-bucket ETag algorithm.
|
||||
//!
|
||||
//! A bucket can derive the ETags of objects written without Content-MD5 from
|
||||
//! XXH3-128 instead of MD5, for clients that never treat the ETag as an MD5
|
||||
//! (an LLM KV cache). The choice is stored as a RustFS extension entry
|
||||
//! (`etag.json`) in the bucket metadata file and published to an in-memory
|
||||
//! registry whenever the metadata is installed, so the write path looks it up
|
||||
//! without I/O. Buckets without the entry keep MD5 ETags.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{OnceLock, PoisonError, RwLock};
|
||||
|
||||
pub const BUCKET_ETAG_ALGORITHM_MD5: &str = "md5";
|
||||
pub const BUCKET_ETAG_ALGORITHM_XXH3: &str = "xxh3";
|
||||
|
||||
/// JSON payload stored under the `etag.json` bucket metadata entry. An absent
|
||||
/// entry, an empty payload or an invalid algorithm all mean MD5 ETags.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BucketEtagConfig {
|
||||
/// `md5` | `xxh3` (case-insensitive, whitespace-tolerant).
|
||||
#[serde(default)]
|
||||
pub algorithm: String,
|
||||
}
|
||||
|
||||
impl BucketEtagConfig {
|
||||
pub fn new(algorithm: &str) -> Self {
|
||||
Self {
|
||||
algorithm: algorithm.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `algorithm` names a supported ETag algorithm.
|
||||
pub fn is_valid_algorithm(algorithm: &str) -> bool {
|
||||
matches!(
|
||||
algorithm.trim().to_ascii_lowercase().as_str(),
|
||||
BUCKET_ETAG_ALGORITHM_MD5 | BUCKET_ETAG_ALGORITHM_XXH3
|
||||
)
|
||||
}
|
||||
|
||||
/// The canonical (trimmed, lowercase) algorithm name, or `None` when the
|
||||
/// config does not carry a valid one.
|
||||
pub fn normalized_algorithm(&self) -> Option<String> {
|
||||
let algorithm = self.algorithm.trim().to_ascii_lowercase();
|
||||
if Self::is_valid_algorithm(&algorithm) {
|
||||
Some(algorithm)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this config selects XXH3 ETags.
|
||||
pub fn uses_xxh3(&self) -> bool {
|
||||
self.normalized_algorithm().as_deref() == Some(BUCKET_ETAG_ALGORITHM_XXH3)
|
||||
}
|
||||
}
|
||||
|
||||
/// Buckets whose ETags use XXH3, kept in step with the installed bucket
|
||||
/// metadata. `ANY` lets the write path skip the lock while no bucket opts in.
|
||||
static XXH3_BUCKETS: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
|
||||
static ANY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn xxh3_buckets() -> &'static RwLock<HashSet<String>> {
|
||||
XXH3_BUCKETS.get_or_init(|| RwLock::new(HashSet::new()))
|
||||
}
|
||||
|
||||
/// Records whether `bucket` uses XXH3 ETags.
|
||||
pub fn set_bucket_xxh3_etag(bucket: &str, xxh3: bool) {
|
||||
if bucket.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut buckets = xxh3_buckets().write().unwrap_or_else(PoisonError::into_inner);
|
||||
if xxh3 {
|
||||
buckets.insert(bucket.to_string());
|
||||
} else {
|
||||
buckets.remove(bucket);
|
||||
}
|
||||
ANY.store(!buckets.is_empty(), Ordering::Release);
|
||||
}
|
||||
|
||||
/// Whether objects written to `bucket` without Content-MD5 get XXH3 ETags.
|
||||
pub fn bucket_uses_xxh3_etag(bucket: &str) -> bool {
|
||||
if !ANY.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
xxh3_buckets().read().unwrap_or_else(PoisonError::into_inner).contains(bucket)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_algorithms_are_recognized() {
|
||||
assert!(BucketEtagConfig::is_valid_algorithm("md5"));
|
||||
assert!(BucketEtagConfig::is_valid_algorithm(" XXH3 "));
|
||||
assert!(!BucketEtagConfig::is_valid_algorithm(""));
|
||||
assert!(!BucketEtagConfig::is_valid_algorithm("sha256"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_algorithm_canonicalizes_or_rejects() {
|
||||
assert_eq!(BucketEtagConfig::new(" Xxh3 ").normalized_algorithm().as_deref(), Some("xxh3"));
|
||||
assert_eq!(BucketEtagConfig::new("MD5").normalized_algorithm().as_deref(), Some("md5"));
|
||||
assert_eq!(BucketEtagConfig::new("crc32").normalized_algorithm(), None);
|
||||
assert!(BucketEtagConfig::new("xxh3").uses_xxh3());
|
||||
assert!(!BucketEtagConfig::new("md5").uses_xxh3());
|
||||
assert!(!BucketEtagConfig::default().uses_xxh3());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip() {
|
||||
let config = BucketEtagConfig::new("xxh3");
|
||||
let json = serde_json::to_vec(&config).expect("serialize");
|
||||
assert_eq!(json, br#"{"algorithm":"xxh3"}"#);
|
||||
assert_eq!(serde_json::from_slice::<BucketEtagConfig>(&json).expect("parse"), config);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<BucketEtagConfig>(b"{}").expect("parse"),
|
||||
BucketEtagConfig::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_tracks_xxh3_buckets() {
|
||||
let bucket = "etag-registry-test-bucket";
|
||||
assert!(!bucket_uses_xxh3_etag(bucket));
|
||||
set_bucket_xxh3_etag(bucket, true);
|
||||
assert!(bucket_uses_xxh3_etag(bucket));
|
||||
assert!(!bucket_uses_xxh3_etag("etag-registry-test-other"));
|
||||
set_bucket_xxh3_etag(bucket, false);
|
||||
assert!(!bucket_uses_xxh3_etag(bucket));
|
||||
set_bucket_xxh3_etag("", true);
|
||||
assert!(!bucket_uses_xxh3_etag(""));
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Lockless cache buckets.
|
||||
//!
|
||||
//! A bucket named in `RUSTFS_LOCKLESS_CACHE_BUCKETS` holds a content-addressed
|
||||
//! cache (an LLM KV cache): a key always names the same bytes, and a lost or
|
||||
//! unreadable object only costs a recompute. PutObject, GetObject and HeadObject
|
||||
//! on such a bucket take no distributed locks and read no bucket metadata from
|
||||
//! disk, so concurrent writers into one bucket no longer meet on bucket-wide
|
||||
//! lock keys.
|
||||
//!
|
||||
//! What a listed bucket gives up, by design:
|
||||
//! - Writes of one key are not serialized. Racing commits of the same bytes can
|
||||
//! leave the object without read quorum until it is rewritten or healed,
|
||||
//! which a cache treats as a miss.
|
||||
//! - Reads are not fenced against a concurrent overwrite or delete.
|
||||
//! - Writes are not fenced against bucket deletion, Object Lock, quota,
|
||||
//! versioning, replication or table-catalog changes made while they run. A
|
||||
//! PUT falls back to the locked path whenever the cached bucket state shows
|
||||
//! any of those features, but listed buckets must not enable them.
|
||||
//!
|
||||
//! The list is read once per process; RustFS system buckets are never lockless.
|
||||
|
||||
use super::utils::is_meta_bucketname;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Buckets named in a comma-separated list, without blanks or system buckets.
|
||||
fn parse_lockless_cache_buckets(value: &str) -> HashSet<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|bucket| !bucket.is_empty() && !is_meta_bucketname(bucket))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `bucket` is configured as a lockless cache bucket.
|
||||
pub fn is_lockless_cache_bucket(bucket: &str) -> bool {
|
||||
static BUCKETS: OnceLock<HashSet<String>> = OnceLock::new();
|
||||
BUCKETS
|
||||
.get_or_init(|| {
|
||||
rustfs_utils::get_env_opt_str(rustfs_config::ENV_LOCKLESS_CACHE_BUCKETS)
|
||||
.map(|value| parse_lockless_cache_buckets(&value))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.contains(bucket)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_keeps_named_user_buckets_only() {
|
||||
let buckets = parse_lockless_cache_buckets(" kvcache-a,,kvcache-b , .rustfs.sys ,");
|
||||
assert_eq!(
|
||||
buckets,
|
||||
HashSet::from(["kvcache-a".to_owned(), "kvcache-b".to_owned()]),
|
||||
"blanks and system buckets must be dropped"
|
||||
);
|
||||
assert!(parse_lockless_cache_buckets("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -390,7 +390,6 @@ 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_ETAG_CONFIG: &str = "etag.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";
|
||||
@@ -582,7 +581,6 @@ pub struct BucketMetadata {
|
||||
pub table_bucket_config_json: Vec<u8>,
|
||||
pub durability_config_json: Vec<u8>,
|
||||
pub on_demand_migration_config_json: Vec<u8>,
|
||||
pub etag_config_json: Vec<u8>,
|
||||
|
||||
pub policy_config_updated_at: OffsetDateTime,
|
||||
pub object_lock_config_updated_at: OffsetDateTime,
|
||||
@@ -605,7 +603,6 @@ pub struct BucketMetadata {
|
||||
pub table_bucket_config_updated_at: OffsetDateTime,
|
||||
pub durability_config_updated_at: OffsetDateTime,
|
||||
pub on_demand_migration_config_updated_at: OffsetDateTime,
|
||||
pub etag_config_updated_at: OffsetDateTime,
|
||||
|
||||
pub new_field_updated_at: OffsetDateTime,
|
||||
|
||||
@@ -660,7 +657,6 @@ impl Default for BucketMetadata {
|
||||
table_bucket_config_json: Default::default(),
|
||||
durability_config_json: Default::default(),
|
||||
on_demand_migration_config_json: Default::default(),
|
||||
etag_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,
|
||||
@@ -682,7 +678,6 @@ impl Default for BucketMetadata {
|
||||
table_bucket_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
durability_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
on_demand_migration_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
etag_config_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
new_field_updated_at: OffsetDateTime::UNIX_EPOCH,
|
||||
policy_config: Default::default(),
|
||||
notification_config: Default::default(),
|
||||
@@ -809,29 +804,6 @@ impl BucketMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed per-bucket ETag algorithm, if one is stored. Invalid payloads
|
||||
/// keep MD5 ETags after logging a parse failure.
|
||||
pub fn etag_config(&self) -> Option<super::etag::BucketEtagConfig> {
|
||||
if self.etag_config_json.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match serde_json::from_slice(&self.etag_config_json) {
|
||||
Ok(cfg) => Some(cfg),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
event = "bucket_metadata_parse_failed",
|
||||
component = "ecstore",
|
||||
subsystem = "bucket_metadata",
|
||||
bucket = %self.name,
|
||||
config = "etag",
|
||||
error = %e,
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode from msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
|
||||
pub fn decode_from<R: Read>(&mut self, rd: &mut R) -> Result<()> {
|
||||
let mut fields = rmp::decode::read_map_len(rd)?;
|
||||
@@ -892,7 +864,6 @@ impl BucketMetadata {
|
||||
"OnDemandMigrationConfigJSON" | "OnDemandMigrationConfigJson" => {
|
||||
self.on_demand_migration_config_json = read_msgp_bin(rd)?
|
||||
}
|
||||
"EtagConfigJSON" | "EtagConfigJson" => self.etag_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)?,
|
||||
@@ -903,7 +874,6 @@ impl BucketMetadata {
|
||||
"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)?,
|
||||
"EtagConfigUpdatedAt" => self.etag_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"BucketCreationCommitted" => {
|
||||
self.bucket_creation_committed = read_msgp_bool(rd)?;
|
||||
self.bucket_creation_commit_record_present = true;
|
||||
@@ -922,8 +892,8 @@ impl BucketMetadata {
|
||||
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
|
||||
// Preserve the absence of a legacy commit record across config rewrites.
|
||||
let commit_record_present = self.bucket_creation_commit_record_present || self.bucket_creation_committed;
|
||||
// Map size: MinIO fields (25) + RustFS extensions (23) + optional commit record.
|
||||
let map_len: u32 = 48 + u32::from(commit_record_present);
|
||||
// Map size: MinIO fields (25) + RustFS extensions (21) + optional commit record.
|
||||
let map_len: u32 = 46 + u32::from(commit_record_present);
|
||||
rmp::encode::write_map_len(wr, map_len)?;
|
||||
|
||||
// MinIO field order (same as Go struct)
|
||||
@@ -984,7 +954,6 @@ impl BucketMetadata {
|
||||
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)?;
|
||||
write_bin_field(wr, "EtagConfigJSON", &self.etag_config_json)?;
|
||||
rmp::encode::write_str(wr, "CorsConfigUpdatedAt")?;
|
||||
write_msgp_time(wr, self.cors_config_updated_at)?;
|
||||
rmp::encode::write_str(wr, "LoggingConfigUpdatedAt")?;
|
||||
@@ -1005,8 +974,6 @@ impl BucketMetadata {
|
||||
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)?;
|
||||
rmp::encode::write_str(wr, "EtagConfigUpdatedAt")?;
|
||||
write_msgp_time(wr, self.etag_config_updated_at)?;
|
||||
if commit_record_present {
|
||||
rmp::encode::write_str(wr, "BucketCreationCommitted")?;
|
||||
rmp::encode::write_bool(wr, self.bucket_creation_committed)?;
|
||||
@@ -1115,9 +1082,6 @@ impl BucketMetadata {
|
||||
if self.on_demand_migration_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
||||
self.on_demand_migration_config_updated_at = self.created
|
||||
}
|
||||
if self.etag_config_updated_at == OffsetDateTime::UNIX_EPOCH {
|
||||
self.etag_config_updated_at = self.created
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace one config payload and stamp its `*_config_updated_at` with the
|
||||
@@ -1264,10 +1228,6 @@ impl BucketMetadata {
|
||||
self.on_demand_migration_config_json = data;
|
||||
self.on_demand_migration_config_updated_at = updated;
|
||||
}
|
||||
BUCKET_ETAG_CONFIG => {
|
||||
self.etag_config_json = data;
|
||||
self.etag_config_updated_at = updated;
|
||||
}
|
||||
_ => return Err(Error::other(format!("config file not found : {config_file}"))),
|
||||
}
|
||||
|
||||
@@ -2760,28 +2720,6 @@ mod test {
|
||||
|
||||
/// HP-5b (rustfs/backlog#938): the durability override is a RustFS
|
||||
/// extension entry and must survive an encode/decode round trip.
|
||||
#[test]
|
||||
fn etag_config_round_trips_and_tracks_updates() {
|
||||
let mut bm = BucketMetadata::new("etag-bucket");
|
||||
assert!(bm.etag_config().is_none());
|
||||
bm.update_config(BUCKET_ETAG_CONFIG, br#"{"algorithm":"xxh3"}"#.to_vec())
|
||||
.expect("set etag config");
|
||||
assert_ne!(bm.etag_config_updated_at, OffsetDateTime::UNIX_EPOCH);
|
||||
assert!(bm.etag_config().expect("parsed").uses_xxh3());
|
||||
|
||||
let back = BucketMetadata::unmarshal(&bm.marshal_msg().expect("marshal")).expect("unmarshal");
|
||||
assert_eq!(back.etag_config_json, bm.etag_config_json);
|
||||
assert_eq!(back.etag_config_updated_at.unix_timestamp(), bm.etag_config_updated_at.unix_timestamp());
|
||||
// The durability entry is independent of the ETag entry.
|
||||
assert!(back.durability_config_json.is_empty());
|
||||
|
||||
bm.update_config(BUCKET_ETAG_CONFIG, Vec::new()).expect("clear etag config");
|
||||
assert!(bm.etag_config().is_none());
|
||||
|
||||
bm.etag_config_json = b"not-json".to_vec();
|
||||
assert!(bm.etag_config().is_none(), "garbage must keep MD5 ETags");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durability_config_round_trips_and_tracks_updates() {
|
||||
let mut bm = BucketMetadata::new("durability-bucket");
|
||||
|
||||
@@ -440,25 +440,6 @@ fn clear_bucket_durability(bucket: &str) {
|
||||
crate::disk::local::bucket_durability::set(bucket, None);
|
||||
}
|
||||
|
||||
/// Publish the bucket's ETag algorithm to the registry the write path consults.
|
||||
fn sync_bucket_etag(bucket: &str, bm: &BucketMetadata) {
|
||||
let xxh3 = bm.etag_config().is_some_and(|cfg| cfg.uses_xxh3());
|
||||
crate::bucket::etag::set_bucket_xxh3_etag(bucket, xxh3);
|
||||
}
|
||||
|
||||
/// Publish every per-bucket override kept outside the metadata cache
|
||||
/// (durability tier, ETag algorithm) on each install path.
|
||||
fn sync_bucket_overrides(bucket: &str, bm: &BucketMetadata) {
|
||||
sync_bucket_durability(bucket, bm);
|
||||
sync_bucket_etag(bucket, bm);
|
||||
}
|
||||
|
||||
/// Drop every per-bucket override when the bucket's metadata leaves the cache.
|
||||
fn clear_bucket_overrides(bucket: &str) {
|
||||
clear_bucket_durability(bucket);
|
||||
crate::bucket::etag::set_bucket_xxh3_etag(bucket, false);
|
||||
}
|
||||
|
||||
/// Publish application-owned bytes on every cache install path.
|
||||
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
|
||||
if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
|
||||
@@ -1606,16 +1587,6 @@ pub async fn get_durability_config(
|
||||
Ok((bm.durability_config(), bm.durability_config_updated_at))
|
||||
}
|
||||
|
||||
/// The bucket's ETag algorithm config with its update time. `Ok((None, ..))`
|
||||
/// means the bucket keeps MD5 ETags.
|
||||
pub async fn get_etag_config(bucket: &str) -> Result<(Option<crate::bucket::etag::BucketEtagConfig>, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
let (bm, _) = bucket_meta_sys.get_config(bucket).await?;
|
||||
Ok((bm.etag_config(), bm.etag_config_updated_at))
|
||||
}
|
||||
|
||||
/// The bucket's on-demand migration config with its update time, or
|
||||
/// `Ok(None)` when the bucket has none. Bytes are opaque to the metadata owner.
|
||||
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
|
||||
@@ -2480,7 +2451,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.insert(bucket.to_string(), ()).await;
|
||||
if removed {
|
||||
BucketTargetSys::get().delete(bucket).await;
|
||||
clear_bucket_overrides(bucket);
|
||||
clear_bucket_durability(bucket);
|
||||
clear_on_demand_migration(bucket);
|
||||
}
|
||||
}
|
||||
@@ -2518,7 +2489,7 @@ impl BucketMetadataSys {
|
||||
self.fabricated_metadata.write().await.remove(bucket);
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_overrides(bucket, &bm);
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
sync_on_demand_migration(bucket, &bm);
|
||||
}
|
||||
MetadataLoadMode::Initial => {
|
||||
@@ -2566,7 +2537,7 @@ impl BucketMetadataSys {
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
if removed {
|
||||
BucketTargetSys::get().delete(bucket).await;
|
||||
clear_bucket_overrides(bucket);
|
||||
clear_bucket_durability(bucket);
|
||||
clear_on_demand_migration(bucket);
|
||||
}
|
||||
return Ok(());
|
||||
@@ -2589,7 +2560,7 @@ impl BucketMetadataSys {
|
||||
self.fabricated_metadata.write().await.remove(bucket);
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &metadata).await;
|
||||
sync_bucket_overrides(bucket, &metadata);
|
||||
sync_bucket_durability(bucket, &metadata);
|
||||
sync_on_demand_migration(bucket, &metadata);
|
||||
Ok(())
|
||||
}
|
||||
@@ -2619,7 +2590,7 @@ impl BucketMetadataSys {
|
||||
self.fabricated_metadata.write().await.remove(&bucket);
|
||||
self.missing_buckets.invalidate(&bucket).await;
|
||||
sync_bucket_target_sys(&bucket, &bm).await;
|
||||
sync_bucket_overrides(&bucket, &bm);
|
||||
sync_bucket_durability(&bucket, &bm);
|
||||
sync_on_demand_migration(&bucket, &bm);
|
||||
}
|
||||
}
|
||||
@@ -2643,7 +2614,7 @@ impl BucketMetadataSys {
|
||||
super::config_parse_mode::forget_bucket_config_parse_state(bucket);
|
||||
if removed {
|
||||
BucketTargetSys::get().delete(bucket).await;
|
||||
clear_bucket_overrides(bucket);
|
||||
clear_bucket_durability(bucket);
|
||||
clear_on_demand_migration(bucket);
|
||||
}
|
||||
removed || removed_fabricated
|
||||
@@ -2986,7 +2957,7 @@ impl BucketMetadataSys {
|
||||
self.fabricated_metadata.write().await.remove(bucket);
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &bm).await;
|
||||
sync_bucket_overrides(bucket, &bm);
|
||||
sync_bucket_durability(bucket, &bm);
|
||||
sync_on_demand_migration(bucket, &bm);
|
||||
return Ok((bm, true));
|
||||
} else {
|
||||
@@ -3437,7 +3408,7 @@ impl BucketMetadataSys {
|
||||
self.fabricated_metadata.write().await.remove(bucket);
|
||||
self.missing_buckets.invalidate(bucket).await;
|
||||
sync_bucket_target_sys(bucket, &metadata).await;
|
||||
sync_bucket_overrides(bucket, &metadata);
|
||||
sync_bucket_durability(bucket, &metadata);
|
||||
sync_on_demand_migration(bucket, &metadata);
|
||||
Ok(BucketMetadataAuthority::Authoritative(metadata))
|
||||
}
|
||||
@@ -6715,41 +6686,6 @@ mod tests {
|
||||
target_sys.delete(bucket).await;
|
||||
}
|
||||
|
||||
/// Installing bucket metadata publishes the bucket's ETag algorithm to the
|
||||
/// write-path registry; metadata without it, an invalid payload or
|
||||
/// clearing the bucket's overrides withdraws XXH3.
|
||||
#[test]
|
||||
fn metadata_sync_publishes_and_clears_etag_algorithm() {
|
||||
use crate::bucket::etag::bucket_uses_xxh3_etag;
|
||||
|
||||
let bucket = "metadata-sync-etag";
|
||||
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.etag_config_json = br#"{"algorithm":"xxh3"}"#.to_vec();
|
||||
sync_bucket_overrides(bucket, &bm);
|
||||
assert!(bucket_uses_xxh3_etag(bucket));
|
||||
|
||||
sync_bucket_overrides(bucket, &BucketMetadata::new(bucket));
|
||||
assert!(!bucket_uses_xxh3_etag(bucket));
|
||||
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.etag_config_json = br#"{"algorithm":"md5"}"#.to_vec();
|
||||
sync_bucket_overrides(bucket, &bm);
|
||||
assert!(!bucket_uses_xxh3_etag(bucket));
|
||||
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.etag_config_json = b"not-json".to_vec();
|
||||
sync_bucket_overrides(bucket, &bm);
|
||||
assert!(!bucket_uses_xxh3_etag(bucket));
|
||||
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.etag_config_json = br#"{"algorithm":"XXH3"}"#.to_vec();
|
||||
sync_bucket_overrides(bucket, &bm);
|
||||
assert!(bucket_uses_xxh3_etag(bucket));
|
||||
clear_bucket_overrides(bucket);
|
||||
assert!(!bucket_uses_xxh3_etag(bucket));
|
||||
}
|
||||
|
||||
/// HP-5b (rustfs/backlog#938): installing bucket metadata publishes the
|
||||
/// durability override to the disk-layer registry, and clearing the
|
||||
/// config (or an invalid payload) withdraws it.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use crate::bucket::metadata::BUCKET_METADATA_FILE;
|
||||
use crate::bucket::replication::ReplicationMigrationBridge;
|
||||
use crate::disk::{BUCKET_META_PREFIX, MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
|
||||
use crate::error::{Error, Result, is_err_strict_not_found, is_err_strict_volume_not_found, to_object_err};
|
||||
use crate::error::{Error, Result, is_err_strict_not_found, is_err_strict_volume_not_found};
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
use crate::storage_api_contracts::{
|
||||
bucket::{BucketInfo, BucketOperations, BucketOptions},
|
||||
@@ -334,14 +334,6 @@ async fn migration_target_exists<S: EcstoreObjectOperations>(store: &S, path: &s
|
||||
}
|
||||
}
|
||||
|
||||
/// Names the target record in a failed migration write, as the existence
|
||||
/// probe's error already does. The commit path returns bare disk errors, and on
|
||||
/// Windows a blocked record is first seen here: reading through a regular-file
|
||||
/// ancestor reports the record missing instead of refusing access to it.
|
||||
fn migration_write_error(err: Error, path: &str) -> Error {
|
||||
to_object_err(err, vec![RUSTFS_META_BUCKET, path])
|
||||
}
|
||||
|
||||
async fn migrate_one_if_missing<S>(
|
||||
store: Arc<S>,
|
||||
opts: &ObjectOptions,
|
||||
@@ -377,10 +369,7 @@ where
|
||||
.unwrap_or(data);
|
||||
|
||||
let mut put_data = PutObjReader::from_vec(data);
|
||||
store
|
||||
.put_object(RUSTFS_META_BUCKET, path, &mut put_data, opts)
|
||||
.await
|
||||
.map_err(|err| migration_write_error(err, path))?;
|
||||
store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, opts).await?;
|
||||
info!("Migrated {label}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -492,10 +481,7 @@ where
|
||||
Err(_) => return Err(MigrationMetadataError::Incompatible(path.to_owned()).into()),
|
||||
};
|
||||
let mut put_data = PutObjReader::from_vec(data);
|
||||
store
|
||||
.put_object(RUSTFS_META_BUCKET, path, &mut put_data, &opts)
|
||||
.await
|
||||
.map_err(|err| migration_write_error(err, path))?;
|
||||
store.put_object(RUSTFS_META_BUCKET, path, &mut put_data, &opts).await?;
|
||||
info!("Migrated IAM config: {path}");
|
||||
total_migrated += 1;
|
||||
}
|
||||
@@ -545,27 +531,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_write_errors_name_the_target_record_without_fragmenting_groups() {
|
||||
use super::{Error, migration_write_error};
|
||||
|
||||
let first = migration_write_error(Error::FileAccessDenied, "buckets/first/.metadata.bin");
|
||||
let second = migration_write_error(Error::FileAccessDenied, "config/iam/format.json");
|
||||
assert!(matches!(first, Error::PrefixAccessDenied(..)), "unexpected error: {first:?}");
|
||||
assert!(
|
||||
first.to_string().contains(".rustfs.sys/buckets/first/.metadata.bin"),
|
||||
"the startup error must name the blocked record: {first}"
|
||||
);
|
||||
assert_eq!(first, second, "record paths must not fragment error grouping");
|
||||
|
||||
let unrelated = migration_write_error(Error::other("disk offline"), "buckets/first/.metadata.bin");
|
||||
assert_eq!(
|
||||
unrelated,
|
||||
Error::other("disk offline"),
|
||||
"errors without a record form pass through unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_errors_group_by_cause_and_retain_typed_record_context() {
|
||||
use super::{Error, MigrationMetadataError};
|
||||
@@ -579,10 +544,8 @@ mod tests {
|
||||
] {
|
||||
let first: Error = make_error("buckets/first/.metadata.bin".into()).into();
|
||||
let second: Error = make_error("buckets/second/.metadata.bin".into()).into();
|
||||
let cloned = first.clone();
|
||||
assert_eq!(first, second, "record paths must not fragment error grouping");
|
||||
assert_eq!(cloned, second, "cloning must preserve error grouping");
|
||||
assert_eq!(std::io::Error::from(cloned).to_string(), message);
|
||||
assert_eq!(first.clone(), second, "cloning must preserve error grouping");
|
||||
|
||||
let io_error = std::io::Error::from(first);
|
||||
let detail = io_error
|
||||
|
||||
@@ -19,9 +19,7 @@ pub mod bucket_target_sys;
|
||||
pub mod config_parse_mode;
|
||||
pub mod durability;
|
||||
pub mod error;
|
||||
pub mod etag;
|
||||
pub mod lifecycle;
|
||||
pub mod lockless_cache;
|
||||
pub mod metadata;
|
||||
pub mod metadata_sys;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -826,17 +826,12 @@ pub(crate) async fn begin(
|
||||
) -> Result<QuotaContext> {
|
||||
let snapshot_admission = opts.quota_admission;
|
||||
let data_movement = opts.data_movement;
|
||||
// A lockless cache PUT is only taken when the bucket has no quota, so it
|
||||
// reserves nothing and needs no metadata-transaction lock.
|
||||
let lockless_cache = opts.is_lockless_cache_write() && snapshot_admission.is_none();
|
||||
if lockless_cache || crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
if !lockless_cache {
|
||||
let meta_bucket_fast_path_started = rustfs_io_metrics::put_stage_timer();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_META_BUCKET_FAST_PATH,
|
||||
meta_bucket_fast_path_started,
|
||||
);
|
||||
}
|
||||
if crate::bucket::utils::is_meta_bucketname(bucket) {
|
||||
let meta_bucket_fast_path_started = rustfs_io_metrics::put_stage_timer();
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_META_BUCKET_FAST_PATH,
|
||||
meta_bucket_fast_path_started,
|
||||
);
|
||||
return Ok(QuotaContext {
|
||||
store: None,
|
||||
bucket: bucket.to_string(),
|
||||
|
||||
@@ -66,9 +66,9 @@ use super::replication_target_boundary::{
|
||||
VersionIdentityCapability, is_replication_target_offline_error, replication_action_for_target_head,
|
||||
replication_complete_multipart_options, replication_delete_marker_purge_remove_options, replication_delete_remove_options,
|
||||
replication_force_delete_remove_options, replication_object_is_ssec_encrypted, replication_put_object_header_size,
|
||||
replication_put_object_options_for_target, replication_source_etag_is_comparable,
|
||||
replication_target_head_is_newer_null_version, resolve_read_api_version_id, ssec_passthrough_evidence_present,
|
||||
ssec_passthrough_gate, version_identity_capability_from_put, version_identity_drifted,
|
||||
replication_put_object_options, replication_source_etag_is_comparable, replication_target_head_is_newer_null_version,
|
||||
resolve_read_api_version_id, ssec_passthrough_evidence_present, ssec_passthrough_gate, version_identity_capability_from_put,
|
||||
version_identity_drifted,
|
||||
};
|
||||
use super::replication_versioning_boundary::ReplicationVersioningStore;
|
||||
use super::runtime_boundary as runtime_sources;
|
||||
@@ -4813,7 +4813,7 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
rinfo.size = size;
|
||||
rinfo.replication_action = replication_action;
|
||||
|
||||
let (put_opts, is_multipart) = match replication_put_object_options_for_target(&tgt_client, &object_info) {
|
||||
let (put_opts, is_multipart) = match replication_put_object_options(&tgt_client.storage_class, &object_info) {
|
||||
Ok((put_opts, is_mp)) => (put_opts, is_mp),
|
||||
Err(e) => {
|
||||
// Unsupported source metadata (e.g. managed SSE) is a fail-closed
|
||||
@@ -5046,7 +5046,7 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
|
||||
// The target client has no metadata-only operation. Reuse the existing
|
||||
// object transport so metadata changes carry tags and object-lock state
|
||||
// atomically with the source version.
|
||||
let (put_opts, is_multipart) = match replication_put_object_options_for_target(&tgt_client, &object_info) {
|
||||
let (put_opts, is_multipart) = match replication_put_object_options(&tgt_client.storage_class, &object_info) {
|
||||
Ok((put_opts, is_mp)) => (put_opts, is_mp),
|
||||
Err(e) => {
|
||||
fail_replicate_all_put_options(&mut rinfo, &tgt_client, &bucket, object_info, &e, start_time);
|
||||
@@ -6577,7 +6577,7 @@ mod tests {
|
||||
assert_eq!(persisted.targets_map["arn:same"].resync_status, ResyncStatusType::ResyncCompleted);
|
||||
}
|
||||
|
||||
use super::super::replication_target_boundary::{BucketTarget, BucketTargets, replication_put_object_options};
|
||||
use super::super::replication_target_boundary::{BucketTarget, BucketTargets};
|
||||
use super::*;
|
||||
use rustfs_gateway_types::dto::Status;
|
||||
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
|
||||
@@ -6588,39 +6588,6 @@ mod tests {
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Only a site-replication peer is told the source object's KMS key
|
||||
/// name; an operator-configured target gets the `aws:kms` intent alone.
|
||||
#[test]
|
||||
fn replica_write_names_the_kms_key_for_site_replication_peers_only() {
|
||||
use rustfs_utils::http::{AMZ_SERVER_SIDE_ENCRYPTION, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID};
|
||||
|
||||
let object_info = ObjectInfo {
|
||||
user_defined: HashMap::from([
|
||||
(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string()),
|
||||
(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID.to_string(), "finance-key".to_string()),
|
||||
])
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut operator_target = test_target_client("http://127.0.0.1:1".to_string());
|
||||
let (options, _) =
|
||||
replication_put_object_options_for_target(&operator_target, &object_info).expect("put options must build");
|
||||
assert_eq!(options.user_metadata.get(AMZ_SERVER_SIDE_ENCRYPTION), Some(&"aws:kms".to_string()));
|
||||
assert!(!options.user_metadata.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID));
|
||||
|
||||
Arc::get_mut(&mut operator_target)
|
||||
.expect("test target is uniquely owned")
|
||||
.site_peer_deployment_id = "peer-deployment".to_string();
|
||||
let site_peer = operator_target;
|
||||
let (options, _) = replication_put_object_options_for_target(&site_peer, &object_info).expect("put options must build");
|
||||
assert_eq!(options.user_metadata.get(AMZ_SERVER_SIDE_ENCRYPTION), Some(&"aws:kms".to_string()));
|
||||
assert_eq!(
|
||||
options.user_metadata.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID),
|
||||
Some(&"finance-key".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
fn test_target_client(endpoint: String) -> Arc<TargetClient> {
|
||||
let config = aws_sdk_s3::Config::builder()
|
||||
.endpoint_url(endpoint.clone())
|
||||
@@ -6639,7 +6606,6 @@ mod tests {
|
||||
disable_proxy: false,
|
||||
arn: format!("arn:rustfs:replication:us-east-1:target:{}", Uuid::new_v4()),
|
||||
reset_id: String::new(),
|
||||
site_peer_deployment_id: String::new(),
|
||||
secure: false,
|
||||
health_check_duration: std::time::Duration::from_secs(5),
|
||||
replicate_sync: false,
|
||||
|
||||
@@ -225,50 +225,6 @@ impl ReplicationTargetStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the source object's KMS key on a replica write to a site-replication
|
||||
/// peer, so the peer seals the replica under its key of the same name.
|
||||
///
|
||||
/// Sites share bucket default-encryption key names and key-scoped IAM
|
||||
/// policies verbatim and are required to hold every key under the same name.
|
||||
/// With only the `aws:kms` intent the peer falls back to its bucket or site
|
||||
/// default, and an object written under an explicitly named key ends up
|
||||
/// sealed under a different key on each site: a caller scoped to the named
|
||||
/// key can then read the object on one site and not on the other. Only the
|
||||
/// name travels; the envelope and the encryption context stay on the source.
|
||||
///
|
||||
/// Must not be applied to an operator-configured target, whose KMS has no
|
||||
/// reason to hold a key of that name.
|
||||
fn name_source_kms_key_for_site_peer(options: &mut PutObjectOptions, object_info: &ObjectInfo) {
|
||||
if !matches!(
|
||||
classify_replication_source_encryption(&object_info.user_defined),
|
||||
ReplicationSourceEncryption::SseKms
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if let Some(key_id) = metadata_value(&object_info.user_defined, AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID)
|
||||
.map(str::trim)
|
||||
.filter(|key_id| !key_id.is_empty())
|
||||
{
|
||||
options
|
||||
.user_metadata
|
||||
.insert(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID.to_string(), key_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Put options for replicating `object_info` to `target`: the transport
|
||||
/// options every target gets, plus the source KMS key name when the target is
|
||||
/// a site-replication peer.
|
||||
pub(crate) fn replication_put_object_options_for_target(
|
||||
target: &TargetClient,
|
||||
object_info: &ObjectInfo,
|
||||
) -> Result<(PutObjectOptions, bool)> {
|
||||
let (mut options, is_multipart) = replication_put_object_options(&target.storage_class, object_info)?;
|
||||
if target.is_site_replication_peer() {
|
||||
name_source_kms_key_for_site_peer(&mut options, object_info);
|
||||
}
|
||||
Ok((options, is_multipart))
|
||||
}
|
||||
|
||||
pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo) -> Result<(PutObjectOptions, bool)> {
|
||||
use base64_simd::STANDARD as BASE64_STANDARD;
|
||||
use rustfs_utils::http::{AMZ_CHECKSUM_TYPE, AMZ_CHECKSUM_TYPE_FULL_OBJECT};
|
||||
@@ -325,9 +281,8 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
|
||||
|
||||
// Managed SSE replicates as plaintext (the replication reader decrypts via
|
||||
// the object-encryption resolver) and re-encrypts on the target with the
|
||||
// target's own KMS. Send only the encryption intent: a key id means
|
||||
// nothing to an arbitrary target's KMS. Site-replication peers are the
|
||||
// exception and get the key name from `name_source_kms_key_for_site_peer`.
|
||||
// target's own KMS. Send only the encryption intent — never the source
|
||||
// key id, whose meaning is local to the source site's KMS.
|
||||
if matches!(source_encryption, ReplicationSourceEncryption::SseS3) {
|
||||
meta.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string());
|
||||
} else if matches!(source_encryption, ReplicationSourceEncryption::SseKms) {
|
||||
@@ -1635,80 +1590,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_peer_replica_write_names_the_source_kms_key_only() {
|
||||
use rustfs_utils::http::object_encryption_keys::{
|
||||
INTERNAL_ENCRYPTION_KEY_HEADER, MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER,
|
||||
};
|
||||
|
||||
let object_info = ObjectInfo {
|
||||
user_defined: Arc::new(HashMap::from([
|
||||
(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string()),
|
||||
(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID.to_string(), "finance-key".to_string()),
|
||||
(INTERNAL_ENCRYPTION_KEY_HEADER.to_string(), "sealed-envelope".to_string()),
|
||||
(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER.to_string(), "ctx".to_string()),
|
||||
])),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (mut options, _) = replication_put_object_options("", &object_info).expect("managed SSE-KMS must build put options");
|
||||
name_source_kms_key_for_site_peer(&mut options, &object_info);
|
||||
|
||||
assert_eq!(options.user_metadata.get(AMZ_SERVER_SIDE_ENCRYPTION), Some(&"aws:kms".to_string()));
|
||||
assert_eq!(
|
||||
options.user_metadata.get(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID),
|
||||
Some(&"finance-key".to_string())
|
||||
);
|
||||
// The name is all that travels: no envelope, no encryption context.
|
||||
assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_KEY_HEADER));
|
||||
assert!(
|
||||
!options
|
||||
.user_metadata
|
||||
.contains_key(MINIO_INTERNAL_ENCRYPTION_KMS_CONTEXT_HEADER)
|
||||
);
|
||||
assert!(!options.user_metadata.values().any(|value| value.contains("sealed-envelope")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_peer_replica_write_names_no_key_for_other_encryption() {
|
||||
use rustfs_utils::http::object_encryption_keys::INTERNAL_ENCRYPTION_KEY_ID_HEADER;
|
||||
|
||||
for (label, user_defined) in [
|
||||
("plaintext", HashMap::from([("x-user-meta".to_string(), "value".to_string())])),
|
||||
(
|
||||
// SSE-S3 wraps its data key under the site default key; that
|
||||
// key is internal and is not a name the peer was asked to use.
|
||||
"sse-s3",
|
||||
HashMap::from([
|
||||
(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string()),
|
||||
(INTERNAL_ENCRYPTION_KEY_ID_HEADER.to_string(), "site-default".to_string()),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"sse-kms without a recorded key id",
|
||||
HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string())]),
|
||||
),
|
||||
(
|
||||
"sse-kms with a blank key id",
|
||||
HashMap::from([
|
||||
(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string()),
|
||||
(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID.to_string(), " ".to_string()),
|
||||
]),
|
||||
),
|
||||
] {
|
||||
let object_info = ObjectInfo {
|
||||
user_defined: Arc::new(user_defined),
|
||||
..Default::default()
|
||||
};
|
||||
let (mut options, _) = replication_put_object_options("", &object_info).expect("put options must build");
|
||||
name_source_kms_key_for_site_peer(&mut options, &object_info);
|
||||
assert!(
|
||||
!options.user_metadata.contains_key(AMZ_SERVER_SIDE_ENCRYPTION_KMS_ID),
|
||||
"{label}: no key id may be sent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_put_options_rejects_unknown_encryption_without_echoing_metadata() {
|
||||
let secret_like_value = "opaque-context-that-must-not-be-logged";
|
||||
|
||||
@@ -41,12 +41,8 @@ use std::{
|
||||
future::Future,
|
||||
io::ErrorKind,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc, LazyLock, Mutex,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
},
|
||||
sync::{LazyLock, Mutex},
|
||||
task::{Context, Poll},
|
||||
time::Duration,
|
||||
};
|
||||
use tonic::{service::interceptor::InterceptedService, transport::Channel};
|
||||
use tower::Service;
|
||||
@@ -74,7 +70,7 @@ pub(crate) async fn scanner_control_time_out_client(
|
||||
let channel = get_channel_for_class(addr, ChannelClass::Control)
|
||||
.await
|
||||
.map_err(|err| crate::error::Error::other(err.to_string()))?;
|
||||
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
let limit = rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES as usize;
|
||||
Ok(ScannerControlServiceClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(limit)
|
||||
@@ -88,7 +84,7 @@ pub async fn heal_control_time_out_client(
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = get_channel_for_class(addr, ChannelClass::Control).await?;
|
||||
let max_message_size = rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE;
|
||||
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
Ok(HealControlServiceClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_message_size)
|
||||
.max_encoding_message_size(max_message_size))
|
||||
@@ -101,7 +97,7 @@ pub async fn tier_mutation_control_time_out_client(
|
||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||
let channel = get_channel_for_class(addr, ChannelClass::Control).await?;
|
||||
let max_message_size = rustfs_protos::TIER_MUTATION_RPC_MAX_MESSAGE_SIZE;
|
||||
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
Ok(TierMutationControlServiceClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_message_size)
|
||||
.max_encoding_message_size(max_message_size))
|
||||
@@ -122,7 +118,7 @@ pub async fn node_service_time_out_client_for_class(
|
||||
let channel = get_channel_for_class(addr, class).await?;
|
||||
|
||||
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
|
||||
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
Ok(NodeServiceClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_message_size)
|
||||
.max_encoding_message_size(max_message_size))
|
||||
@@ -137,7 +133,7 @@ pub(crate) async fn node_service_delete_recovery_client(
|
||||
let channel = rustfs_protos::create_channel_with_timeout(addr, timeout)
|
||||
.await
|
||||
.map_err(|error| DiskError::RemoteClientUnavailable(error.to_string()))?;
|
||||
let channel = ReplayScopeChannel::new(BatchLockDeadlineChannel::new(channel), interceptor.replay_scope_audience());
|
||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
||||
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
|
||||
Ok(NodeServiceClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_message_size)
|
||||
@@ -253,95 +249,7 @@ pub struct ReplayScopeChannel<S> {
|
||||
}
|
||||
|
||||
/// The channel type used by internode clients after v2 authentication and replay-scope handling.
|
||||
pub type AuthenticatedChannel = ReplayScopeChannel<BatchLockDeadlineChannel<Channel>>;
|
||||
|
||||
/// Gate lock-batch payloads after the transport's internal readiness queues.
|
||||
/// Other RPC bodies pass through without an additional wrapper.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BatchLockDeadlineChannel<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> BatchLockDeadlineChannel<S> {
|
||||
fn new(inner: S) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Service<HttpRequest<tonic::body::Body>> for BatchLockDeadlineChannel<S>
|
||||
where
|
||||
S: Service<HttpRequest<tonic::body::Body>>,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = S::Future;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, mut request: HttpRequest<tonic::body::Body>) -> Self::Future {
|
||||
if let Some(deadline) = request.extensions().get::<BatchLockRpcDeadline>().cloned() {
|
||||
let body = std::mem::replace(request.body_mut(), tonic::body::Body::empty());
|
||||
*request.body_mut() = tonic::body::Body::new(BatchLockDeadlineBody {
|
||||
inner: body,
|
||||
deadline,
|
||||
dispatched: false,
|
||||
failed: false,
|
||||
});
|
||||
}
|
||||
self.inner.call(request)
|
||||
}
|
||||
}
|
||||
|
||||
struct BatchLockDeadlineBody {
|
||||
inner: tonic::body::Body,
|
||||
deadline: BatchLockRpcDeadline,
|
||||
dispatched: bool,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl http_body::Body for BatchLockDeadlineBody {
|
||||
type Data = bytes::Bytes;
|
||||
type Error = tonic::Status;
|
||||
|
||||
fn poll_frame(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
|
||||
if self.failed {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
if !self.dispatched
|
||||
&& let Err(error) = self.deadline.remaining_before_dispatch()
|
||||
{
|
||||
self.failed = true;
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
let frame = std::task::ready!(Pin::new(&mut self.inner).poll_frame(cx));
|
||||
if let Some(Ok(frame)) = &frame
|
||||
&& frame.is_data()
|
||||
&& !self.dispatched
|
||||
{
|
||||
// Codec work can consume the deadline in this poll. Arbitrate only
|
||||
// after encoding, immediately before any payload leaves the body.
|
||||
if let Err(error) = self.deadline.mark_dispatched() {
|
||||
self.failed = true;
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
self.dispatched = true;
|
||||
}
|
||||
Poll::Ready(frame)
|
||||
}
|
||||
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.failed || self.inner.is_end_stream()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> http_body::SizeHint {
|
||||
self.inner.size_hint()
|
||||
}
|
||||
}
|
||||
pub type AuthenticatedChannel = ReplayScopeChannel<Channel>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum PeerReplayCapability {
|
||||
@@ -518,9 +426,7 @@ where
|
||||
)
|
||||
}) {
|
||||
Ok(headers) => request.headers_mut().extend(headers),
|
||||
Err(error) => {
|
||||
debug!(error = %error, "could not attach replay-scoped RPC signature")
|
||||
}
|
||||
Err(error) => debug!(error = %error, "could not attach replay-scoped RPC signature"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -582,82 +488,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// A batch may leave local readiness queues only while its original budget is live.
|
||||
/// Cancellation and submission arbitrate once so a cancelled caller cannot send later.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct BatchLockRpcDeadline {
|
||||
deadline: tokio::time::Instant,
|
||||
max_acquire_wait: Duration,
|
||||
dispatch: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
impl BatchLockRpcDeadline {
|
||||
const PENDING: u8 = 0;
|
||||
const DISPATCHED: u8 = 1;
|
||||
const CANCELLED: u8 = 2;
|
||||
|
||||
pub(crate) fn new(deadline: tokio::time::Instant, max_acquire_wait: Duration) -> Self {
|
||||
Self {
|
||||
deadline,
|
||||
max_acquire_wait,
|
||||
dispatch: Arc::new(AtomicU8::new(Self::PENDING)),
|
||||
}
|
||||
}
|
||||
|
||||
/// True proves that this batch cannot send payload or enter the server lock handler.
|
||||
/// Transport headers may already have left an internal channel queue.
|
||||
pub(crate) fn cancel_before_dispatch(&self) -> bool {
|
||||
match self
|
||||
.dispatch
|
||||
.compare_exchange(Self::PENDING, Self::CANCELLED, Ordering::AcqRel, Ordering::Acquire)
|
||||
{
|
||||
Ok(_) => true,
|
||||
Err(state) => state == Self::CANCELLED,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn was_dispatched(&self) -> bool {
|
||||
self.dispatch.load(Ordering::Acquire) == Self::DISPATCHED
|
||||
}
|
||||
|
||||
fn remaining_before_dispatch(&self) -> Result<Duration, tonic::Status> {
|
||||
let remaining = self.deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining <= self.max_acquire_wait || self.dispatch.load(Ordering::Acquire) == Self::CANCELLED {
|
||||
return Err(tonic::Status::deadline_exceeded("Lock batch budget exhausted before payload dispatch"));
|
||||
}
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
fn update_timeout(&self, request: &mut tonic::Request<()>) -> Result<(), tonic::Status> {
|
||||
request.set_timeout(self.remaining_before_dispatch()?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_dispatched(&self) -> Result<(), tonic::Status> {
|
||||
self.remaining_before_dispatch()?;
|
||||
self.dispatch
|
||||
.compare_exchange(Self::PENDING, Self::DISPATCHED, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map(|_| ())
|
||||
.map_err(|_| tonic::Status::deadline_exceeded("Lock batch cancelled before dispatch"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn dispatch_payload_for_test(&self) -> Result<(), tonic::Status> {
|
||||
use http_body_util::BodyExt as _;
|
||||
let mut body = BatchLockDeadlineBody {
|
||||
inner: tonic::body::Body::new(http_body_util::Full::new(bytes::Bytes::from_static(b"payload"))),
|
||||
deadline: self.clone(),
|
||||
dispatched: false,
|
||||
failed: false,
|
||||
};
|
||||
match body.frame().await {
|
||||
Some(Ok(frame)) if frame.is_data() => Ok(()),
|
||||
Some(Err(error)) => Err(error),
|
||||
_ => Err(tonic::Status::internal("test lock payload did not produce a data frame")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TonicSignatureInterceptor {
|
||||
audience: Option<String>,
|
||||
body_digest_strict: bool,
|
||||
@@ -752,21 +582,11 @@ impl TonicInterceptor {
|
||||
}
|
||||
|
||||
impl tonic::service::Interceptor for TonicInterceptor {
|
||||
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
|
||||
let batch_deadline = req.extensions().get::<BatchLockRpcDeadline>().cloned();
|
||||
if let Some(deadline) = &batch_deadline {
|
||||
deadline.update_timeout(&mut req)?;
|
||||
}
|
||||
let mut req = match self {
|
||||
fn call(&mut self, req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
|
||||
match self {
|
||||
TonicInterceptor::Signature(interceptor) => interceptor.call(req),
|
||||
TonicInterceptor::NoOp(interceptor) => interceptor.call(req),
|
||||
}?;
|
||||
if let Some(deadline) = batch_deadline {
|
||||
// Signing consumes budget, but Channel readiness only reserves a
|
||||
// queue slot. The payload body arbitrates actual dispatch later.
|
||||
deadline.update_timeout(&mut req)?;
|
||||
}
|
||||
Ok(req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,136 +782,6 @@ mod tests {
|
||||
test_request_for("Ping")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_dispatch_and_cancellation_have_one_winner() {
|
||||
for _ in 0..100 {
|
||||
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_secs(1), Duration::ZERO);
|
||||
let other = deadline.clone();
|
||||
let barrier = Arc::new(std::sync::Barrier::new(2));
|
||||
let other_barrier = barrier.clone();
|
||||
let submit = std::thread::spawn(move || {
|
||||
other_barrier.wait();
|
||||
other.mark_dispatched().is_ok()
|
||||
});
|
||||
barrier.wait();
|
||||
let cancelled = deadline.cancel_before_dispatch();
|
||||
let submitted = submit.join().expect("submission thread");
|
||||
assert_ne!(cancelled, submitted);
|
||||
assert_eq!(deadline.was_dispatched(), submitted);
|
||||
assert!(deadline.mark_dispatched().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_interceptor_rejects_exhausted_wait_reserve_before_submission() {
|
||||
let deadline =
|
||||
BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_millis(20), Duration::from_millis(30));
|
||||
let mut request = tonic::Request::new(());
|
||||
request.extensions_mut().insert(deadline.clone());
|
||||
let status = TonicInterceptor::NoOp(NoOpInterceptor)
|
||||
.call(request)
|
||||
.expect_err("insufficient wait reserve");
|
||||
assert_eq!(status.code(), tonic::Code::DeadlineExceeded);
|
||||
assert!(!deadline.was_dispatched());
|
||||
assert!(deadline.cancel_before_dispatch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_interceptor_rejects_cancelled_ready_request_and_accepts_live_budget() {
|
||||
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_secs(2), Duration::from_millis(1));
|
||||
let mut request = tonic::Request::new(());
|
||||
request.extensions_mut().insert(deadline.clone());
|
||||
assert!(deadline.cancel_before_dispatch());
|
||||
assert!(TonicInterceptor::NoOp(NoOpInterceptor).call(request).is_err());
|
||||
assert!(!deadline.was_dispatched());
|
||||
|
||||
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_secs(2), Duration::from_millis(1));
|
||||
let mut request = tonic::Request::new(());
|
||||
request.extensions_mut().insert(deadline.clone());
|
||||
let request = TonicInterceptor::NoOp(NoOpInterceptor).call(request).expect("live budget");
|
||||
assert!(!deadline.was_dispatched(), "interceptor completion does not bypass the transport queue");
|
||||
assert!(request.metadata().contains_key("grpc-timeout"));
|
||||
assert!(deadline.cancel_before_dispatch());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn batch_body_rejects_payload_after_channel_queue_deadline() {
|
||||
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_millis(20), Duration::ZERO);
|
||||
tokio::time::advance(Duration::from_millis(21)).await;
|
||||
let error = deadline
|
||||
.dispatch_payload_for_test()
|
||||
.await
|
||||
.expect_err("queued payload expired");
|
||||
assert_eq!(error.code(), tonic::Code::DeadlineExceeded);
|
||||
assert!(!deadline.was_dispatched());
|
||||
assert!(deadline.cancel_before_dispatch());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn batch_body_rejects_payload_when_encoding_crosses_deadline_in_one_poll() {
|
||||
use http_body_util::BodyExt as _;
|
||||
struct SlowEncodingBody {
|
||||
deadline: tokio::time::Instant,
|
||||
}
|
||||
impl http_body::Body for SlowEncodingBody {
|
||||
type Data = bytes::Bytes;
|
||||
type Error = tonic::Status;
|
||||
|
||||
fn poll_frame(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
|
||||
// Model codec work that begins before the deadline but blocks
|
||||
// this single poll until the original budget has expired.
|
||||
tokio::time::resume();
|
||||
std::thread::sleep(
|
||||
self.deadline.saturating_duration_since(tokio::time::Instant::now()) + Duration::from_millis(1),
|
||||
);
|
||||
Poll::Ready(Some(Ok(http_body::Frame::data(bytes::Bytes::from_static(b"encoded payload")))))
|
||||
}
|
||||
}
|
||||
let expires = tokio::time::Instant::now() + Duration::from_millis(20);
|
||||
let deadline = BatchLockRpcDeadline::new(expires, Duration::ZERO);
|
||||
let mut body = BatchLockDeadlineBody {
|
||||
inner: tonic::body::Body::new(SlowEncodingBody { deadline: expires }),
|
||||
deadline: deadline.clone(),
|
||||
dispatched: false,
|
||||
failed: false,
|
||||
};
|
||||
let error = body
|
||||
.frame()
|
||||
.await
|
||||
.expect("first frame result")
|
||||
.expect_err("late encoded payload");
|
||||
assert_eq!(error.code(), tonic::Code::DeadlineExceeded);
|
||||
assert!(!deadline.was_dispatched());
|
||||
assert!(deadline.cancel_before_dispatch());
|
||||
assert!(body.frame().await.is_none(), "failed body must not expose a later frame");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn batch_body_keeps_cleanup_responsibility_after_first_payload() {
|
||||
use http_body_util::BodyExt as _;
|
||||
let deadline = BatchLockRpcDeadline::new(tokio::time::Instant::now() + Duration::from_millis(20), Duration::ZERO);
|
||||
let frames = futures::stream::iter([
|
||||
Ok::<_, tonic::Status>(http_body::Frame::data(bytes::Bytes::from_static(b"first"))),
|
||||
Ok(http_body::Frame::data(bytes::Bytes::from_static(b"last"))),
|
||||
]);
|
||||
let mut body = BatchLockDeadlineBody {
|
||||
inner: tonic::body::Body::new(http_body_util::StreamBody::new(frames)),
|
||||
deadline: deadline.clone(),
|
||||
dispatched: false,
|
||||
failed: false,
|
||||
};
|
||||
assert!(body.frame().await.expect("first frame").expect("live payload").is_data());
|
||||
assert!(deadline.was_dispatched());
|
||||
assert!(!deadline.cancel_before_dispatch());
|
||||
tokio::time::advance(Duration::from_millis(21)).await;
|
||||
assert!(body.frame().await.expect("last frame").expect("late drain").is_data());
|
||||
assert!(body.frame().await.is_none());
|
||||
assert!(!deadline.cancel_before_dispatch(), "a sent payload requires late cleanup ownership");
|
||||
}
|
||||
|
||||
fn test_request_for(method: &'static str) -> tonic::Request<()> {
|
||||
let mut request = tonic::Request::new(());
|
||||
request
|
||||
|
||||
@@ -1399,73 +1399,6 @@ pub fn build_auth_headers(url: &str, method: &Method, headers: &mut HeaderMap) -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Signs only external stream controls targeting members of this instance's topology.
|
||||
#[cfg(feature = "internode-transport")]
|
||||
#[derive(Clone)]
|
||||
pub struct ExternalStreamAuth {
|
||||
peers: std::sync::Arc<std::collections::HashSet<url::Origin>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "internode-transport")]
|
||||
impl ExternalStreamAuth {
|
||||
/// The authenticated HTTP route used by peer stream controls.
|
||||
pub const PATH: &'static str = "/rustfs/rpc/external_stream";
|
||||
|
||||
/// Capture the topology already configured for this host; a missing topology has no peers.
|
||||
pub fn new(instance: &crate::runtime::instance::InstanceContext) -> Self {
|
||||
let mut peers = std::collections::HashSet::new();
|
||||
if let Some(topology) = instance.endpoints() {
|
||||
for pool in topology.0 {
|
||||
for endpoint in pool.endpoints.as_ref() {
|
||||
if matches!(endpoint.url.scheme(), "http" | "https") {
|
||||
peers.insert(endpoint.url.origin());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
peers: std::sync::Arc::new(peers),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a fresh POST for one configured origin, request body digest and replay nonce.
|
||||
/// The caller must send it to the returned URL without redirects or resubmission.
|
||||
pub fn sign_control(&self, endpoint: &str, digest: &[u8; 32]) -> std::io::Result<(String, HeaderMap)> {
|
||||
let mut url = url::Url::parse(endpoint).map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
|
||||
if !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| !self.peers.contains(&url.origin())
|
||||
|| url.path() != "/"
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"invalid internode control endpoint",
|
||||
));
|
||||
}
|
||||
url.set_path(Self::PATH);
|
||||
url.query_pairs_mut()
|
||||
.append_pair("body_sha256", &hex_simd::encode_to_string(digest, hex_simd::AsciiCase::Lower))
|
||||
.append_pair("request_nonce", &Uuid::new_v4().to_string());
|
||||
let url = url.to_string();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(http::header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
|
||||
build_auth_headers(&url, &Method::POST, &mut headers)?;
|
||||
Ok((url, headers))
|
||||
}
|
||||
|
||||
/// Derive the operation key for this stream protocol and exact request digest.
|
||||
/// The shared RPC signing credential is never exposed to the stream provider.
|
||||
pub fn transfer_key(&self, request_digest: &[u8; 32]) -> std::io::Result<[u8; 32]> {
|
||||
let secret = get_shared_secret()?;
|
||||
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(std::io::Error::other)?;
|
||||
mac.update(b"rustfs-external-stream-key-v1\0");
|
||||
mac.update(request_digest);
|
||||
Ok(mac.finalize().into_bytes().into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gen_signature_headers(url: &str, method: &Method) -> std::io::Result<HeaderMap> {
|
||||
let secret = get_shared_secret()?;
|
||||
let timestamp = OffsetDateTime::now_utc().unix_timestamp();
|
||||
|
||||
@@ -220,11 +220,6 @@ impl InternodeDataTransportCapabilities {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(
|
||||
feature = "internode-transport",
|
||||
derive(serde::Serialize, serde::Deserialize),
|
||||
serde(deny_unknown_fields)
|
||||
)]
|
||||
pub struct ReadStreamRequest {
|
||||
pub endpoint: String,
|
||||
pub disk: String,
|
||||
@@ -236,11 +231,6 @@ pub struct ReadStreamRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(
|
||||
feature = "internode-transport",
|
||||
derive(serde::Serialize, serde::Deserialize),
|
||||
serde(deny_unknown_fields)
|
||||
)]
|
||||
pub struct WriteStreamRequest {
|
||||
pub endpoint: String,
|
||||
pub disk: String,
|
||||
@@ -1253,38 +1243,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "internode-transport")]
|
||||
#[test]
|
||||
fn peer_transport_is_selected_once_per_instance() {
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
let first = InstanceContext::new();
|
||||
let second = InstanceContext::new();
|
||||
let provider: Arc<dyn InternodeDataTransport> = Arc::new(LegacyTestTransport);
|
||||
first
|
||||
.select_internode_data_transport(provider.clone(), Some(" LEGACY-TEST "))
|
||||
.expect("select external transport");
|
||||
let selected = first.internode_data_transport().expect("first instance selection");
|
||||
assert!(Arc::ptr_eq(selected, &provider));
|
||||
assert!(second.internode_data_transport().is_none());
|
||||
assert_eq!(
|
||||
first
|
||||
.select_internode_data_transport(provider.clone(), Some("tcp"))
|
||||
.expect_err("already configured")
|
||||
.kind(),
|
||||
io::ErrorKind::AlreadyExists
|
||||
);
|
||||
second
|
||||
.select_internode_data_transport(provider.clone(), None)
|
||||
.expect("ordinary TCP");
|
||||
assert_eq!(
|
||||
second.internode_data_transport().expect("ordinary selection").name(),
|
||||
DEFAULT_INTERNODE_DATA_TRANSPORT
|
||||
);
|
||||
let invalid = InstanceContext::new();
|
||||
assert!(invalid.select_internode_data_transport(provider, Some("unknown")).is_err());
|
||||
assert!(invalid.internode_data_transport().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_transport_defaults_namespace_scanner_to_unsupported() {
|
||||
let transport = LegacyTestTransport;
|
||||
|
||||
@@ -54,10 +54,6 @@ use bytes::Bytes;
|
||||
use futures::lock::Mutex;
|
||||
use metrics::counter;
|
||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo, RawFileInfo};
|
||||
use rustfs_io_metrics::internode_metrics::{
|
||||
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
|
||||
INTERNODE_OPERATION_GRPC_WRITE_ALL, measure_client_rpc,
|
||||
};
|
||||
use rustfs_io_metrics::internode_metrics::{
|
||||
INTERNODE_STAGE_BATCH_READ_VERSION_REQUEST_ENCODE, INTERNODE_STAGE_BATCH_READ_VERSION_RESPONSE_DECODE,
|
||||
INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, INTERNODE_STAGE_READ_VERSION_REQUEST_ENCODE,
|
||||
@@ -173,72 +169,6 @@ fn decode_delete_recovery_batch_errors(codes: Vec<Option<u32>>, expected_len: us
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn log_delete_recovery_batch_result(
|
||||
endpoint: &Endpoint,
|
||||
expected_len: usize,
|
||||
started: Instant,
|
||||
result: &Result<Vec<Option<DiskError>>>,
|
||||
) {
|
||||
let elapsed_ms = started.elapsed().as_millis();
|
||||
let mut disk_error_counts = std::collections::BTreeMap::<u32, usize>::new();
|
||||
let (failure_scope, rpc_status) = match result {
|
||||
Ok(errors) => {
|
||||
for error in errors.iter().flatten() {
|
||||
*disk_error_counts.entry(error.to_u32()).or_default() += 1;
|
||||
}
|
||||
("object_group", None)
|
||||
}
|
||||
Err(error) => {
|
||||
disk_error_counts.insert(error.to_u32(), expected_len);
|
||||
let rpc_status = match error {
|
||||
DiskError::Io(source) => crate::cluster::rpc::client::embedded_tonic_status(source).map(tonic::Status::code),
|
||||
_ => None,
|
||||
};
|
||||
("rpc_or_reply", rpc_status)
|
||||
}
|
||||
};
|
||||
let failed_group_count = disk_error_counts.values().sum::<usize>();
|
||||
let result = if result.is_err() || failed_group_count > 0 {
|
||||
"failed"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
if result == "failed" || elapsed_ms >= 1_000 {
|
||||
warn!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
result,
|
||||
operation = "apply_versions",
|
||||
failure_scope,
|
||||
pool_index = endpoint.pool_idx,
|
||||
set_index = endpoint.set_idx,
|
||||
disk_index = endpoint.disk_idx,
|
||||
attempted_group_count = expected_len,
|
||||
failed_group_count,
|
||||
disk_error_counts = ?disk_error_counts,
|
||||
rpc_status = ?rpc_status,
|
||||
elapsed_ms,
|
||||
"Recoverable batch delete RPC completed"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
result,
|
||||
operation = "apply_versions",
|
||||
pool_index = endpoint.pool_idx,
|
||||
set_index = endpoint.set_idx,
|
||||
disk_index = endpoint.disk_idx,
|
||||
attempted_group_count = expected_len,
|
||||
failed_group_count,
|
||||
elapsed_ms,
|
||||
"Recoverable batch delete RPC completed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_delete_recovery_inspection(
|
||||
volume: &str,
|
||||
object: &str,
|
||||
@@ -1001,18 +931,8 @@ pub struct RemoteDisk {
|
||||
#[cfg(test)]
|
||||
recovery_monitor_teardown_hook: Arc<tokio::sync::Mutex<Option<Arc<RecoveryMonitorTeardownHook>>>>,
|
||||
data_transport: Arc<dyn InternodeDataTransport>,
|
||||
#[cfg(feature = "internode-transport")]
|
||||
external_streams: bool,
|
||||
/// Bound the per-operation failure warnings of this peer. An unreachable
|
||||
/// peer fails every in-flight shard operation; the counters keep the exact
|
||||
/// numbers per operation. Per handle, so one peer cannot hide another's.
|
||||
timeout_logs: rustfs_utils::LogThrottle,
|
||||
network_error_logs: rustfs_utils::LogThrottle,
|
||||
}
|
||||
|
||||
/// Shortest gap between two failure warnings of one kind from one remote disk.
|
||||
const REMOTE_DISK_RPC_FAILURE_LOG_INTERVAL_MS: u64 = 5_000;
|
||||
|
||||
struct RecoveryMonitorLease {
|
||||
active: Arc<AtomicBool>,
|
||||
}
|
||||
@@ -1444,10 +1364,6 @@ impl RemoteDisk {
|
||||
#[cfg(test)]
|
||||
recovery_monitor_teardown_hook: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
data_transport,
|
||||
#[cfg(feature = "internode-transport")]
|
||||
external_streams: false,
|
||||
timeout_logs: rustfs_utils::LogThrottle::new(REMOTE_DISK_RPC_FAILURE_LOG_INTERVAL_MS),
|
||||
network_error_logs: rustfs_utils::LogThrottle::new(REMOTE_DISK_RPC_FAILURE_LOG_INTERVAL_MS),
|
||||
};
|
||||
record_drive_runtime_state(ep, RuntimeDriveHealthState::Online);
|
||||
|
||||
@@ -1460,14 +1376,6 @@ impl RemoteDisk {
|
||||
Ok(disk)
|
||||
}
|
||||
|
||||
#[cfg(feature = "internode-transport")]
|
||||
pub(crate) fn with_instance_transport(mut self) -> Self {
|
||||
// Built-in names are reserved by the instance registration API. A host provider
|
||||
// owns its open, data verification and cancellation as one operation.
|
||||
self.external_streams = !rustfs_config::KNOWN_INTERNODE_DATA_TRANSPORT_BACKENDS.contains(&self.data_transport.name());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn runtime_state(&self) -> RuntimeDriveHealthState {
|
||||
self.health.runtime_state()
|
||||
}
|
||||
@@ -1491,10 +1399,6 @@ impl RemoteDisk {
|
||||
}
|
||||
|
||||
async fn open_write_with_retry(&self, request: WriteStreamRequest) -> Result<FileWriter> {
|
||||
#[cfg(feature = "internode-transport")]
|
||||
if self.external_streams {
|
||||
return self.data_transport.open_write(request).await;
|
||||
}
|
||||
let mut attempt = 1;
|
||||
let mut last_retry_classification = None;
|
||||
loop {
|
||||
@@ -2186,20 +2090,17 @@ impl RemoteDisk {
|
||||
"op" => op.to_string()
|
||||
)
|
||||
.increment(1);
|
||||
if let Some(suppressed) = self.timeout_logs.claim() {
|
||||
warn!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = timeout_duration.as_millis(),
|
||||
suppressed,
|
||||
state = "timeout",
|
||||
"Remote disk operation timed out"
|
||||
);
|
||||
}
|
||||
warn!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = timeout_duration.as_millis(),
|
||||
state = "timeout",
|
||||
"Remote disk operation timed out"
|
||||
);
|
||||
}
|
||||
|
||||
async fn handle_network_like_error<T>(
|
||||
@@ -2219,20 +2120,17 @@ impl RemoteDisk {
|
||||
"op" => op.to_string()
|
||||
)
|
||||
.increment(1);
|
||||
if let Some(suppressed) = self.network_error_logs.claim() {
|
||||
warn!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = timeout_duration.as_millis(),
|
||||
suppressed,
|
||||
state = "network_like_error",
|
||||
"Remote disk operation returned network-like error"
|
||||
);
|
||||
}
|
||||
warn!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REMOTE_DISK,
|
||||
endpoint = %self.endpoint,
|
||||
addr = %self.addr,
|
||||
op,
|
||||
timeout_ms = timeout_duration.as_millis(),
|
||||
state = "network_like_error",
|
||||
"Remote disk operation returned network-like error"
|
||||
);
|
||||
if failure_health_action == FailureHealthAction::MarkFailure {
|
||||
self.mark_faulty_and_evict("operation_network_error").await;
|
||||
}
|
||||
@@ -3741,17 +3639,13 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
Err(err) => {
|
||||
record_read_version_stage(INTERNODE_STAGE_READ_VERSION_RPC_ROUNDTRIP, rpc_started);
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error(
|
||||
crate::cluster::rpc::runtime_sources::ReadVersionErrorStage::RpcStatus,
|
||||
);
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error();
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
if !response.success {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error(
|
||||
crate::cluster::rpc::runtime_sources::ReadVersionErrorStage::RemoteResponse,
|
||||
);
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error();
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
|
||||
@@ -3770,9 +3664,7 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
Err(err) => {
|
||||
record_read_version_stage(INTERNODE_STAGE_READ_VERSION_RESPONSE_DECODE, decode_started);
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error(
|
||||
crate::cluster::rpc::runtime_sources::ReadVersionErrorStage::ResponseDecode,
|
||||
);
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_error();
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
@@ -3834,8 +3726,12 @@ impl DiskAPI for RemoteDisk {
|
||||
);
|
||||
}
|
||||
let rpc_started = read_version_stage_timer(batch_read_version_attribution_enabled);
|
||||
let response_result =
|
||||
measure_client_rpc(INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, client.batch_read_version(request)).await;
|
||||
let response_result = client.batch_read_version(request).await;
|
||||
if let Some(rpc_started) = rpc_started {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_duration(
|
||||
rpc_started.elapsed(),
|
||||
);
|
||||
}
|
||||
let response = match response_result {
|
||||
Ok(response) => {
|
||||
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, rpc_started);
|
||||
@@ -4228,10 +4124,6 @@ impl DiskAPI for RemoteDisk {
|
||||
length,
|
||||
stall_timeout: timeouts.body_stall,
|
||||
};
|
||||
#[cfg(feature = "internode-transport")]
|
||||
if self.external_streams {
|
||||
return self.data_transport.open_read(request).await;
|
||||
}
|
||||
let reader = self.open_read_with_retry(request.clone()).await?;
|
||||
Ok(Box::new(RetryingRemoteReader::new_with_timeouts(
|
||||
reader,
|
||||
@@ -4263,10 +4155,6 @@ impl DiskAPI for RemoteDisk {
|
||||
length,
|
||||
stall_timeout: timeouts.body_stall,
|
||||
};
|
||||
#[cfg(feature = "internode-transport")]
|
||||
if self.external_streams {
|
||||
return self.data_transport.open_read_chunks(request).await;
|
||||
}
|
||||
let reader = self.open_read_chunks_with_retry(request.clone()).await?;
|
||||
Ok(reader.map(|reader| {
|
||||
Box::new(RetryingRemoteChunkReader::new_with_timeouts(
|
||||
@@ -4485,7 +4373,6 @@ impl DiskAPI for RemoteDisk {
|
||||
opts: DeleteOptions,
|
||||
) -> Vec<Option<DiskError>> {
|
||||
let expected_len = versions.len();
|
||||
let started = Instant::now();
|
||||
let result = match self
|
||||
.recover_delete_transaction(volume, DeleteRecoveryRpcOperation::ApplyVersions { versions, opts })
|
||||
.await
|
||||
@@ -4494,7 +4381,6 @@ impl DiskAPI for RemoteDisk {
|
||||
Ok(_) => Err(Error::other("unexpected recoverable batch delete reply")),
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
log_delete_recovery_batch_result(&self.endpoint, expected_len, started, &result);
|
||||
match result {
|
||||
Ok(errors) => errors,
|
||||
Err(error) => vec![Some(error); expected_len],
|
||||
@@ -4861,7 +4747,9 @@ impl DiskAPI for RemoteDisk {
|
||||
read_multiple_req_bin: read_multiple_req_bin.into(),
|
||||
});
|
||||
|
||||
let response = measure_client_rpc(INTERNODE_OPERATION_GRPC_READ_MULTIPLE, client.read_multiple(request)).await;
|
||||
let rpc_started = Instant::now();
|
||||
let response = client.read_multiple(request).await;
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_multiple_duration(rpc_started.elapsed());
|
||||
let response = response?.into_inner();
|
||||
|
||||
if !response.success {
|
||||
@@ -4913,7 +4801,8 @@ impl DiskAPI for RemoteDisk {
|
||||
attach_mutation_body_digest(&mut request, canonical_body, "write_all")?;
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_request();
|
||||
let response = match measure_client_rpc(INTERNODE_OPERATION_GRPC_WRITE_ALL, client.write_all(request)).await {
|
||||
let rpc_started = Instant::now();
|
||||
let response = match client.write_all(request).await {
|
||||
Ok(response) => response.into_inner(),
|
||||
Err(err) => {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_error();
|
||||
@@ -4921,6 +4810,7 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
};
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_duration(rpc_started.elapsed());
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_write_all_sent_bytes(data_len);
|
||||
|
||||
if !response.success {
|
||||
@@ -5018,7 +4908,8 @@ impl DiskAPI for RemoteDisk {
|
||||
});
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_request();
|
||||
let response = match measure_client_rpc(INTERNODE_OPERATION_GRPC_READ_ALL, client.read_all(request)).await {
|
||||
let rpc_started = Instant::now();
|
||||
let response = match client.read_all(request).await {
|
||||
Ok(response) => response.into_inner(),
|
||||
Err(err) => {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_error();
|
||||
@@ -5026,6 +4917,8 @@ impl DiskAPI for RemoteDisk {
|
||||
}
|
||||
};
|
||||
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_duration(rpc_started.elapsed());
|
||||
|
||||
if !response.success {
|
||||
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_all_error();
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
@@ -5780,62 +5673,6 @@ mod tests {
|
||||
assert!(decode_delete_recovery_batch_errors(vec![Some(u32::MAX)], 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recoverable_batch_delete_diagnostics_preserve_context_and_redact_error_details() {
|
||||
let logs = CapturedLogs::default();
|
||||
let subscriber = Registry::default().with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(logs.clone())
|
||||
.with_ansi(false)
|
||||
.without_time()
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_span_list(true),
|
||||
);
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
let _callsite_pin = crate::test_tracing::pin_callsite_interest_for_test();
|
||||
let endpoint = Endpoint {
|
||||
url: url::Url::parse("http://127.0.0.1:9000/private-disk-path").expect("valid diagnostic endpoint"),
|
||||
is_local: false,
|
||||
pool_idx: 1,
|
||||
set_idx: 2,
|
||||
disk_idx: 3,
|
||||
};
|
||||
let rpc_failure = Err(DiskError::from(Status::deadline_exceeded("private-error-detail")));
|
||||
let group_failures = Ok(vec![None, Some(DiskError::FileNotFound), Some(DiskError::FileNotFound)]);
|
||||
tracing::info_span!("request-span", request_id = "req-batch-delete-diagnostics").in_scope(|| {
|
||||
log_delete_recovery_batch_result(&endpoint, 1000, Instant::now(), &rpc_failure);
|
||||
log_delete_recovery_batch_result(&endpoint, 3, Instant::now(), &group_failures);
|
||||
});
|
||||
let entries = logs.lines();
|
||||
assert_eq!(entries.len(), 2, "one summary per disk batch, never per object");
|
||||
assert_eq!(entries[0]["failure_scope"], "rpc_or_reply");
|
||||
assert_eq!(entries[0]["failed_group_count"], 1000);
|
||||
assert_eq!(entries[0]["rpc_status"], "Some(DeadlineExceeded)");
|
||||
assert_eq!(entries[1]["failure_scope"], "object_group");
|
||||
assert_eq!(entries[1]["failed_group_count"], 2);
|
||||
for entry in entries {
|
||||
assert_eq!(entry["pool_index"], 1);
|
||||
assert_eq!(entry["set_index"], 2);
|
||||
assert_eq!(entry["disk_index"], 3);
|
||||
assert!(
|
||||
entry["spans"]
|
||||
.as_array()
|
||||
.expect("request spans recorded")
|
||||
.iter()
|
||||
.any(|span| { span["request_id"] == "req-batch-delete-diagnostics" })
|
||||
);
|
||||
let text = entry.to_string();
|
||||
assert!(!text.contains("private-error-detail"));
|
||||
assert!(!text.contains("private-disk-path"));
|
||||
}
|
||||
assert!(rpc_failure.is_err(), "diagnostics borrow and preserve the original failure");
|
||||
assert_eq!(
|
||||
group_failures,
|
||||
Ok(vec![None, Some(DiskError::FileNotFound), Some(DiskError::FileNotFound)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_recovery_inspection_rejects_wrong_transaction_object_and_protocol() {
|
||||
let transaction = Uuid::new_v4();
|
||||
@@ -10417,81 +10254,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "internode-transport")]
|
||||
#[tokio::test]
|
||||
async fn peer_transport_open_returns_the_original_error_after_one_attempt() {
|
||||
let failure = || {
|
||||
OpenWriteTestStep::Error(DiskError::from(rustfs_rio::new_test_internode_http_io_error(
|
||||
rustfs_rio::InternodeHttpErrorKind::HttpStatus(reqwest::StatusCode::SERVICE_UNAVAILABLE),
|
||||
)))
|
||||
};
|
||||
let writer = RetryingOpenWriteInternodeDataTransport::with_steps(vec![failure(), OpenWriteTestStep::Success]);
|
||||
let disk = new_remote_disk_with_transport(Arc::new(writer.clone()))
|
||||
.await
|
||||
.with_instance_transport();
|
||||
let error = disk
|
||||
.create_file("", "bucket", "shard", 3)
|
||||
.await
|
||||
.err()
|
||||
.expect("provider write failure");
|
||||
assert_eq!(
|
||||
error.internode_http_error_kind(),
|
||||
Some(rustfs_rio::InternodeHttpErrorKind::HttpStatus(reqwest::StatusCode::SERVICE_UNAVAILABLE))
|
||||
);
|
||||
assert_eq!(writer.calls().len(), 1);
|
||||
let reader = RetryingOpenReadInternodeDataTransport::with_steps(vec![failure(), OpenWriteTestStep::Success]);
|
||||
let disk = new_remote_disk_with_transport(Arc::new(reader.clone()))
|
||||
.await
|
||||
.with_instance_transport();
|
||||
let error = disk
|
||||
.read_file_stream("bucket", "shard", 0, 3)
|
||||
.await
|
||||
.err()
|
||||
.expect("provider read failure");
|
||||
assert_eq!(
|
||||
error.internode_http_error_kind(),
|
||||
Some(rustfs_rio::InternodeHttpErrorKind::HttpStatus(reqwest::StatusCode::SERVICE_UNAVAILABLE))
|
||||
);
|
||||
assert_eq!(reader.calls().len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "internode-transport")]
|
||||
#[tokio::test]
|
||||
async fn peer_transport_body_failures_never_open_a_fresh_stream() {
|
||||
let provider = Arc::new(ResumeTransport::with_read_steps(vec![ResumeReadStep::PartialThenReset(b"x".to_vec())]));
|
||||
let disk = new_remote_disk_with_transport(provider.clone())
|
||||
.await
|
||||
.with_instance_transport();
|
||||
let mut reader = disk.read_file_stream("bucket", "shard", 0, 3).await.expect("reader");
|
||||
let mut bytes = Vec::new();
|
||||
let error = tokio::io::AsyncReadExt::read_to_end(&mut reader, &mut bytes)
|
||||
.await
|
||||
.expect_err("stream reset");
|
||||
assert_eq!(error.kind(), std_io::ErrorKind::ConnectionReset);
|
||||
assert_eq!(bytes, b"x");
|
||||
assert_eq!(provider.read_requests.lock().expect("recorded transport calls").len(), 1);
|
||||
assert!(provider.fresh_read_requests.lock().expect("recorded fresh calls").is_empty());
|
||||
let provider = Arc::new(ResumeTransport::with_chunk_steps(vec![ResumeReadStep::PartialThenReset(b"x".to_vec())]));
|
||||
let disk = new_remote_disk_with_transport(provider.clone())
|
||||
.await
|
||||
.with_instance_transport();
|
||||
let mut reader = disk
|
||||
.read_file_stream_chunks("bucket", "shard", 0, 3)
|
||||
.await
|
||||
.expect("chunked stream")
|
||||
.expect("supported");
|
||||
let chunk = std::future::poll_fn(|cx| Pin::new(reader.as_mut()).poll_read_chunk(cx, 16))
|
||||
.await
|
||||
.expect("first chunk");
|
||||
assert_eq!(chunk, Some(Bytes::from_static(b"x")));
|
||||
let error = std::future::poll_fn(|cx| Pin::new(reader.as_mut()).poll_read_chunk(cx, 16))
|
||||
.await
|
||||
.expect_err("chunk stream reset");
|
||||
assert_eq!(error.kind(), std_io::ErrorKind::ConnectionReset);
|
||||
assert_eq!(provider.chunk_requests.lock().expect("recorded transport calls").len(), 1);
|
||||
assert!(provider.fresh_chunk_requests.lock().expect("recorded fresh calls").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(internode_metrics)]
|
||||
async fn test_remote_disk_create_file_retries_once_on_retryable_open_write_error() {
|
||||
@@ -11926,66 +11688,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A peer that fails every operation logs one warning per interval, each
|
||||
/// operation still gets its own error, and another peer's first warning is
|
||||
/// not held back by the noisy one.
|
||||
#[tokio::test]
|
||||
async fn rpc_failure_warnings_are_bounded_per_remote_disk() {
|
||||
async fn remote_disk(port: u16) -> RemoteDisk {
|
||||
let url = url::Url::parse(&format!("http://127.0.0.1:{port}/data")).expect("endpoint url");
|
||||
let endpoint = Endpoint {
|
||||
url,
|
||||
is_local: false,
|
||||
pool_idx: 0,
|
||||
set_idx: 0,
|
||||
disk_idx: 0,
|
||||
};
|
||||
RemoteDisk::new(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
Arc::new(TcpHttpInternodeDataTransport),
|
||||
)
|
||||
.await
|
||||
.expect("remote disk handle")
|
||||
}
|
||||
let noisy = remote_disk(59981).await;
|
||||
let quiet = remote_disk(59982).await;
|
||||
|
||||
for _ in 0..50 {
|
||||
let error = noisy
|
||||
.execute_with_timeout_for_op_and_health_action(
|
||||
"read_all",
|
||||
|| async {
|
||||
Err::<(), Error>(DiskError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionRefused,
|
||||
"connection refused",
|
||||
)))
|
||||
},
|
||||
Duration::from_secs(1),
|
||||
FailureHealthAction::IgnoreFailure,
|
||||
)
|
||||
.await
|
||||
.expect_err("every failing operation still fails");
|
||||
assert!(matches!(error, DiskError::Io(io) if io.kind() == std::io::ErrorKind::ConnectionRefused));
|
||||
noisy.record_timeout("read_all", Duration::from_secs(1));
|
||||
}
|
||||
|
||||
assert!(
|
||||
noisy.network_error_logs.claim().is_none(),
|
||||
"the burst used this peer's network-error window"
|
||||
);
|
||||
assert!(noisy.timeout_logs.claim().is_none(), "the burst used this peer's timeout window");
|
||||
assert_eq!(
|
||||
quiet.network_error_logs.claim(),
|
||||
Some(0),
|
||||
"another peer's first warning is not suppressed"
|
||||
);
|
||||
assert_eq!(quiet.timeout_logs.claim(), Some(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_with_timeout_keeps_remote_disk_online_for_business_error() {
|
||||
let addr = "http://127.0.0.1:59994".to_string();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -481,6 +481,14 @@ pub(crate) fn record_remote_disk_grpc_write_all_request() {
|
||||
.record_outgoing_request_for_operation_and_backend(INTERNODE_OPERATION_GRPC_WRITE_ALL, INTERNODE_TRANSPORT_BACKEND_GRPC);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_write_all_duration(duration: Duration) {
|
||||
global_internode_metrics().record_duration_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_WRITE_ALL,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_write_all_sent_bytes(bytes: usize) {
|
||||
global_internode_metrics().record_sent_bytes_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_WRITE_ALL,
|
||||
@@ -521,6 +529,14 @@ pub(crate) fn record_remote_disk_grpc_read_all_request() {
|
||||
.record_outgoing_request_for_operation_and_backend(INTERNODE_OPERATION_GRPC_READ_ALL, INTERNODE_TRANSPORT_BACKEND_GRPC);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_read_all_duration(duration: Duration) {
|
||||
global_internode_metrics().record_duration_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_READ_ALL,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
fn read_version_request_mode(opts: &ReadOptions) -> ReadVersionRequestMode {
|
||||
match (opts.read_data, opts.healing, opts.incl_free_versions) {
|
||||
@@ -598,6 +614,17 @@ pub(crate) fn record_remote_disk_grpc_batch_read_version_stage(stage: &'static s
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_batch_read_version_duration(duration: Duration) {
|
||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
global_internode_metrics().record_duration_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_batch_read_version_error() {
|
||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
||||
return;
|
||||
@@ -629,34 +656,12 @@ pub(crate) fn record_remote_disk_grpc_batch_read_version_recv_bytes(bytes: usize
|
||||
record_grpc_payload_size(INTERNODE_OPERATION_GRPC_BATCH_READ_VERSION, bytes);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ReadVersionErrorStage {
|
||||
RpcStatus,
|
||||
RemoteResponse,
|
||||
ResponseDecode,
|
||||
}
|
||||
|
||||
impl ReadVersionErrorStage {
|
||||
fn classification(self) -> &'static str {
|
||||
match self {
|
||||
Self::RpcStatus => "grpc_status",
|
||||
Self::RemoteResponse => "remote_response",
|
||||
Self::ResponseDecode => "response_decode",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_read_version_error(stage: ReadVersionErrorStage) {
|
||||
pub(crate) fn record_remote_disk_grpc_read_version_error() {
|
||||
if !rustfs_io_metrics::get_stage_metrics_enabled() {
|
||||
return;
|
||||
}
|
||||
let metrics = global_internode_metrics();
|
||||
metrics.record_error_for_operation_and_backend(INTERNODE_OPERATION_GRPC_READ_VERSION, INTERNODE_TRANSPORT_BACKEND_GRPC);
|
||||
metrics.record_classified_error_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_READ_VERSION,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
stage.classification(),
|
||||
);
|
||||
global_internode_metrics()
|
||||
.record_error_for_operation_and_backend(INTERNODE_OPERATION_GRPC_READ_VERSION, INTERNODE_TRANSPORT_BACKEND_GRPC);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_read_version_sent_bytes(bytes: usize) {
|
||||
@@ -712,6 +717,14 @@ pub(crate) fn record_remote_disk_grpc_read_multiple_recv_bytes(bytes: usize) {
|
||||
record_grpc_payload_size(INTERNODE_OPERATION_GRPC_READ_MULTIPLE, bytes);
|
||||
}
|
||||
|
||||
pub(crate) fn record_remote_disk_grpc_read_multiple_duration(duration: Duration) {
|
||||
global_internode_metrics().record_duration_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_GRPC_READ_MULTIPLE,
|
||||
INTERNODE_TRANSPORT_BACKEND_GRPC,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
/// Payload-size threshold (bytes) above which a unary internode gRPC response is counted as a
|
||||
/// "large payload" for alerting. Env-overridable via `RUSTFS_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES`.
|
||||
fn internode_rpc_large_payload_warn_bytes() -> usize {
|
||||
@@ -791,53 +804,6 @@ mod tests {
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_version_errors_preserve_totals_and_classify_failure_boundaries() {
|
||||
use super::{ReadVersionErrorStage, record_remote_disk_grpc_read_version_error};
|
||||
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
|
||||
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
for stage in [
|
||||
ReadVersionErrorStage::RpcStatus,
|
||||
ReadVersionErrorStage::RemoteResponse,
|
||||
ReadVersionErrorStage::ResponseDecode,
|
||||
] {
|
||||
record_remote_disk_grpc_read_version_error(stage);
|
||||
}
|
||||
});
|
||||
rustfs_io_metrics::set_get_stage_metrics_enabled(false);
|
||||
let entries = snapshotter.snapshot().into_vec();
|
||||
let total = entries
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry.0.key().name() == "rustfs_system_network_internode_operation_errors_total"
|
||||
&& entry
|
||||
.0
|
||||
.key()
|
||||
.labels()
|
||||
.any(|label| label.key() == "operation" && label.value() == "grpc_read_version")
|
||||
})
|
||||
.expect("existing operation error counter");
|
||||
assert!(matches!(total.3, DebugValue::Counter(3)));
|
||||
for classification in ["grpc_status", "remote_response", "response_decode"] {
|
||||
let count = entries
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
entry.0.key().name() == "rustfs_system_network_internode_operation_classified_errors_total"
|
||||
&& entry
|
||||
.0
|
||||
.key()
|
||||
.labels()
|
||||
.any(|label| label.key() == "classification" && label.value() == classification)
|
||||
})
|
||||
.expect("bounded failure classification");
|
||||
assert!(matches!(count.3, DebugValue::Counter(1)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
fn diagnostic_operation(operation: rustfs_s3_ops::S3Operation) -> S3HttpOperationContext {
|
||||
let mut request = rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard::new("GET");
|
||||
|
||||
@@ -20945,12 +20945,12 @@ impl ECStore {
|
||||
let locator = parse_decommission_durable_ilm_receipt_path(&receipt_path)?;
|
||||
let mut proof = None::<DecommissionDurableIlmReceipt>;
|
||||
let mut nonterminal_receipt_found = false;
|
||||
for (pool_idx, pool) in self.pools.iter().enumerate() {
|
||||
for pool_idx in 0..self.pools.len() {
|
||||
if pool_idx == source_pool_idx {
|
||||
continue;
|
||||
}
|
||||
let data = match read_config_limited_preserve_empty(
|
||||
pool.clone(),
|
||||
self.pools[pool_idx].clone(),
|
||||
&receipt_path,
|
||||
DECOMMISSION_DURABLE_ILM_RECEIPT_MAX_SIZE,
|
||||
)
|
||||
@@ -28413,7 +28413,7 @@ mod tests {
|
||||
for wrapped in [
|
||||
err.clone(),
|
||||
data_movement::data_movement_stage_error_for_test("decommission_object", "put_object", "b", "o", err.clone()),
|
||||
with_decommission_entry_context("migrate_object", "b", "o", err),
|
||||
with_decommission_entry_context("migrate_object", "b", "o", err.clone()),
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_decommission_capacity_failure(&wrapped),
|
||||
@@ -40281,7 +40281,7 @@ mod pools_tests {
|
||||
)
|
||||
.expect("finalize records the completion");
|
||||
|
||||
let mut adopted = meta;
|
||||
let mut adopted = meta.clone();
|
||||
let plan = decommission_capacity_intent_adoption_plan(&adopted, 0, generation)
|
||||
.expect("plan adoption")
|
||||
.expect("the interrupted completion should be planned");
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::disk::error_reduce::count_errs;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
use crate::disk::new_disk;
|
||||
use crate::error::{Error, Result, is_all_volume_not_found, is_err_object_not_found, is_err_strict_volume_not_found};
|
||||
use crate::layout::set_heal::{formats_to_drives_info, new_heal_format_sets};
|
||||
use crate::multipart_listing::paginate_multipart_listing;
|
||||
@@ -30,6 +28,7 @@ use crate::{
|
||||
DiskAPI, DiskOption, DiskStore,
|
||||
error::DiskError,
|
||||
format::{DistributionAlgoVersion, FormatV3},
|
||||
new_disk,
|
||||
},
|
||||
error::StorageError,
|
||||
layout::endpoints::{Endpoints, PoolEndpoints},
|
||||
@@ -1106,7 +1105,6 @@ impl Sets {
|
||||
F: FnMut() -> bool + Send,
|
||||
{
|
||||
let (disks, init_errs) = init_storage_disks_with_errors(
|
||||
&self.ctx,
|
||||
&self.endpoints.endpoints,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
@@ -1312,7 +1310,6 @@ async fn _close_storage_disks(disks: &[Option<DiskStore>]) {
|
||||
}
|
||||
|
||||
async fn init_storage_disks_with_errors(
|
||||
instance: &InstanceContext,
|
||||
endpoints: &Endpoints,
|
||||
opts: &DiskOption,
|
||||
) -> (Vec<Option<DiskStore>>, Vec<Option<DiskError>>) {
|
||||
@@ -1321,7 +1318,7 @@ async fn init_storage_disks_with_errors(
|
||||
// let errs = Arc::new(RwLock::new(vec![None; endpoints.as_ref().len()]));
|
||||
let mut futures = Vec::with_capacity(endpoints.as_ref().len());
|
||||
for endpoint in endpoints.as_ref().iter() {
|
||||
futures.push(crate::disk::new_disk_for_instance(instance, endpoint, opts));
|
||||
futures.push(new_disk(endpoint, opts));
|
||||
|
||||
// let ep = endpoint.clone();
|
||||
// let opt = opts.clone();
|
||||
|
||||
@@ -4242,7 +4242,7 @@ mod tests {
|
||||
"compatible aliases for {suffix} should match"
|
||||
);
|
||||
|
||||
let mut source_without_marker = source;
|
||||
let mut source_without_marker = source.clone();
|
||||
rustfs_utils::http::remove_str(Arc::make_mut(&mut source_without_marker.user_defined), suffix);
|
||||
let mut target_with_generated_marker = source_without_marker.clone();
|
||||
rustfs_utils::http::insert_str(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user